From 9e28ffccd9551b5ca0b6b5c989c939c4225e2ac8 Mon Sep 17 00:00:00 2001 From: scab24 Date: Tue, 5 May 2026 12:47:47 +0200 Subject: [PATCH 001/115] chore(workspace): upgrade to Rust edition 2024 --- Cargo.toml | 6 ++++-- crates/ilold-cli/Cargo.toml | 4 ++-- crates/ilold-cli/src/explore.rs | 8 ++++---- crates/ilold-core/Cargo.toml | 4 ++-- crates/ilold-web/Cargo.toml | 4 ++-- crates/ilold-web/src/ws/search.rs | 4 ++-- 6 files changed, 16 insertions(+), 14 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2ca21b8..c295140 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,9 +6,11 @@ members = [ "crates/ilold-web", ] -# Shared dependency versions — referenced via { workspace = true } in member crates +[workspace.package] +edition = "2024" +version = "0.1.0" + [workspace.dependencies] -# Pinned: no API stability guarantee between patch versions solar = { version = "=0.1.8", package = "solar-compiler", default-features = false } petgraph = "0.8" serde = { version = "1", features = ["derive"] } diff --git a/crates/ilold-cli/Cargo.toml b/crates/ilold-cli/Cargo.toml index 2840f1e..fef4896 100644 --- a/crates/ilold-cli/Cargo.toml +++ b/crates/ilold-cli/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "ilold-cli" -version = "0.1.0" -edition = "2021" +version.workspace = true +edition.workspace = true description = "CLI for Ilold: smart contract execution path analyzer" [[bin]] diff --git a/crates/ilold-cli/src/explore.rs b/crates/ilold-cli/src/explore.rs index b5314d9..53584a6 100644 --- a/crates/ilold-cli/src/explore.rs +++ b/crates/ilold-cli/src/explore.rs @@ -200,7 +200,7 @@ fn repl_loop( contract = new_name.clone(); steps.clear(); scenario_name = "main".into(); - if let Some(ref state) = state { + if let Some(state) = state.as_ref() { // Local mode: use AppState directly if let Some(c) = state.project.contracts.iter().find(|c| c.name == new_name) { functions = state.project @@ -212,7 +212,7 @@ fn repl_loop( comp.functions = functions.clone(); } } - } else if let Some(ref fbc) = functions_by_contract { + } else if let Some(fbc) = functions_by_contract.as_ref() { // --attach mode: use cached per-contract function map if let Some(funcs) = fbc.get(&new_name) { functions = funcs.clone(); @@ -385,7 +385,7 @@ fn handle_input( } "ct" | "contracts" => { - if let Some(ref state) = state { + if let Some(state) = state { print_contracts(state, contract); } else { // --attach mode: fetch contract list from server @@ -414,7 +414,7 @@ fn handle_input( println!(" Usage: use "); return InputResult::Continue; } - if let Some(ref state) = state { + if let Some(state) = state { // Local mode match state.project.find_contract(Some(arg)) { Ok(c) => { diff --git a/crates/ilold-core/Cargo.toml b/crates/ilold-core/Cargo.toml index ce2061a..ab8082a 100644 --- a/crates/ilold-core/Cargo.toml +++ b/crates/ilold-core/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "ilold-core" -version = "0.1.0" -edition = "2021" +version.workspace = true +edition.workspace = true description = "Core library for Ilold: smart contract execution path analysis engine" [dependencies] diff --git a/crates/ilold-web/Cargo.toml b/crates/ilold-web/Cargo.toml index 8286d9a..1621d61 100644 --- a/crates/ilold-web/Cargo.toml +++ b/crates/ilold-web/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "ilold-web" -version = "0.1.0" -edition = "2021" +version.workspace = true +edition.workspace = true description = "Web server and interactive viewer for Ilold" [dependencies] diff --git a/crates/ilold-web/src/ws/search.rs b/crates/ilold-web/src/ws/search.rs index e52c6cb..27805b0 100644 --- a/crates/ilold-web/src/ws/search.rs +++ b/crates/ilold-web/src/ws/search.rs @@ -35,10 +35,10 @@ pub fn search_paths(state: &AppState, query: &SearchQuery) -> Vec let mut results = Vec::new(); for ((contract, function), path_tree) in &state.path_trees { - if let Some(ref filter_contract) = query.contract { + if let Some(filter_contract) = query.contract.as_ref() { if contract != filter_contract { continue; } } - if let Some(ref filter_function) = query.function { + if let Some(filter_function) = query.function.as_ref() { if function != filter_function { continue; } } From d5822c2c42df0b486ecfdf66e724995b48ad9ddc Mon Sep 17 00:00:00 2001 From: scab24 Date: Tue, 5 May 2026 12:53:41 +0200 Subject: [PATCH 002/115] refactor(core): extract session and journal to ilold-session-core ExplorationSession, ExplorationStep, StateMutation, ForkOrigin, AuditJournal and AssignOperator move to a new shared crate so ilold-solana-core can reuse them. FlowTree stays in ilold-core; ExplorationStep now keeps it as an opaque serde_json::Value alongside a new runtime_trace slot for Solana, and add_step_with_internals becomes the free function add_solidity_step. --- Cargo.lock | 9 ++ Cargo.toml | 1 + crates/ilold-core/Cargo.toml | 1 + .../src/exploration/add_step_solidity.rs | 79 ++++++++++++++++ crates/ilold-core/src/exploration/commands.rs | 6 +- crates/ilold-core/src/exploration/mod.rs | 3 +- crates/ilold-core/src/exploration/timeline.rs | 8 +- crates/ilold-core/src/lib.rs | 2 +- crates/ilold-core/src/model/expression.rs | 35 +------ crates/ilold-session-core/Cargo.toml | 9 ++ .../src/exploration/assign_operator.rs | 34 +++++++ .../ilold-session-core/src/exploration/mod.rs | 2 + .../src/exploration/session.rs | 91 ++----------------- .../src/journal/export.rs | 0 .../src/journal/mod.rs | 0 .../src/journal/types.rs | 0 crates/ilold-session-core/src/lib.rs | 3 + .../ilold-session-core/src/runtime_trace.rs | 43 +++++++++ crates/ilold-web/src/api/session.rs | 2 +- 19 files changed, 204 insertions(+), 124 deletions(-) create mode 100644 crates/ilold-core/src/exploration/add_step_solidity.rs create mode 100644 crates/ilold-session-core/Cargo.toml create mode 100644 crates/ilold-session-core/src/exploration/assign_operator.rs create mode 100644 crates/ilold-session-core/src/exploration/mod.rs rename crates/{ilold-core => ilold-session-core}/src/exploration/session.rs (68%) rename crates/{ilold-core => ilold-session-core}/src/journal/export.rs (100%) rename crates/{ilold-core => ilold-session-core}/src/journal/mod.rs (100%) rename crates/{ilold-core => ilold-session-core}/src/journal/types.rs (100%) create mode 100644 crates/ilold-session-core/src/lib.rs create mode 100644 crates/ilold-session-core/src/runtime_trace.rs diff --git a/Cargo.lock b/Cargo.lock index 76641b0..1aed8ce 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1705,6 +1705,7 @@ dependencies = [ name = "ilold-core" version = "0.1.0" dependencies = [ + "ilold-session-core", "petgraph", "serde", "serde_json", @@ -1712,6 +1713,14 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ilold-session-core" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "ilold-web" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index c295140..5b50cea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/ilold-core", "crates/ilold-cli", "crates/ilold-web", + "crates/ilold-session-core", ] [workspace.package] diff --git a/crates/ilold-core/Cargo.toml b/crates/ilold-core/Cargo.toml index ab8082a..ccc7491 100644 --- a/crates/ilold-core/Cargo.toml +++ b/crates/ilold-core/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true description = "Core library for Ilold: smart contract execution path analysis engine" [dependencies] +ilold-session-core = { path = "../ilold-session-core" } solar = { workspace = true } petgraph = { workspace = true } serde = { workspace = true } diff --git a/crates/ilold-core/src/exploration/add_step_solidity.rs b/crates/ilold-core/src/exploration/add_step_solidity.rs new file mode 100644 index 0000000..0626728 --- /dev/null +++ b/crates/ilold-core/src/exploration/add_step_solidity.rs @@ -0,0 +1,79 @@ +use std::collections::HashMap; + +use ilold_session_core::exploration::session::{ + ExplorationSession, ExplorationStep, MutationScope, StateMutation, TraceConfig, +}; +use ilold_session_core::journal::types::JournalEntry; + +use crate::cfg::types::CfgGraph; +use crate::model::common::StateVar; +use crate::model::contract::ContractDef; +use crate::model::function::FunctionDef; +use crate::model::project::Project; +use crate::narrative::trace::{build_flow_tree_with_mutations, FlowConfig}; + +#[allow(clippy::too_many_arguments)] +pub fn add_solidity_step<'a>( + session: &'a mut ExplorationSession, + function: &FunctionDef, + cfg: &CfgGraph, + state_vars: &[StateVar], + project: &Project, + owning_contract: &ContractDef, + all_cfgs: &HashMap<(String, String), CfgGraph>, + timestamp: &str, + trace_config: TraceConfig, +) -> &'a ExplorationStep { + let step_index = session.steps.len(); + + let flow_config = FlowConfig { + max_depth: trace_config.depth, + include_reverts: trace_config.include_reverts, + expand_set: trace_config.expand_set.iter().copied().collect(), + }; + + let (flow_tree, raw_mutations) = build_flow_tree_with_mutations( + owning_contract, + function, + cfg, + project, + all_cfgs, + &flow_config, + ); + + let mutations: Vec = raw_mutations + .into_iter() + .filter_map(|fm| { + let base = crate::util::target_base_name(&fm.target); + if !state_vars.iter().any(|sv| sv.name == base) { + return None; + } + Some(StateMutation { + variable: fm.target, + operator: fm.operator, + value_expr: fm.value, + step_index, + via: fm.via, + flow_step_id: Some(fm.flow_step_id), + scope: MutationScope::State, + }) + }) + .collect(); + + let flow_tree_value = serde_json::to_value(&flow_tree).ok(); + + session.steps.push(ExplorationStep { + function: function.name.clone(), + mutations, + flow_tree: flow_tree_value, + trace_config, + runtime_trace: None, + }); + + session.journal.record(JournalEntry::SequenceExplored { + steps: session.steps.iter().map(|s| s.function.clone()).collect(), + timestamp: timestamp.into(), + }); + + session.steps.last().unwrap() +} diff --git a/crates/ilold-core/src/exploration/commands.rs b/crates/ilold-core/src/exploration/commands.rs index 5196c9d..fac19de 100644 --- a/crates/ilold-core/src/exploration/commands.rs +++ b/crates/ilold-core/src/exploration/commands.rs @@ -441,7 +441,8 @@ fn execute_call( let access = crate::classify::entry_points::classify_function(function_def, owning_contract); let combined_state_vars = data.project.inherited_state_vars(data.contract); - session.add_step_with_internals( + crate::exploration::add_step_solidity::add_solidity_step( + session, function_def, cfg, &combined_state_vars, @@ -727,7 +728,8 @@ pub fn get_sequence_narrative( for (i, step) in narrative.steps.iter_mut().enumerate() { if let Some(session_step) = session.steps.get(i) { step.flow_summary = session_step.flow_tree.as_ref() - .map(|tree| compute_flow_summary(tree, i)); + .and_then(|v| serde_json::from_value::(v.clone()).ok()) + .map(|tree| compute_flow_summary(&tree, i)); } } diff --git a/crates/ilold-core/src/exploration/mod.rs b/crates/ilold-core/src/exploration/mod.rs index 5f3c029..46633d4 100644 --- a/crates/ilold-core/src/exploration/mod.rs +++ b/crates/ilold-core/src/exploration/mod.rs @@ -1,3 +1,4 @@ -pub mod session; +pub use ilold_session_core::exploration::session; pub mod commands; pub mod timeline; +pub mod add_step_solidity; diff --git a/crates/ilold-core/src/exploration/timeline.rs b/crates/ilold-core/src/exploration/timeline.rs index ba0bbf8..0de5eb7 100644 --- a/crates/ilold-core/src/exploration/timeline.rs +++ b/crates/ilold-core/src/exploration/timeline.rs @@ -50,8 +50,11 @@ pub fn build_variable_timeline( } let reached_when = match (mutation.flow_step_id, &step.flow_tree) { - (Some(flow_id), Some(tree)) => { - collect_path_conditions(tree, flow_id).unwrap_or_default() + (Some(flow_id), Some(tree_value)) => { + serde_json::from_value::(tree_value.clone()) + .ok() + .and_then(|tree| collect_path_conditions(&tree, flow_id)) + .unwrap_or_default() } _ => Vec::new(), }; @@ -117,6 +120,7 @@ mod tests { mutations, flow_tree: None, trace_config: TraceConfig::default(), + runtime_trace: None, } } diff --git a/crates/ilold-core/src/lib.rs b/crates/ilold-core/src/lib.rs index 2b014ea..2276609 100644 --- a/crates/ilold-core/src/lib.rs +++ b/crates/ilold-core/src/lib.rs @@ -11,6 +11,6 @@ pub mod sequence; pub mod classify; pub mod narrative; pub mod slicing; -pub mod journal; +pub use ilold_session_core::journal; pub mod exploration; pub mod util; diff --git a/crates/ilold-core/src/model/expression.rs b/crates/ilold-core/src/model/expression.rs index 52bcfc0..17788b0 100644 --- a/crates/ilold-core/src/model/expression.rs +++ b/crates/ilold-core/src/model/expression.rs @@ -141,40 +141,7 @@ impl UnaryOperator { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] -pub enum AssignOperator { - Assign, - AddAssign, - SubAssign, - MulAssign, - DivAssign, - ModAssign, - BitAndAssign, - BitOrAssign, - BitXorAssign, - ShlAssign, - ShrAssign, -} - -impl AssignOperator { - /// Solidity source-form symbol for the assignment operator - /// (e.g. `AddAssign` → `"+="`, `Assign` → `"="`). - pub fn as_str(self) -> &'static str { - match self { - AssignOperator::Assign => "=", - AssignOperator::AddAssign => "+=", - AssignOperator::SubAssign => "-=", - AssignOperator::MulAssign => "*=", - AssignOperator::DivAssign => "/=", - AssignOperator::ModAssign => "%=", - AssignOperator::BitAndAssign => "&=", - AssignOperator::BitOrAssign => "|=", - AssignOperator::BitXorAssign => "^=", - AssignOperator::ShlAssign => "<<=", - AssignOperator::ShrAssign => ">>=", - } - } -} +pub use ilold_session_core::exploration::assign_operator::AssignOperator; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum LiteralType { diff --git a/crates/ilold-session-core/Cargo.toml b/crates/ilold-session-core/Cargo.toml new file mode 100644 index 0000000..9aafa11 --- /dev/null +++ b/crates/ilold-session-core/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "ilold-session-core" +version.workspace = true +edition.workspace = true +description = "Language-agnostic session and audit-journal types shared by ilold-core (Solidity) and ilold-solana-core." + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/ilold-session-core/src/exploration/assign_operator.rs b/crates/ilold-session-core/src/exploration/assign_operator.rs new file mode 100644 index 0000000..50bd1fb --- /dev/null +++ b/crates/ilold-session-core/src/exploration/assign_operator.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AssignOperator { + Assign, + AddAssign, + SubAssign, + MulAssign, + DivAssign, + ModAssign, + BitAndAssign, + BitOrAssign, + BitXorAssign, + ShlAssign, + ShrAssign, +} + +impl AssignOperator { + pub fn as_str(self) -> &'static str { + match self { + AssignOperator::Assign => "=", + AssignOperator::AddAssign => "+=", + AssignOperator::SubAssign => "-=", + AssignOperator::MulAssign => "*=", + AssignOperator::DivAssign => "/=", + AssignOperator::ModAssign => "%=", + AssignOperator::BitAndAssign => "&=", + AssignOperator::BitOrAssign => "|=", + AssignOperator::BitXorAssign => "^=", + AssignOperator::ShlAssign => "<<=", + AssignOperator::ShrAssign => ">>=", + } + } +} diff --git a/crates/ilold-session-core/src/exploration/mod.rs b/crates/ilold-session-core/src/exploration/mod.rs new file mode 100644 index 0000000..1982598 --- /dev/null +++ b/crates/ilold-session-core/src/exploration/mod.rs @@ -0,0 +1,2 @@ +pub mod assign_operator; +pub mod session; diff --git a/crates/ilold-core/src/exploration/session.rs b/crates/ilold-session-core/src/exploration/session.rs similarity index 68% rename from crates/ilold-core/src/exploration/session.rs rename to crates/ilold-session-core/src/exploration/session.rs index 46f6c7d..08ec8f6 100644 --- a/crates/ilold-core/src/exploration/session.rs +++ b/crates/ilold-session-core/src/exploration/session.rs @@ -2,32 +2,18 @@ use std::collections::HashMap; use serde::{Deserialize, Serialize}; -use crate::cfg::types::CfgGraph; +use crate::exploration::assign_operator::AssignOperator; use crate::journal::types::AuditJournal; -use crate::model::common::StateVar; -use crate::model::contract::ContractDef; -use crate::model::expression::AssignOperator; -use crate::model::function::FunctionDef; -use crate::model::project::Project; -use crate::narrative::trace::{build_flow_tree_with_mutations, FlowConfig, FlowTree}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ExplorationSession { pub contract: String, pub steps: Vec, pub journal: AuditJournal, - /// Set when this scenario was created via `scenario fork`. Records the - /// source scenario and the at-step boundary so the frontend can render - /// the fork as a branch from the origin instead of a parallel timeline. - /// `None` for the default `main` scenario and for any scenario loaded - /// from a pre-fork-origin save file (serde defaults to `None`). #[serde(default)] pub forked_from: Option, } -/// Where a scenario was forked from. The `at_step` is the boundary: the -/// scenario's steps `[0..at_step)` are the inherited prefix (a clone of the -/// origin's steps at fork time), and steps `[at_step..]` are its own. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ForkOrigin { pub scenario: String, @@ -39,9 +25,11 @@ pub struct ExplorationStep { pub function: String, pub mutations: Vec, #[serde(default)] - pub flow_tree: Option, + pub flow_tree: Option, #[serde(default)] pub trace_config: TraceConfig, + #[serde(default)] + pub runtime_trace: Option, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] @@ -97,73 +85,6 @@ impl ExplorationSession { } } - /// Append a step, building and persisting its FlowTree. Each harvested - /// mutation carries a `flow_step_id` into that tree. - #[allow(clippy::too_many_arguments)] - pub fn add_step_with_internals( - &mut self, - function: &FunctionDef, - cfg: &CfgGraph, - state_vars: &[StateVar], - project: &Project, - owning_contract: &ContractDef, - all_cfgs: &HashMap<(String, String), CfgGraph>, - timestamp: &str, - trace_config: TraceConfig, - ) -> &ExplorationStep { - let step_index = self.steps.len(); - - let flow_config = FlowConfig { - max_depth: trace_config.depth, - include_reverts: trace_config.include_reverts, - expand_set: trace_config.expand_set.iter().copied().collect(), - }; - - let (flow_tree, raw_mutations) = build_flow_tree_with_mutations( - owning_contract, - function, - cfg, - project, - all_cfgs, - &flow_config, - ); - - // Walker is scope-agnostic; keep only writes whose base name is a - // state var and convert into the session-level mutation type. - let mutations: Vec = raw_mutations - .into_iter() - .filter_map(|fm| { - let base = crate::util::target_base_name(&fm.target); - if !state_vars.iter().any(|sv| sv.name == base) { - return None; - } - Some(StateMutation { - variable: fm.target, - operator: fm.operator, - value_expr: fm.value, - step_index, - via: fm.via, - flow_step_id: Some(fm.flow_step_id), - scope: MutationScope::State, - }) - }) - .collect(); - - self.steps.push(ExplorationStep { - function: function.name.clone(), - mutations, - flow_tree: Some(flow_tree), - trace_config, - }); - - self.journal.record(crate::journal::types::JournalEntry::SequenceExplored { - steps: self.steps.iter().map(|s| s.function.clone()).collect(), - timestamp: timestamp.into(), - }); - - self.steps.last().unwrap() - } - pub fn remove_last_step(&mut self) -> bool { self.steps.pop().is_some() } @@ -247,12 +168,14 @@ mod tests { mutations: vec![], flow_tree: None, trace_config: TraceConfig::default(), + runtime_trace: None, }); s.steps.push(ExplorationStep { function: "withdraw".into(), mutations: vec![], flow_tree: None, trace_config: TraceConfig::default(), + runtime_trace: None, }); assert_eq!(s.current_sequence(), vec!["deposit", "withdraw"]); s.clear(); @@ -286,6 +209,7 @@ mod tests { ], flow_tree: None, trace_config: TraceConfig::default(), + runtime_trace: None, }); s.steps.push(ExplorationStep { function: "withdraw".into(), @@ -302,6 +226,7 @@ mod tests { ], flow_tree: None, trace_config: TraceConfig::default(), + runtime_trace: None, }); let summaries = s.variable_summary(); diff --git a/crates/ilold-core/src/journal/export.rs b/crates/ilold-session-core/src/journal/export.rs similarity index 100% rename from crates/ilold-core/src/journal/export.rs rename to crates/ilold-session-core/src/journal/export.rs diff --git a/crates/ilold-core/src/journal/mod.rs b/crates/ilold-session-core/src/journal/mod.rs similarity index 100% rename from crates/ilold-core/src/journal/mod.rs rename to crates/ilold-session-core/src/journal/mod.rs diff --git a/crates/ilold-core/src/journal/types.rs b/crates/ilold-session-core/src/journal/types.rs similarity index 100% rename from crates/ilold-core/src/journal/types.rs rename to crates/ilold-session-core/src/journal/types.rs diff --git a/crates/ilold-session-core/src/lib.rs b/crates/ilold-session-core/src/lib.rs new file mode 100644 index 0000000..e3a619f --- /dev/null +++ b/crates/ilold-session-core/src/lib.rs @@ -0,0 +1,3 @@ +pub mod exploration; +pub mod journal; +pub mod runtime_trace; diff --git a/crates/ilold-session-core/src/runtime_trace.rs b/crates/ilold-session-core/src/runtime_trace.rs new file mode 100644 index 0000000..7e28259 --- /dev/null +++ b/crates/ilold-session-core/src/runtime_trace.rs @@ -0,0 +1,43 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct RuntimeTrace { + pub logs: Vec, + pub compute_units: u64, + pub inner_instructions: Vec, + pub account_diffs: Vec, + #[serde(default)] + pub return_data: Option>, + #[serde(default)] + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InnerInstruction { + pub program: String, + pub instruction: String, + pub depth: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountDiff { + pub address: String, + #[serde(default)] + pub name: Option, + #[serde(default)] + pub before: Option>, + #[serde(default)] + pub after: Option>, + pub lamports_delta: i128, + pub owner_changed: bool, + #[serde(default)] + pub decoded_before: Option, + #[serde(default)] + pub decoded_after: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DecodedAccount { + pub type_name: String, + pub value: serde_json::Value, +} diff --git a/crates/ilold-web/src/api/session.rs b/crates/ilold-web/src/api/session.rs index 6d616f2..50f3b7b 100644 --- a/crates/ilold-web/src/api/session.rs +++ b/crates/ilold-web/src/api/session.rs @@ -335,7 +335,7 @@ pub async fn get_step_detail( pub async fn get_session_step_trace( State(state): State>, Path(step_index): Path, -) -> Result, (StatusCode, String)> { +) -> Result, (StatusCode, String)> { let scenarios_guard = state.scenarios.read().unwrap(); let session = scenarios_guard.active_session(); From 633f518ae485b93e7e01dd91817a9f0746682385 Mon Sep 17 00:00:00 2001 From: scab24 Date: Tue, 5 May 2026 13:30:30 +0200 Subject: [PATCH 003/115] feat(solana-core): crate skeleton reusing anchor-lang-idl via git dep Add ilold-solana-core that consumes anchor-lang-idl from the upstream Anchor monorepo at tag v1.0.2 instead of hand-rolling the IDL types. The crate exposes parse_idl and parse_idl_dir on top of the upstream types and ships its own SolanaError. Real lever and relations IDL fixtures drive five end-to-end tests covering basic shape, PDA seeds, composite account groups, sorted directory walks and invalid-JSON error paths. --- Cargo.lock | 64 ++++++++ Cargo.toml | 1 + crates/ilold-solana-core/Cargo.toml | 12 ++ crates/ilold-solana-core/src/error.rs | 24 +++ crates/ilold-solana-core/src/idl/mod.rs | 5 + crates/ilold-solana-core/src/idl/parse.rs | 34 ++++ crates/ilold-solana-core/src/idl/types.rs | 1 + crates/ilold-solana-core/src/lib.rs | 2 + .../tests/fixtures/lever.json | 68 ++++++++ .../tests/fixtures/relations.json | 151 ++++++++++++++++++ crates/ilold-solana-core/tests/idl_parse.rs | 78 +++++++++ 11 files changed, 440 insertions(+) create mode 100644 crates/ilold-solana-core/Cargo.toml create mode 100644 crates/ilold-solana-core/src/error.rs create mode 100644 crates/ilold-solana-core/src/idl/mod.rs create mode 100644 crates/ilold-solana-core/src/idl/parse.rs create mode 100644 crates/ilold-solana-core/src/idl/types.rs create mode 100644 crates/ilold-solana-core/src/lib.rs create mode 100644 crates/ilold-solana-core/tests/fixtures/lever.json create mode 100644 crates/ilold-solana-core/tests/fixtures/relations.json create mode 100644 crates/ilold-solana-core/tests/idl_parse.rs diff --git a/Cargo.lock b/Cargo.lock index 1aed8ce..f27e021 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -15,6 +15,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -80,6 +89,27 @@ dependencies = [ "winnow 0.7.15", ] +[[package]] +name = "anchor-lang-idl" +version = "0.1.2" +source = "git+https://github.com/solana-foundation/anchor?tag=v1.0.2#1314a6b83b16e6a31947b372d57988fd0e81559c" +dependencies = [ + "anchor-lang-idl-spec", + "anyhow", + "regex", + "serde", + "serde_json", +] + +[[package]] +name = "anchor-lang-idl-spec" +version = "0.1.0" +source = "git+https://github.com/solana-foundation/anchor?tag=v1.0.2#1314a6b83b16e6a31947b372d57988fd0e81559c" +dependencies = [ + "anyhow", + "serde", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -1721,6 +1751,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "ilold-solana-core" +version = "0.1.0" +dependencies = [ + "anchor-lang-idl", + "anyhow", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "ilold-web" version = "0.1.0" @@ -2696,6 +2737,29 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + [[package]] name = "regex-syntax" version = "0.8.10" diff --git a/Cargo.toml b/Cargo.toml index 5b50cea..2b21757 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ members = [ "crates/ilold-cli", "crates/ilold-web", "crates/ilold-session-core", + "crates/ilold-solana-core", ] [workspace.package] diff --git a/crates/ilold-solana-core/Cargo.toml b/crates/ilold-solana-core/Cargo.toml new file mode 100644 index 0000000..c5043d7 --- /dev/null +++ b/crates/ilold-solana-core/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "ilold-solana-core" +version.workspace = true +edition.workspace = true +description = "Anchor IDL parser and (later) Solana execution backend for Ilold." + +[dependencies] +anchor-lang-idl = { git = "https://github.com/solana-foundation/anchor", tag = "v1.0.2", features = ["build"] } +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } diff --git a/crates/ilold-solana-core/src/error.rs b/crates/ilold-solana-core/src/error.rs new file mode 100644 index 0000000..d95aab5 --- /dev/null +++ b/crates/ilold-solana-core/src/error.rs @@ -0,0 +1,24 @@ +use std::path::PathBuf; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum SolanaError { + #[error("failed to read IDL file '{path}': {source}")] + IdlReadFailed { + path: PathBuf, + #[source] + source: std::io::Error, + }, + + #[error("failed to parse IDL JSON: {0}")] + IdlParseFailed(#[from] serde_json::Error), + + #[error("IDL spec '{0}' is not a recognized version")] + UnsupportedIdlSpec(String), + + #[error("IDL type '{0}' uses generics, which are not supported in MVP")] + UnsupportedGeneric(String), + + #[error("IDL address '{0}' is not a valid base58 pubkey")] + InvalidProgramId(String), +} diff --git a/crates/ilold-solana-core/src/idl/mod.rs b/crates/ilold-solana-core/src/idl/mod.rs new file mode 100644 index 0000000..edeb16e --- /dev/null +++ b/crates/ilold-solana-core/src/idl/mod.rs @@ -0,0 +1,5 @@ +pub mod parse; +pub mod types; + +pub use parse::{parse_idl, parse_idl_dir}; +pub use types::*; diff --git a/crates/ilold-solana-core/src/idl/parse.rs b/crates/ilold-solana-core/src/idl/parse.rs new file mode 100644 index 0000000..aad7436 --- /dev/null +++ b/crates/ilold-solana-core/src/idl/parse.rs @@ -0,0 +1,34 @@ +use std::path::{Path, PathBuf}; + +use anchor_lang_idl::types::Idl; + +use crate::error::SolanaError; + +pub fn parse_idl(json: &str) -> Result { + serde_json::from_str(json).map_err(SolanaError::IdlParseFailed) +} + +pub fn parse_idl_dir(dir: &Path) -> Result, SolanaError> { + let mut idls = Vec::new(); + let entries = std::fs::read_dir(dir).map_err(|e| SolanaError::IdlReadFailed { + path: dir.to_path_buf(), + source: e, + })?; + for entry in entries { + let entry = entry.map_err(|e| SolanaError::IdlReadFailed { + path: dir.to_path_buf(), + source: e, + })?; + let path = entry.path(); + if path.extension().is_some_and(|ext| ext == "json") { + let json = std::fs::read_to_string(&path).map_err(|e| SolanaError::IdlReadFailed { + path: path.clone(), + source: e, + })?; + let idl = parse_idl(&json)?; + idls.push((path, idl)); + } + } + idls.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(idls) +} diff --git a/crates/ilold-solana-core/src/idl/types.rs b/crates/ilold-solana-core/src/idl/types.rs new file mode 100644 index 0000000..d512170 --- /dev/null +++ b/crates/ilold-solana-core/src/idl/types.rs @@ -0,0 +1 @@ +pub use anchor_lang_idl::types::*; diff --git a/crates/ilold-solana-core/src/lib.rs b/crates/ilold-solana-core/src/lib.rs new file mode 100644 index 0000000..f123ef5 --- /dev/null +++ b/crates/ilold-solana-core/src/lib.rs @@ -0,0 +1,2 @@ +pub mod error; +pub mod idl; diff --git a/crates/ilold-solana-core/tests/fixtures/lever.json b/crates/ilold-solana-core/tests/fixtures/lever.json new file mode 100644 index 0000000..3ec862d --- /dev/null +++ b/crates/ilold-solana-core/tests/fixtures/lever.json @@ -0,0 +1,68 @@ +{ + "address": "E64FVeubGC4NPNF2UBJYX4AkrVowf74fRJD9q6YhwstN", + "metadata": { + "name": "lever", + "version": "0.1.0", + "spec": "0.1.0", + "description": "Created with Anchor" + }, + "instructions": [ + { + "name": "initialize", + "discriminator": [175, 175, 109, 31, 13, 152, 155, 237], + "accounts": [ + { + "name": "power", + "writable": true, + "signer": true + }, + { + "name": "user", + "writable": true, + "signer": true + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [] + }, + { + "name": "switch_power", + "discriminator": [226, 238, 56, 172, 191, 45, 122, 87], + "accounts": [ + { + "name": "power", + "writable": true + } + ], + "args": [ + { + "name": "name", + "type": "string" + } + ] + } + ], + "accounts": [ + { + "name": "PowerStatus", + "discriminator": [145, 147, 198, 35, 253, 101, 231, 26] + } + ], + "types": [ + { + "name": "PowerStatus", + "type": { + "kind": "struct", + "fields": [ + { + "name": "is_on", + "type": "bool" + } + ] + } + } + ] +} diff --git a/crates/ilold-solana-core/tests/fixtures/relations.json b/crates/ilold-solana-core/tests/fixtures/relations.json new file mode 100644 index 0000000..6418651 --- /dev/null +++ b/crates/ilold-solana-core/tests/fixtures/relations.json @@ -0,0 +1,151 @@ +{ + "address": "Re1ationsDerivation111111111111111111111111", + "metadata": { + "name": "relations_derivation", + "version": "0.1.0", + "spec": "0.1.0", + "description": "Created with Anchor" + }, + "instructions": [ + { + "name": "init_base", + "discriminator": [ + 85, + 87, + 185, + 141, + 241, + 191, + 213, + 88 + ], + "accounts": [ + { + "name": "my_account", + "writable": true, + "signer": true + }, + { + "name": "account", + "writable": true, + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 115, + 101, + 101, + 100 + ] + } + ] + } + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [] + }, + { + "name": "test_relation", + "discriminator": [ + 247, + 199, + 255, + 202, + 7, + 0, + 197, + 158 + ], + "accounts": [ + { + "name": "my_account", + "relations": [ + "account" + ] + }, + { + "name": "account", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 115, + 101, + 101, + 100 + ] + } + ] + } + }, + { + "name": "nested", + "accounts": [ + { + "name": "my_account", + "relations": [ + "account" + ] + }, + { + "name": "account", + "pda": { + "seeds": [ + { + "kind": "const", + "value": [ + 115, + 101, + 101, + 100 + ] + } + ] + } + } + ] + } + ], + "args": [] + } + ], + "accounts": [ + { + "name": "MyAccount", + "discriminator": [ + 246, + 28, + 6, + 87, + 251, + 45, + 50, + 42 + ] + } + ], + "types": [ + { + "name": "MyAccount", + "type": { + "kind": "struct", + "fields": [ + { + "name": "my_account", + "type": "pubkey" + }, + { + "name": "bump", + "type": "u8" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/crates/ilold-solana-core/tests/idl_parse.rs b/crates/ilold-solana-core/tests/idl_parse.rs new file mode 100644 index 0000000..32efacf --- /dev/null +++ b/crates/ilold-solana-core/tests/idl_parse.rs @@ -0,0 +1,78 @@ +use std::path::Path; + +use anchor_lang_idl::types::{IdlInstructionAccountItem, IdlSeed}; +use ilold_solana_core::error::SolanaError; +use ilold_solana_core::idl::{parse_idl, parse_idl_dir}; + +#[test] +fn parse_lever_basic_shape() { + let json = include_str!("fixtures/lever.json"); + let idl = parse_idl(json).expect("lever IDL should parse"); + + assert_eq!(idl.metadata.name, "lever"); + assert_eq!(idl.address, "E64FVeubGC4NPNF2UBJYX4AkrVowf74fRJD9q6YhwstN"); + assert_eq!(idl.instructions.len(), 2); + assert_eq!(idl.instructions[0].name, "initialize"); + assert_eq!(idl.instructions[1].name, "switch_power"); + assert_eq!(idl.accounts.len(), 1); + assert_eq!(idl.accounts[0].name, "PowerStatus"); + assert_eq!(idl.instructions[0].discriminator.len(), 8); + assert_eq!(idl.accounts[0].discriminator.len(), 8); +} + +#[test] +fn parse_relations_has_pdas_and_composites() { + let json = include_str!("fixtures/relations.json"); + let idl = parse_idl(json).expect("relations IDL should parse"); + + assert_eq!(idl.metadata.name, "relations_derivation"); + + let pdas: Vec<_> = idl + .instructions + .iter() + .flat_map(|ix| ix.accounts.iter()) + .filter_map(|a| match a { + IdlInstructionAccountItem::Single(s) => s.pda.as_ref(), + IdlInstructionAccountItem::Composite(_) => None, + }) + .collect(); + assert!(pdas.len() >= 2, "expected multiple PDAs, got {}", pdas.len()); + + let composite_count = idl + .instructions + .iter() + .flat_map(|ix| ix.accounts.iter()) + .filter(|a| matches!(a, IdlInstructionAccountItem::Composite(_))) + .count(); + assert!(composite_count >= 1, "expected at least one composite account group"); + + let has_const_seed = pdas + .iter() + .flat_map(|p| &p.seeds) + .any(|s| matches!(s, IdlSeed::Const(_))); + assert!(has_const_seed, "expected at least one Const seed"); +} + +#[test] +fn parse_idl_dir_reads_all_jsons_sorted() { + let dir = Path::new("tests/fixtures"); + let idls = parse_idl_dir(dir).expect("fixtures dir should parse"); + + assert_eq!(idls.len(), 2); + assert_eq!(idls[0].0.file_name().unwrap(), "lever.json"); + assert_eq!(idls[1].0.file_name().unwrap(), "relations.json"); + assert_eq!(idls[0].1.metadata.name, "lever"); + assert_eq!(idls[1].1.metadata.name, "relations_derivation"); +} + +#[test] +fn parse_idl_invalid_json_returns_parse_error() { + let err = parse_idl("not valid json {{{").unwrap_err(); + assert!(matches!(err, SolanaError::IdlParseFailed(_))); +} + +#[test] +fn parse_idl_dir_missing_returns_read_error() { + let err = parse_idl_dir(Path::new("/nonexistent/path/__ilold_test__")).unwrap_err(); + assert!(matches!(err, SolanaError::IdlReadFailed { .. })); +} From e3363a4e1835849defb165771aebe533466edc88 Mon Sep 17 00:00:00 2001 From: scab24 Date: Tue, 5 May 2026 16:32:55 +0200 Subject: [PATCH 004/115] feat(solana-core): use convert_idl for legacy IDL fallback - anchor_lang_idl::convert::convert_idl detects metadata.spec automatically - v0.1.0 specs parse straight, pre-Anchor-0.30 legacy IDLs are converted to the new shape - replaces the previous serde_json::from_str path that would silently fail on legacy IDLs --- Cargo.lock | 21 ++++++++++++++++----- crates/ilold-solana-core/Cargo.toml | 2 +- crates/ilold-solana-core/src/error.rs | 4 ++-- crates/ilold-solana-core/src/idl/parse.rs | 5 ++++- crates/ilold-solana-core/tests/idl_parse.rs | 2 +- 5 files changed, 24 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f27e021..cadd630 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -96,9 +96,11 @@ source = "git+https://github.com/solana-foundation/anchor?tag=v1.0.2#1314a6b83b1 dependencies = [ "anchor-lang-idl-spec", "anyhow", + "heck 0.3.3", "regex", "serde", "serde_json", + "sha2", ] [[package]] @@ -650,7 +652,7 @@ version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -1420,6 +1422,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "heck" version = "0.5.0" @@ -3495,7 +3506,7 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "rustversion", @@ -3508,7 +3519,7 @@ version = "0.27.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" dependencies = [ - "heck", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.117", @@ -4437,7 +4448,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" dependencies = [ "anyhow", - "heck", + "heck 0.5.0", "wit-parser", ] @@ -4448,7 +4459,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", - "heck", + "heck 0.5.0", "indexmap", "prettyplease", "syn 2.0.117", diff --git a/crates/ilold-solana-core/Cargo.toml b/crates/ilold-solana-core/Cargo.toml index c5043d7..70cd935 100644 --- a/crates/ilold-solana-core/Cargo.toml +++ b/crates/ilold-solana-core/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true description = "Anchor IDL parser and (later) Solana execution backend for Ilold." [dependencies] -anchor-lang-idl = { git = "https://github.com/solana-foundation/anchor", tag = "v1.0.2", features = ["build"] } +anchor-lang-idl = { git = "https://github.com/solana-foundation/anchor", tag = "v1.0.2", features = ["build", "convert"] } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/ilold-solana-core/src/error.rs b/crates/ilold-solana-core/src/error.rs index d95aab5..ca51f29 100644 --- a/crates/ilold-solana-core/src/error.rs +++ b/crates/ilold-solana-core/src/error.rs @@ -10,8 +10,8 @@ pub enum SolanaError { source: std::io::Error, }, - #[error("failed to parse IDL JSON: {0}")] - IdlParseFailed(#[from] serde_json::Error), + #[error("failed to parse or convert IDL JSON: {message}")] + IdlParseFailed { message: String }, #[error("IDL spec '{0}' is not a recognized version")] UnsupportedIdlSpec(String), diff --git a/crates/ilold-solana-core/src/idl/parse.rs b/crates/ilold-solana-core/src/idl/parse.rs index aad7436..67e1bf7 100644 --- a/crates/ilold-solana-core/src/idl/parse.rs +++ b/crates/ilold-solana-core/src/idl/parse.rs @@ -1,11 +1,14 @@ use std::path::{Path, PathBuf}; +use anchor_lang_idl::convert::convert_idl; use anchor_lang_idl::types::Idl; use crate::error::SolanaError; pub fn parse_idl(json: &str) -> Result { - serde_json::from_str(json).map_err(SolanaError::IdlParseFailed) + convert_idl(json.as_bytes()).map_err(|e| SolanaError::IdlParseFailed { + message: e.to_string(), + }) } pub fn parse_idl_dir(dir: &Path) -> Result, SolanaError> { diff --git a/crates/ilold-solana-core/tests/idl_parse.rs b/crates/ilold-solana-core/tests/idl_parse.rs index 32efacf..22facaf 100644 --- a/crates/ilold-solana-core/tests/idl_parse.rs +++ b/crates/ilold-solana-core/tests/idl_parse.rs @@ -68,7 +68,7 @@ fn parse_idl_dir_reads_all_jsons_sorted() { #[test] fn parse_idl_invalid_json_returns_parse_error() { let err = parse_idl("not valid json {{{").unwrap_err(); - assert!(matches!(err, SolanaError::IdlParseFailed(_))); + assert!(matches!(err, SolanaError::IdlParseFailed { .. })); } #[test] From f271b9338886372a4c10a7b9a834d8a8a0313eeb Mon Sep 17 00:00:00 2001 From: scab24 Date: Tue, 5 May 2026 16:54:18 +0200 Subject: [PATCH 005/115] feat(solana-core): ProgramDef model with from_idl conversion - new model/ module with ProgramDef, InstructionDef, AccountSpec, PdaSpec, SeedSpec, SolanaProject - from_idl flattens composite accounts into dotted paths - discriminators typed as [u8; 8] - SeedSpec::Arg keeps the IdlType for later seed encoding - solana-address dep so program_id is typed --- Cargo.lock | 44 ++++ crates/ilold-solana-core/Cargo.toml | 1 + crates/ilold-solana-core/src/error.rs | 6 + crates/ilold-solana-core/src/lib.rs | 1 + crates/ilold-solana-core/src/model/account.rs | 10 + .../src/model/instruction.rs | 61 +++++ crates/ilold-solana-core/src/model/mod.rs | 9 + crates/ilold-solana-core/src/model/program.rs | 219 ++++++++++++++++++ crates/ilold-solana-core/src/model/project.rs | 34 +++ 9 files changed, 385 insertions(+) create mode 100644 crates/ilold-solana-core/src/model/account.rs create mode 100644 crates/ilold-solana-core/src/model/instruction.rs create mode 100644 crates/ilold-solana-core/src/model/mod.rs create mode 100644 crates/ilold-solana-core/src/model/program.rs create mode 100644 crates/ilold-solana-core/src/model/project.rs diff --git a/Cargo.lock b/Cargo.lock index cadd630..9583866 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1199,6 +1199,30 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "five8" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f76610e969fa1784327ded240f1e28a3fd9520c9cec93b636fcf62dd37f772" +dependencies = [ + "five8_core", +] + +[[package]] +name = "five8_const" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a0f1728185f277989ca573a402716ae0beaaea3f76a8ff87ef9dd8fb19436c5" +dependencies = [ + "five8_core", +] + +[[package]] +name = "five8_core" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "059c31d7d36c43fe39d89e55711858b4da8be7eb6dabac23c7289b1a19489406" + [[package]] name = "fixed-hash" version = "0.8.0" @@ -1770,6 +1794,7 @@ dependencies = [ "anyhow", "serde", "serde_json", + "solana-address", "thiserror 2.0.18", ] @@ -3302,6 +3327,25 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "solana-address" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1384b52c435a750cc9c538760fc7bb472fd78e65a9900a2d07312c5bb335b72" +dependencies = [ + "five8", + "five8_const", + "serde", + "serde_derive", + "solana-program-error", +] + +[[package]] +name = "solana-program-error" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f04fa578707b3612b095f0c8e19b66a1233f7c42ca8082fcb3b745afcc0add6" + [[package]] name = "solar-ast" version = "0.1.8" diff --git a/crates/ilold-solana-core/Cargo.toml b/crates/ilold-solana-core/Cargo.toml index 70cd935..9f62700 100644 --- a/crates/ilold-solana-core/Cargo.toml +++ b/crates/ilold-solana-core/Cargo.toml @@ -6,6 +6,7 @@ description = "Anchor IDL parser and (later) Solana execution backend for Ilold. [dependencies] anchor-lang-idl = { git = "https://github.com/solana-foundation/anchor", tag = "v1.0.2", features = ["build", "convert"] } +solana-address = { version = "2.0", features = ["serde", "decode"] } serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/ilold-solana-core/src/error.rs b/crates/ilold-solana-core/src/error.rs index ca51f29..a069d32 100644 --- a/crates/ilold-solana-core/src/error.rs +++ b/crates/ilold-solana-core/src/error.rs @@ -21,4 +21,10 @@ pub enum SolanaError { #[error("IDL address '{0}' is not a valid base58 pubkey")] InvalidProgramId(String), + + #[error("expected 8-byte discriminator for '{name}', got {len}")] + InvalidDiscriminatorLength { name: String, len: usize }, + + #[error("PDA seed references arg '{path}' which is not declared on the instruction")] + SeedArgUnresolved { path: String }, } diff --git a/crates/ilold-solana-core/src/lib.rs b/crates/ilold-solana-core/src/lib.rs index f123ef5..b53189b 100644 --- a/crates/ilold-solana-core/src/lib.rs +++ b/crates/ilold-solana-core/src/lib.rs @@ -1,2 +1,3 @@ pub mod error; pub mod idl; +pub mod model; diff --git a/crates/ilold-solana-core/src/model/account.rs b/crates/ilold-solana-core/src/model/account.rs new file mode 100644 index 0000000..ddc6f4e --- /dev/null +++ b/crates/ilold-solana-core/src/model/account.rs @@ -0,0 +1,10 @@ +use anchor_lang_idl::types::IdlTypeDef; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountTypeDef { + pub name: String, + #[serde(with = "super::instruction::discriminator_serde")] + pub discriminator: [u8; 8], + pub layout: IdlTypeDef, +} diff --git a/crates/ilold-solana-core/src/model/instruction.rs b/crates/ilold-solana-core/src/model/instruction.rs new file mode 100644 index 0000000..c6e03c5 --- /dev/null +++ b/crates/ilold-solana-core/src/model/instruction.rs @@ -0,0 +1,61 @@ +use anchor_lang_idl::types::{IdlField, IdlType}; +use serde::{Deserialize, Serialize}; +use solana_address::Address; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstructionDef { + pub name: String, + #[serde(with = "discriminator_serde")] + pub discriminator: [u8; 8], + pub args: Vec, + pub accounts: Vec, + #[serde(default)] + pub returns: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountSpec { + pub path: String, + pub name: String, + pub writable: bool, + pub signer: bool, + pub optional: bool, + #[serde(default)] + pub address: Option
, + #[serde(default)] + pub pda: Option, + #[serde(default)] + pub relations: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PdaSpec { + pub seeds: Vec, + #[serde(default)] + pub program: Option, + #[serde(default)] + pub bump_arg: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum SeedSpec { + Const { value: Vec }, + Arg { path: String, ty: IdlType }, + Account { path: String }, +} + +pub(crate) mod discriminator_serde { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + + pub fn serialize(bytes: &[u8; 8], s: S) -> Result { + bytes.as_slice().serialize(s) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 8], D::Error> { + let v = Vec::::deserialize(d)?; + v.try_into().map_err(|v: Vec| { + serde::de::Error::custom(format!("expected 8-byte discriminator, got {}", v.len())) + }) + } +} diff --git a/crates/ilold-solana-core/src/model/mod.rs b/crates/ilold-solana-core/src/model/mod.rs new file mode 100644 index 0000000..129e2e2 --- /dev/null +++ b/crates/ilold-solana-core/src/model/mod.rs @@ -0,0 +1,9 @@ +pub mod account; +pub mod instruction; +pub mod program; +pub mod project; + +pub use account::AccountTypeDef; +pub use instruction::{AccountSpec, InstructionDef, PdaSpec, SeedSpec}; +pub use program::ProgramDef; +pub use project::SolanaProject; diff --git a/crates/ilold-solana-core/src/model/program.rs b/crates/ilold-solana-core/src/model/program.rs new file mode 100644 index 0000000..92a05a9 --- /dev/null +++ b/crates/ilold-solana-core/src/model/program.rs @@ -0,0 +1,219 @@ +use anchor_lang_idl::types::{ + Idl, IdlField, IdlInstruction, IdlInstructionAccount, IdlInstructionAccountItem, IdlPda, + IdlSeed, IdlType, IdlTypeDef, +}; +use serde::{Deserialize, Serialize}; +use solana_address::Address; + +use crate::error::SolanaError; + +use super::{ + AccountSpec, AccountTypeDef, InstructionDef, PdaSpec, SeedSpec, +}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProgramDef { + pub name: String, + pub program_id: Address, + pub instructions: Vec, + pub account_types: Vec, + pub types: Vec, +} + +impl ProgramDef { + pub fn from_idl(idl: Idl) -> Result { + let program_id = idl + .address + .parse::
() + .map_err(|_| SolanaError::InvalidProgramId(idl.address.clone()))?; + + let instructions = idl + .instructions + .iter() + .map(build_instruction) + .collect::, _>>()?; + + let account_types = idl + .accounts + .iter() + .map(|a| build_account_type(a, &idl.types)) + .collect::, _>>()?; + + Ok(Self { + name: idl.metadata.name.clone(), + program_id, + instructions, + account_types, + types: idl.types, + }) + } +} + +fn build_instruction(ix: &IdlInstruction) -> Result { + let discriminator: [u8; 8] = ix.discriminator.as_slice().try_into().map_err(|_| { + SolanaError::InvalidDiscriminatorLength { + name: ix.name.clone(), + len: ix.discriminator.len(), + } + })?; + + let mut accounts = Vec::new(); + for item in &ix.accounts { + flatten_account_item(item, "", &ix.args, &mut accounts)?; + } + + let bump_arg = detect_bump_arg(&ix.args); + if let Some(arg) = bump_arg.as_deref() { + for spec in accounts.iter_mut() { + if let Some(pda) = spec.pda.as_mut() { + if pda.bump_arg.is_none() { + pda.bump_arg = Some(arg.to_string()); + } + } + } + } + + Ok(InstructionDef { + name: ix.name.clone(), + discriminator, + args: ix.args.clone(), + accounts, + returns: ix.returns.clone(), + }) +} + +fn flatten_account_item( + item: &IdlInstructionAccountItem, + prefix: &str, + ix_args: &[IdlField], + out: &mut Vec, +) -> Result<(), SolanaError> { + match item { + IdlInstructionAccountItem::Single(single) => { + out.push(build_account_spec(single, prefix, ix_args)?); + Ok(()) + } + IdlInstructionAccountItem::Composite(group) => { + let new_prefix = join_path(prefix, &group.name); + for sub in &group.accounts { + flatten_account_item(sub, &new_prefix, ix_args, out)?; + } + Ok(()) + } + } +} + +fn build_account_spec( + src: &IdlInstructionAccount, + prefix: &str, + ix_args: &[IdlField], +) -> Result { + let address = match &src.address { + Some(s) => Some( + s.parse::
() + .map_err(|_| SolanaError::InvalidProgramId(s.clone()))?, + ), + None => None, + }; + + let pda = match &src.pda { + Some(spec) => Some(map_pda(spec, ix_args)?), + None => None, + }; + + Ok(AccountSpec { + path: join_path(prefix, &src.name), + name: src.name.clone(), + writable: src.writable, + signer: src.signer, + optional: src.optional, + address, + pda, + relations: src.relations.clone(), + }) +} + +fn map_pda(spec: &IdlPda, ix_args: &[IdlField]) -> Result { + let seeds = spec + .seeds + .iter() + .map(|s| map_seed(s, ix_args)) + .collect::, _>>()?; + let program = match &spec.program { + Some(s) => Some(map_seed(s, ix_args)?), + None => None, + }; + Ok(PdaSpec { + seeds, + program, + bump_arg: None, + }) +} + +fn map_seed(seed: &IdlSeed, ix_args: &[IdlField]) -> Result { + Ok(match seed { + IdlSeed::Const(c) => SeedSpec::Const { value: c.value.clone() }, + IdlSeed::Arg(a) => { + let ty = ix_args + .iter() + .find(|f| f.name == a.path) + .map(|f| f.ty.clone()) + .ok_or_else(|| SolanaError::SeedArgUnresolved { + path: a.path.clone(), + })?; + SeedSpec::Arg { + path: a.path.clone(), + ty, + } + } + IdlSeed::Account(a) => SeedSpec::Account { path: a.path.clone() }, + }) +} + +fn detect_bump_arg(args: &[IdlField]) -> Option { + args.iter() + .find(|f| (f.name == "bump" || f.name.ends_with("_bump")) && matches!(f.ty, IdlType::U8)) + .map(|f| f.name.clone()) +} + +fn build_account_type( + src: &anchor_lang_idl::types::IdlAccount, + types: &[IdlTypeDef], +) -> Result { + let discriminator: [u8; 8] = src.discriminator.as_slice().try_into().map_err(|_| { + SolanaError::InvalidDiscriminatorLength { + name: src.name.clone(), + len: src.discriminator.len(), + } + })?; + let layout = types + .iter() + .find(|t| t.name == src.name) + .cloned() + .unwrap_or_else(|| placeholder_typedef(&src.name)); + Ok(AccountTypeDef { + name: src.name.clone(), + discriminator, + layout, + }) +} + +fn placeholder_typedef(name: &str) -> IdlTypeDef { + use anchor_lang_idl::types::{IdlSerialization, IdlTypeDefTy}; + IdlTypeDef { + name: name.to_string(), + docs: vec![], + serialization: IdlSerialization::Borsh, + repr: None, + generics: vec![], + ty: IdlTypeDefTy::Struct { fields: None }, + } +} + +fn join_path(prefix: &str, name: &str) -> String { + if prefix.is_empty() { + name.to_string() + } else { + format!("{prefix}.{name}") + } +} diff --git a/crates/ilold-solana-core/src/model/project.rs b/crates/ilold-solana-core/src/model/project.rs new file mode 100644 index 0000000..61a2de2 --- /dev/null +++ b/crates/ilold-solana-core/src/model/project.rs @@ -0,0 +1,34 @@ +use std::collections::HashMap; + +use serde::{Deserialize, Serialize}; + +use super::ProgramDef; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct SolanaProject { + pub programs: Vec, + #[serde(skip)] + pub program_index: HashMap, +} + +impl SolanaProject { + pub fn new(programs: Vec) -> Self { + let mut me = Self { + programs, + program_index: HashMap::new(), + }; + me.rebuild_index(); + me + } + + pub fn rebuild_index(&mut self) { + self.program_index.clear(); + for (idx, program) in self.programs.iter().enumerate() { + self.program_index.insert(program.name.clone(), idx); + } + } + + pub fn find_program(&self, name: &str) -> Option<&ProgramDef> { + self.program_index.get(name).and_then(|i| self.programs.get(*i)) + } +} From fc94b252661383f85bfccb9805c092ccdb89e427 Mon Sep 17 00:00:00 2001 From: scab24 Date: Tue, 5 May 2026 16:55:25 +0200 Subject: [PATCH 006/115] feat(solana-core): project ingest detection - new ingest module with detect, find_idls, find_so - detect walks ancestors for Anchor.toml and discriminates Solana, Solidity or mixed projects - find_idls scans target/idl with idls/ fallback, find_so scans target/deploy - adds MixedProject and UnknownProjectType variants to SolanaError --- crates/ilold-solana-core/src/error.rs | 6 ++ crates/ilold-solana-core/src/ingest.rs | 139 +++++++++++++++++++++++++ crates/ilold-solana-core/src/lib.rs | 1 + 3 files changed, 146 insertions(+) create mode 100644 crates/ilold-solana-core/src/ingest.rs diff --git a/crates/ilold-solana-core/src/error.rs b/crates/ilold-solana-core/src/error.rs index a069d32..de4f308 100644 --- a/crates/ilold-solana-core/src/error.rs +++ b/crates/ilold-solana-core/src/error.rs @@ -27,4 +27,10 @@ pub enum SolanaError { #[error("PDA seed references arg '{path}' which is not declared on the instruction")] SeedArgUnresolved { path: String }, + + #[error("project at '{path}' contains both Anchor.toml and Solidity sources")] + MixedProject { path: PathBuf }, + + #[error("project at '{path}' is neither Anchor nor Solidity (no Anchor.toml, foundry.toml or *.sol found)")] + UnknownProjectType { path: PathBuf }, } diff --git a/crates/ilold-solana-core/src/ingest.rs b/crates/ilold-solana-core/src/ingest.rs new file mode 100644 index 0000000..b60745e --- /dev/null +++ b/crates/ilold-solana-core/src/ingest.rs @@ -0,0 +1,139 @@ +use std::path::{Path, PathBuf}; + +use crate::error::SolanaError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProjectKind { + Solana, + Solidity, +} + +#[derive(Debug, Clone)] +pub struct DetectedProject { + pub kind: ProjectKind, + pub root: PathBuf, + pub idl_paths: Vec, + pub so_paths: Vec, +} + +pub fn detect(path: &Path) -> Result { + let anchor_root = find_anchor_root(path); + let solidity_marker = has_solidity_marker(path); + + match (anchor_root, solidity_marker) { + (Some(_), true) => Err(SolanaError::MixedProject { + path: path.to_path_buf(), + }), + (Some(root), false) => Ok(DetectedProject { + kind: ProjectKind::Solana, + idl_paths: find_idls(&root), + so_paths: find_so(&root), + root, + }), + (None, true) => Ok(DetectedProject { + kind: ProjectKind::Solidity, + root: path.to_path_buf(), + idl_paths: vec![], + so_paths: vec![], + }), + (None, false) => Err(SolanaError::UnknownProjectType { + path: path.to_path_buf(), + }), + } +} + +fn find_anchor_root(path: &Path) -> Option { + let mut current = if path.is_file() { + path.parent()?.to_path_buf() + } else { + path.to_path_buf() + }; + loop { + if current.join("Anchor.toml").is_file() { + return Some(current); + } + match current.parent() { + Some(parent) => current = parent.to_path_buf(), + None => return None, + } + } +} + +fn has_solidity_marker(path: &Path) -> bool { + let dir = if path.is_file() { + match path.parent() { + Some(p) => p, + None => return false, + } + } else { + path + }; + if dir.join("foundry.toml").is_file() || dir.join("hardhat.config.ts").is_file() + || dir.join("hardhat.config.js").is_file() + { + return true; + } + has_sol_anywhere(dir, 6) +} + +fn has_sol_anywhere(dir: &Path, depth_remaining: usize) -> bool { + if depth_remaining == 0 { + return false; + } + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return false, + }; + for entry in entries.flatten() { + let p = entry.path(); + if p.is_file() && p.extension().is_some_and(|e| e == "sol") { + return true; + } + if p.is_dir() { + let name = p.file_name().and_then(|n| n.to_str()).unwrap_or(""); + if name.starts_with('.') || matches!(name, "node_modules" | "target" | "out" | "cache" | "lib") { + continue; + } + if has_sol_anywhere(&p, depth_remaining - 1) { + return true; + } + } + } + false +} + +pub fn find_idls(anchor_root: &Path) -> Vec { + let preferred = anchor_root.join("target").join("idl"); + let fallback = anchor_root.join("idls"); + let mut found = collect_jsons(&preferred); + if found.is_empty() { + found = collect_jsons(&fallback); + } + found.sort(); + found +} + +pub fn find_so(anchor_root: &Path) -> Vec { + let dir = anchor_root.join("target").join("deploy"); + let mut found: Vec = match std::fs::read_dir(&dir) { + Ok(entries) => entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|e| e == "so")) + .collect(), + Err(_) => Vec::new(), + }; + found.sort(); + found +} + +fn collect_jsons(dir: &Path) -> Vec { + match std::fs::read_dir(dir) { + Ok(entries) => entries + .flatten() + .map(|e| e.path()) + .filter(|p| p.extension().is_some_and(|e| e == "json")) + .collect(), + Err(_) => Vec::new(), + } +} diff --git a/crates/ilold-solana-core/src/lib.rs b/crates/ilold-solana-core/src/lib.rs index b53189b..14786af 100644 --- a/crates/ilold-solana-core/src/lib.rs +++ b/crates/ilold-solana-core/src/lib.rs @@ -1,3 +1,4 @@ pub mod error; pub mod idl; +pub mod ingest; pub mod model; From 942564c63d7225f6fe398fbf671fd7cd41bf5e5b Mon Sep 17 00:00:00 2001 From: scab24 Date: Tue, 5 May 2026 16:59:03 +0200 Subject: [PATCH 007/115] test(solana-core): cover model conversion and ingest scenarios - 9 tests across two suites - ProgramDef::from_idl against lever and relations IDLs (composite flattening, seed mapping) - SolanaProject lookup by name - ingest::detect with tempdir fixtures: anchor only, foundry, .sol files, mixed, empty, idls under target --- Cargo.lock | 1 + crates/ilold-solana-core/Cargo.toml | 3 + .../tests/model_and_ingest.rs | 137 ++++++++++++++++++ 3 files changed, 141 insertions(+) create mode 100644 crates/ilold-solana-core/tests/model_and_ingest.rs diff --git a/Cargo.lock b/Cargo.lock index 9583866..f93df20 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1795,6 +1795,7 @@ dependencies = [ "serde", "serde_json", "solana-address", + "tempfile", "thiserror 2.0.18", ] diff --git a/crates/ilold-solana-core/Cargo.toml b/crates/ilold-solana-core/Cargo.toml index 9f62700..a7ebb8c 100644 --- a/crates/ilold-solana-core/Cargo.toml +++ b/crates/ilold-solana-core/Cargo.toml @@ -11,3 +11,6 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } anyhow = { workspace = true } + +[dev-dependencies] +tempfile = "3" diff --git a/crates/ilold-solana-core/tests/model_and_ingest.rs b/crates/ilold-solana-core/tests/model_and_ingest.rs new file mode 100644 index 0000000..18011bd --- /dev/null +++ b/crates/ilold-solana-core/tests/model_and_ingest.rs @@ -0,0 +1,137 @@ +use std::fs; + +use ilold_solana_core::error::SolanaError; +use ilold_solana_core::idl::parse_idl; +use ilold_solana_core::ingest::{detect, ProjectKind}; +use ilold_solana_core::model::{ProgramDef, SeedSpec, SolanaProject}; + +const LEVER_JSON: &str = include_str!("fixtures/lever.json"); +const RELATIONS_JSON: &str = include_str!("fixtures/relations.json"); + +#[test] +fn program_def_from_lever_idl() { + let idl = parse_idl(LEVER_JSON).unwrap(); + let program = ProgramDef::from_idl(idl).unwrap(); + + assert_eq!(program.name, "lever"); + assert_eq!(program.program_id.to_string(), "E64FVeubGC4NPNF2UBJYX4AkrVowf74fRJD9q6YhwstN"); + assert_eq!(program.instructions.len(), 2); + + let init = &program.instructions[0]; + assert_eq!(init.name, "initialize"); + assert_eq!(init.discriminator.len(), 8); + assert!(!init.accounts.is_empty()); + + let switch = &program.instructions[1]; + assert_eq!(switch.name, "switch_power"); + assert!(!switch.accounts.is_empty()); + + assert_eq!(program.account_types.len(), 1); + assert_eq!(program.account_types[0].name, "PowerStatus"); + assert_eq!(program.account_types[0].discriminator.len(), 8); +} + +#[test] +fn program_def_from_relations_flattens_composites_and_maps_seeds() { + let idl = parse_idl(RELATIONS_JSON).unwrap(); + let program = ProgramDef::from_idl(idl).unwrap(); + + let composite_paths: Vec<&str> = program + .instructions + .iter() + .flat_map(|ix| ix.accounts.iter()) + .filter(|a| a.path.contains('.')) + .map(|a| a.path.as_str()) + .collect(); + assert!( + !composite_paths.is_empty(), + "expected at least one dotted path from a composite group" + ); + + let any_const_seed = program + .instructions + .iter() + .flat_map(|ix| ix.accounts.iter()) + .filter_map(|a| a.pda.as_ref()) + .flat_map(|pda| pda.seeds.iter()) + .any(|s| matches!(s, SeedSpec::Const { .. })); + assert!(any_const_seed, "expected at least one Const seed mapped"); +} + +#[test] +fn solana_project_index_lookup() { + let lever = ProgramDef::from_idl(parse_idl(LEVER_JSON).unwrap()).unwrap(); + let relations = ProgramDef::from_idl(parse_idl(RELATIONS_JSON).unwrap()).unwrap(); + + let project = SolanaProject::new(vec![lever, relations]); + + assert!(project.find_program("lever").is_some()); + assert!(project.find_program("relations_derivation").is_some()); + assert!(project.find_program("missing").is_none()); +} + +#[test] +fn detect_anchor_project() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("Anchor.toml"), "[programs.localnet]\n").unwrap(); + + let detected = detect(dir.path()).unwrap(); + assert_eq!(detected.kind, ProjectKind::Solana); + assert_eq!(detected.root, dir.path()); + assert!(detected.idl_paths.is_empty()); + assert!(detected.so_paths.is_empty()); +} + +#[test] +fn detect_solidity_project_via_foundry_toml() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("foundry.toml"), "[profile.default]\n").unwrap(); + + let detected = detect(dir.path()).unwrap(); + assert_eq!(detected.kind, ProjectKind::Solidity); +} + +#[test] +fn detect_solidity_project_via_sol_files() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join("src")).unwrap(); + fs::write( + dir.path().join("src/Token.sol"), + "// SPDX-License-Identifier: MIT\npragma solidity ^0.8.0;\ncontract Token {}\n", + ) + .unwrap(); + + let detected = detect(dir.path()).unwrap(); + assert_eq!(detected.kind, ProjectKind::Solidity); +} + +#[test] +fn detect_mixed_project_returns_error() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("Anchor.toml"), "[programs.localnet]\n").unwrap(); + fs::write(dir.path().join("foundry.toml"), "[profile.default]\n").unwrap(); + + let err = detect(dir.path()).unwrap_err(); + assert!(matches!(err, SolanaError::MixedProject { .. })); +} + +#[test] +fn detect_empty_directory_returns_unknown_type() { + let dir = tempfile::tempdir().unwrap(); + let err = detect(dir.path()).unwrap_err(); + assert!(matches!(err, SolanaError::UnknownProjectType { .. })); +} + +#[test] +fn detect_anchor_finds_idls_under_target() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("Anchor.toml"), "[programs.localnet]\n").unwrap(); + let idl_dir = dir.path().join("target").join("idl"); + fs::create_dir_all(&idl_dir).unwrap(); + fs::write(idl_dir.join("foo.json"), LEVER_JSON).unwrap(); + + let detected = detect(dir.path()).unwrap(); + assert_eq!(detected.kind, ProjectKind::Solana); + assert_eq!(detected.idl_paths.len(), 1); + assert!(detected.idl_paths[0].ends_with("foo.json")); +} From 3ceb39fdc2934d89303bcf37bfb5edb2e7c6a75e Mon Sep 17 00:00:00 2001 From: scab24 Date: Tue, 5 May 2026 17:37:19 +0200 Subject: [PATCH 008/115] refactor(web): Backend enum and SolidityState wrapper - AppState now holds backend: Backend (Solidity | Solana) and 5 backend-agnostic fields (annotations, scenarios, session_tx, port, project_root) - 7 Solidity-specific fields move into SolidityState; Solana variant carries SolanaProject - handlers reach Solidity state through state.solidity(), require_solidity, require_solidity_msg or unwrap_solidity - contract_path renamed to project_root - SolanaError UnsupportedGeneric message dropped the project-phase qualifier --- Cargo.lock | 1 + crates/ilold-cli/src/explore.rs | 23 +++++---- crates/ilold-solana-core/src/error.rs | 2 +- crates/ilold-web/Cargo.toml | 1 + crates/ilold-web/src/api/contract.rs | 57 ++++++++++++--------- crates/ilold-web/src/api/project.rs | 32 +++++++----- crates/ilold-web/src/api/session.rs | 34 +++++++------ crates/ilold-web/src/lib.rs | 20 ++++---- crates/ilold-web/src/state.rs | 71 ++++++++++++++++++++++----- crates/ilold-web/src/ws/handler.rs | 5 +- crates/ilold-web/src/ws/pty.rs | 2 +- crates/ilold-web/src/ws/search.rs | 4 +- 12 files changed, 165 insertions(+), 87 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f93df20..6293e68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1807,6 +1807,7 @@ dependencies = [ "axum", "futures-util", "ilold-core", + "ilold-solana-core", "portable-pty", "reqwest", "serde", diff --git a/crates/ilold-cli/src/explore.rs b/crates/ilold-cli/src/explore.rs index 53584a6..0aa94e9 100644 --- a/crates/ilold-cli/src/explore.rs +++ b/crates/ilold-cli/src/explore.rs @@ -78,14 +78,15 @@ pub async fn run(paths: Vec, port: u16, max_seq_depth: usize, attach: O println!("Analyzing {} file(s)...", paths.len()); let (state, actual_port) = ilold_web::start_server(paths, port, max_seq_depth).await?; - let contract_name = state.project.find_contract(None) + let s = state.unwrap_solidity(); + let contract_name = s.project.find_contract(None) .map(|c| c.name.clone()) .unwrap_or_else(|_| "unknown".into()); - let function_names: Vec = state.project.contracts.iter() + let function_names: Vec = s.project.contracts.iter() .find(|c| c.name == contract_name) .map(|c| { - state.project + s.project .accessible_functions(c) .iter() .map(|af| af.function.name.clone()) @@ -93,7 +94,7 @@ pub async fn run(paths: Vec, port: u16, max_seq_depth: usize, attach: O }) .unwrap_or_default(); - let contract_names: Vec = state.project.contracts.iter() + let contract_names: Vec = s.project.contracts.iter() .map(|c| c.name.clone()) .filter(|n| !n.is_empty()) .collect(); @@ -201,9 +202,9 @@ fn repl_loop( steps.clear(); scenario_name = "main".into(); if let Some(state) = state.as_ref() { - // Local mode: use AppState directly - if let Some(c) = state.project.contracts.iter().find(|c| c.name == new_name) { - functions = state.project + let s = state.unwrap_solidity(); + if let Some(c) = s.project.contracts.iter().find(|c| c.name == new_name) { + functions = s.project .accessible_functions(c) .iter() .map(|af| af.function.name.clone()) @@ -416,7 +417,8 @@ fn handle_input( } if let Some(state) = state { // Local mode - match state.project.find_contract(Some(arg)) { + let s = state.unwrap_solidity(); + match s.project.find_contract(Some(arg)) { Ok(c) => { let name = c.name.clone(); if name == contract { @@ -1094,13 +1096,14 @@ fn handle_finding_interactive( fn print_contracts(state: &std::sync::Arc, current: &str) { use ilold_core::model::contract::ContractKind; + let s = state.unwrap_solidity(); println!(); - let max_name = state.project.contracts.iter() + let max_name = s.project.contracts.iter() .filter(|c| !c.name.is_empty()) .map(|c| c.name.chars().count()) .max().unwrap_or(0); - for c in &state.project.contracts { + for c in &s.project.contracts { if c.name.is_empty() { continue; } let badge = match c.kind { ContractKind::Contract => c_accent("[C]"), diff --git a/crates/ilold-solana-core/src/error.rs b/crates/ilold-solana-core/src/error.rs index de4f308..31b6fd2 100644 --- a/crates/ilold-solana-core/src/error.rs +++ b/crates/ilold-solana-core/src/error.rs @@ -16,7 +16,7 @@ pub enum SolanaError { #[error("IDL spec '{0}' is not a recognized version")] UnsupportedIdlSpec(String), - #[error("IDL type '{0}' uses generics, which are not supported in MVP")] + #[error("IDL type '{0}' uses generics, which are not supported")] UnsupportedGeneric(String), #[error("IDL address '{0}' is not a valid base58 pubkey")] diff --git a/crates/ilold-web/Cargo.toml b/crates/ilold-web/Cargo.toml index 1621d61..4684421 100644 --- a/crates/ilold-web/Cargo.toml +++ b/crates/ilold-web/Cargo.toml @@ -6,6 +6,7 @@ description = "Web server and interactive viewer for Ilold" [dependencies] ilold-core = { path = "../ilold-core" } +ilold-solana-core = { path = "../ilold-solana-core" } axum = { workspace = true } tokio = { workspace = true } tower-http = { workspace = true } diff --git a/crates/ilold-web/src/api/contract.rs b/crates/ilold-web/src/api/contract.rs index 2e90fc1..0f14ee2 100644 --- a/crates/ilold-web/src/api/contract.rs +++ b/crates/ilold-web/src/api/contract.rs @@ -9,7 +9,7 @@ use ilold_core::model::common::SourceSpan; use ilold_core::pathtree::types::PathTree; use ilold_core::sequence::types::SequenceTree; -use crate::state::AppState; +use crate::state::{require_solidity, AppState}; // ============================================================================ // Contract detail @@ -58,7 +58,8 @@ pub async fn get_contract( State(state): State>, Path(name): Path, ) -> Result, StatusCode> { - let contract = state + let s = require_solidity(&state)?; + let contract = s .project .contracts .iter() @@ -70,7 +71,7 @@ pub async fn get_contract( .iter() .map(|f| { let key = (contract.name.clone(), f.name.clone()); - let pt = state.path_trees.get(&key); + let pt = s.path_trees.get(&key); FunctionSummary { name: f.name.clone(), kind: format!("{:?}", f.kind), @@ -100,14 +101,14 @@ pub async fn get_contract( }) .collect(); - let inherited_functions: Vec = state.project + let inherited_functions: Vec = s.project .accessible_functions(contract) .into_iter() .filter(|af| af.is_inherited) .map(|af| { let f = af.function; let key = (af.origin.clone(), f.name.clone()); - let pt = state.path_trees.get(&key); + let pt = s.path_trees.get(&key); FunctionSummary { name: f.name.clone(), kind: format!("{:?}", f.kind), @@ -128,7 +129,7 @@ pub async fn get_contract( let own_var_names: std::collections::HashSet = contract.state_vars.iter() .map(|sv| sv.name.clone()) .collect(); - let inherited_state_vars: Vec = state.project + let inherited_state_vars: Vec = s.project .inherited_state_vars(contract) .into_iter() .filter(|sv| !own_var_names.contains(&sv.name)) @@ -196,7 +197,8 @@ pub async fn get_callgraph( State(state): State>, Path(name): Path, ) -> Result, StatusCode> { - let cg = state + let s = require_solidity(&state)?; + let cg = s .call_graphs .get(&name) .ok_or(StatusCode::NOT_FOUND)?; @@ -247,15 +249,16 @@ pub async fn get_cfg( State(state): State>, Path((contract_name, func_name)): Path<(String, String)>, ) -> Result, StatusCode> { - let contract = state.project.contracts.iter() + let s = require_solidity(&state)?; + let contract = s.project.contracts.iter() .find(|c| c.name == contract_name) .ok_or(StatusCode::NOT_FOUND)?; - let (owning, _func) = state.project.resolve_function(contract, &func_name) + let (owning, _func) = s.project.resolve_function(contract, &func_name) .ok_or(StatusCode::NOT_FOUND)?; let key = (owning.name.clone(), func_name); - let cfg = state.cfgs.get(&key).ok_or(StatusCode::NOT_FOUND)?; + let cfg = s.cfgs.get(&key).ok_or(StatusCode::NOT_FOUND)?; let nodes: Vec = cfg .node_indices() @@ -317,17 +320,18 @@ pub async fn get_function_source( State(state): State>, Path((contract_name, func_name)): Path<(String, String)>, ) -> Result, StatusCode> { - let contract = state.project.contracts.iter() + let s = require_solidity(&state)?; + let contract = s.project.contracts.iter() .find(|c| c.name == contract_name) .ok_or(StatusCode::NOT_FOUND)?; // `resolve_function` walks the inheritance chain, so a function declared // in a parent contract resolves to the parent's FunctionDef (with its // own span + file_index). - let (_owning, func) = state.project.resolve_function(contract, &func_name) + let (_owning, func) = s.project.resolve_function(contract, &func_name) .ok_or(StatusCode::NOT_FOUND)?; - let file = state.project.source_files.get(func.span.file_index) + let file = s.project.source_files.get(func.span.file_index) .ok_or(StatusCode::NOT_FOUND)?; let source = slice_lines( @@ -365,16 +369,19 @@ pub async fn get_paths( State(state): State>, Path((contract_name, func_name)): Path<(String, String)>, ) -> Result, StatusCode> { - let contract = state.project.contracts.iter() + let s = require_solidity(&state)?; + let contract = s + .project + .contracts + .iter() .find(|c| c.name == contract_name) .ok_or(StatusCode::NOT_FOUND)?; - let (owning, _func) = state.project.resolve_function(contract, &func_name) + let (owning, _func) = s.project.resolve_function(contract, &func_name) .ok_or(StatusCode::NOT_FOUND)?; let key = (owning.name.clone(), func_name); - state - .path_trees + s.path_trees .get(&key) .cloned() .map(Json) @@ -395,8 +402,8 @@ pub async fn get_sequences( Path(name): Path, Query(_query): Query, ) -> Result, StatusCode> { - state - .sequence_trees + let s = require_solidity(&state)?; + s.sequence_trees .get(&name) .cloned() .map(Json) @@ -413,7 +420,8 @@ pub async fn get_sequence_analysis( State(state): State>, Path(name): Path, ) -> Result, StatusCode> { - let analysis = analyze_sequences(&state.path_trees, &name); + let s = require_solidity(&state)?; + let analysis = analyze_sequences(&s.path_trees, &name); Ok(Json(analysis)) } @@ -440,21 +448,22 @@ pub async fn get_search_suggestions( State(state): State>, Path(name): Path, ) -> Result, StatusCode> { - let contract = state + let s = require_solidity(&state)?; + let contract = s .project .contracts .iter() .find(|c| c.name == name) .ok_or(StatusCode::NOT_FOUND)?; - let accessible = state.project.accessible_functions(contract); + let accessible = s.project.accessible_functions(contract); let functions: Vec = accessible .iter() .filter(|af| !af.function.name.is_empty()) .map(|af| af.function.name.clone()) .collect(); - let state_vars: Vec = state.project + let state_vars: Vec = s.project .inherited_state_vars(contract) .into_iter() .map(|sv| sv.name) @@ -477,7 +486,7 @@ pub async fn get_search_suggestions( // Collect unique external calls from all paths keyed by any origin let mut ext_calls = std::collections::HashSet::new(); - for ((c, _), pt) in &state.path_trees { + for ((c, _), pt) in &s.path_trees { if !origins.contains(c) { continue; } for path in &pt.paths { for call in &path.annotations.external_calls { diff --git a/crates/ilold-web/src/api/project.rs b/crates/ilold-web/src/api/project.rs index 5c2f037..cdb8319 100644 --- a/crates/ilold-web/src/api/project.rs +++ b/crates/ilold-web/src/api/project.rs @@ -1,10 +1,11 @@ use std::sync::Arc; use axum::extract::State; +use axum::http::StatusCode; use axum::Json; use serde::Serialize; -use crate::state::AppState; +use crate::state::{require_solidity_msg, AppState}; #[derive(Serialize)] pub struct ProjectSummary { @@ -21,8 +22,11 @@ pub struct ContractSummary { pub inherits: Vec, } -pub async fn get_project(State(state): State>) -> Json { - let contracts = state +pub async fn get_project( + State(state): State>, +) -> Result, (StatusCode, String)> { + let s = require_solidity_msg(&state)?; + let contracts = s .project .contracts .iter() @@ -35,10 +39,10 @@ pub async fn get_project(State(state): State>) -> Json>) -> Json { +pub async fn get_project_map( + State(state): State>, +) -> Result, (StatusCode, String)> { + let s = require_solidity_msg(&state)?; let mut contracts = Vec::new(); let mut relationships = Vec::new(); - for contract in &state.project.contracts { + for contract in &s.project.contracts { let functions: Vec = contract .functions .iter() .filter(|f| !f.name.is_empty()) .map(|f| { let key = (contract.name.clone(), f.name.clone()); - let pt = state.path_trees.get(&key); + let pt = s.path_trees.get(&key); let has_ext = pt .map(|p| p.paths.iter().any(|path| !path.annotations.external_calls.is_empty())) .unwrap_or(false); @@ -132,8 +139,7 @@ pub async fn get_project_map(State(state): State>) -> Json>) -> Json( state: &'a AppState, contract_name: &str, ) -> Result, (StatusCode, String)> { - let contract = state.project.contracts.iter() + let s = require_solidity_msg(state)?; + let contract = s.project.contracts.iter() .find(|c| c.name == contract_name) .ok_or((StatusCode::NOT_FOUND, format!("Contract '{}' not found", contract_name)))?; - let seq_analysis = state.sequence_analyses.get(contract_name) + let seq_analysis = s.sequence_analyses.get(contract_name) .ok_or((StatusCode::NOT_FOUND, "No analysis for contract".into()))?; - let classifs = state.classifications.get(contract_name) + let classifs = s.classifications.get(contract_name) .ok_or((StatusCode::NOT_FOUND, "No classifications for contract".into()))?; Ok(AnalysisData { - project: &state.project, + project: &s.project, contract, - cfgs: &state.cfgs, - path_trees: &state.path_trees, + cfgs: &s.cfgs, + path_trees: &s.path_trees, behaviors: &seq_analysis.functions, transitions: &seq_analysis.transitions, classifications: classifs, - all_sequence_analyses: &state.sequence_analyses, - all_classifications: &state.classifications, + all_sequence_analyses: &s.sequence_analyses, + all_classifications: &s.classifications, }) } @@ -65,7 +66,8 @@ fn resolve_contract(state: &AppState, explicit: Option<&str>) -> Result>, -) -> Json { +) -> Result, (StatusCode, String)> { + let s = require_solidity_msg(&state)?; let guard = state.scenarios.read().unwrap(); let active = guard.active().to_string(); let mut scenarios: Vec = Vec::with_capacity(guard.len()); for name in guard.names() { let Some(session) = guard.get(name) else { continue }; - let classifs = state.classifications.get(&session.contract); + let classifs = s.classifications.get(&session.contract); let steps = session .steps .iter() @@ -578,7 +582,7 @@ pub async fn get_all_scenarios( forked_from: session.forked_from.clone(), }); } - Json(AllScenariosResponse { active, scenarios }) + Ok(Json(AllScenariosResponse { active, scenarios })) } /// Parse a comma-separated `expand` query value into a set of step_ids. diff --git a/crates/ilold-web/src/lib.rs b/crates/ilold-web/src/lib.rs index e51aa19..2c7f626 100644 --- a/crates/ilold-web/src/lib.rs +++ b/crates/ilold-web/src/lib.rs @@ -46,15 +46,17 @@ fn build_router(state: Arc) -> Router { pub async fn serve(paths: Vec, port: u16, max_seq_depth: usize) -> anyhow::Result<()> { println!("Analyzing {} file(s)...", paths.len()); - let contract_path = paths.first().map(|p| p.parent().unwrap_or(p).to_path_buf()).unwrap_or_default(); + let project_root = paths.first().map(|p| p.parent().unwrap_or(p).to_path_buf()).unwrap_or_default(); let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")).await?; let actual_port = listener.local_addr()?.port(); - let state = Arc::new(AppState::from_paths(&paths, max_seq_depth, actual_port, contract_path)?); - println!( - "Ready: {} contracts, {} functions analyzed\n", - state.project.contracts.len(), - state.cfgs.len(), - ); + let state = Arc::new(AppState::from_paths(&paths, max_seq_depth, actual_port, project_root)?); + if let Some(s) = state.solidity() { + println!( + "Ready: {} contracts, {} functions analyzed\n", + s.project.contracts.len(), + s.cfgs.len(), + ); + } let app = build_router(state); println!("Server running at http://localhost:{actual_port}"); @@ -67,10 +69,10 @@ pub async fn start_server( port: u16, max_seq_depth: usize, ) -> anyhow::Result<(Arc, u16)> { - let contract_path = paths.first().map(|p| p.parent().unwrap_or(p).to_path_buf()).unwrap_or_default(); + let project_root = paths.first().map(|p| p.parent().unwrap_or(p).to_path_buf()).unwrap_or_default(); let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")).await?; let actual_port = listener.local_addr()?.port(); - let state = Arc::new(AppState::from_paths(&paths, max_seq_depth, actual_port, contract_path)?); + let state = Arc::new(AppState::from_paths(&paths, max_seq_depth, actual_port, project_root)?); let app = build_router(state.clone()); tokio::spawn(async move { diff --git a/crates/ilold-web/src/state.rs b/crates/ilold-web/src/state.rs index 1d3db17..9a83e45 100644 --- a/crates/ilold-web/src/state.rs +++ b/crates/ilold-web/src/state.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::path::PathBuf; use std::sync::RwLock; +use axum::http::StatusCode; use tokio::sync::broadcast; use ilold_core::callgraph::builder::build_call_graph; @@ -20,6 +21,7 @@ use ilold_core::pathtree::walker::build_path_tree; use ilold_core::sequence::analysis::{analyze_project, analyze_sequences, SequenceAnalysis}; use ilold_core::sequence::builder::build_sequence_tree; use ilold_core::sequence::types::SequenceTree; +use ilold_solana_core::model::SolanaProject; use serde::{Deserialize, Serialize}; @@ -197,7 +199,7 @@ struct ScenarioStoreFile { order: Vec, } -pub struct AppState { +pub struct SolidityState { pub project: Project, pub cfgs: HashMap<(String, String), CfgGraph>, pub path_trees: HashMap<(String, String), PathTree>, @@ -205,11 +207,56 @@ pub struct AppState { pub sequence_trees: HashMap, pub sequence_analyses: HashMap, pub classifications: HashMap>, +} + +pub struct SolanaState { + pub project: SolanaProject, +} + +pub enum Backend { + Solidity(SolidityState), + Solana(SolanaState), +} + +pub struct AppState { + pub backend: Backend, pub annotations: RwLock>, pub scenarios: RwLock, pub session_tx: broadcast::Sender, pub port: u16, - pub contract_path: PathBuf, + pub project_root: PathBuf, +} + +impl AppState { + pub fn solidity(&self) -> Option<&SolidityState> { + match &self.backend { + Backend::Solidity(s) => Some(s), + Backend::Solana(_) => None, + } + } + + pub fn solana(&self) -> Option<&SolanaState> { + match &self.backend { + Backend::Solana(s) => Some(s), + Backend::Solidity(_) => None, + } + } +} + +impl AppState { + pub fn unwrap_solidity(&self) -> &SolidityState { + self.solidity().expect("Solidity backend required") + } +} + +pub fn require_solidity(state: &AppState) -> Result<&SolidityState, StatusCode> { + state.solidity().ok_or(StatusCode::BAD_REQUEST) +} + +pub fn require_solidity_msg(state: &AppState) -> Result<&SolidityState, (StatusCode, String)> { + state + .solidity() + .ok_or_else(|| (StatusCode::BAD_REQUEST, "endpoint is Solidity-only".to_string())) } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -243,7 +290,7 @@ pub enum AnnotationStatus { } impl AppState { - pub fn from_paths(paths: &[PathBuf], max_seq_depth: usize, port: u16, contract_path: PathBuf) -> anyhow::Result { + pub fn from_paths(paths: &[PathBuf], max_seq_depth: usize, port: u16, project_root: PathBuf) -> anyhow::Result { let parser = SolarParser; let mut project = parser.parse(paths)?; project.rebuild_index(); @@ -304,18 +351,20 @@ impl AppState { .unwrap_or_else(|| "unknown".to_string()); Ok(Self { - project, - cfgs, - path_trees, - call_graphs, - sequence_trees, - sequence_analyses, - classifications, + backend: Backend::Solidity(SolidityState { + project, + cfgs, + path_trees, + call_graphs, + sequence_trees, + sequence_analyses, + classifications, + }), annotations: RwLock::new(Vec::new()), scenarios: RwLock::new(ScenarioStore::new_for_contract(default_contract)), session_tx, port, - contract_path, + project_root, }) } } diff --git a/crates/ilold-web/src/ws/handler.rs b/crates/ilold-web/src/ws/handler.rs index baeaf9a..6495a9c 100644 --- a/crates/ilold-web/src/ws/handler.rs +++ b/crates/ilold-web/src/ws/handler.rs @@ -107,7 +107,10 @@ async fn handle_socket(mut socket: WebSocket, state: Arc) { match parsed { ClientMessage::Search(query) => { - let results = search::search_paths(&state, &query); + let results = match state.solidity() { + Some(s) => search::search_paths(s, &query), + None => Vec::new(), + }; let total = results.len(); for result in results { diff --git a/crates/ilold-web/src/ws/pty.rs b/crates/ilold-web/src/ws/pty.rs index ac6a188..604443f 100644 --- a/crates/ilold-web/src/ws/pty.rs +++ b/crates/ilold-web/src/ws/pty.rs @@ -15,7 +15,7 @@ pub async fn ws_pty_handler( State(state): State>, ) -> impl IntoResponse { let port = state.port; - let contract_path = state.contract_path.clone(); + let contract_path = state.project_root.clone(); ws.on_upgrade(move |socket| handle_pty_session(socket, port, contract_path)) } diff --git a/crates/ilold-web/src/ws/search.rs b/crates/ilold-web/src/ws/search.rs index 27805b0..1d14abf 100644 --- a/crates/ilold-web/src/ws/search.rs +++ b/crates/ilold-web/src/ws/search.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -use crate::state::AppState; +use crate::state::SolidityState; #[derive(Debug, Deserialize)] pub struct SearchQuery { @@ -30,7 +30,7 @@ pub struct SearchComplete { pub total: usize, } -pub fn search_paths(state: &AppState, query: &SearchQuery) -> Vec { +pub fn search_paths(state: &SolidityState, query: &SearchQuery) -> Vec { let q = query.query.to_lowercase(); let mut results = Vec::new(); From 86af2de7c7d25e0b339761d071b5e6d5d7229263 Mon Sep 17 00:00:00 2001 From: scab24 Date: Tue, 5 May 2026 20:35:45 +0200 Subject: [PATCH 009/115] feat(web): serve_solana entry and ProjectMap kind discriminant - AppState::from_solana builds an Arc with Backend::Solana wrapping a SolanaProject - serve_solana and start_solana_server parse all IDLs in DetectedProject and start the same axum router used for Solidity - get_project_map dispatches on backend and returns kind=solidity with contracts or kind=solana with programs (name, program_id, instructions, account_types) - frontend can read the kind discriminant to branch rendering when the SolanaState ships --- crates/ilold-web/src/api/project.rs | 76 +++++++++++++++++++++++++++-- crates/ilold-web/src/lib.rs | 45 +++++++++++++++++ crates/ilold-web/src/state.rs | 21 ++++++++ 3 files changed, 137 insertions(+), 5 deletions(-) diff --git a/crates/ilold-web/src/api/project.rs b/crates/ilold-web/src/api/project.rs index cdb8319..197a09d 100644 --- a/crates/ilold-web/src/api/project.rs +++ b/crates/ilold-web/src/api/project.rs @@ -5,7 +5,7 @@ use axum::http::StatusCode; use axum::Json; use serde::Serialize; -use crate::state::{require_solidity_msg, AppState}; +use crate::state::{require_solidity_msg, AppState, Backend}; #[derive(Serialize)] pub struct ProjectSummary { @@ -51,10 +51,33 @@ pub async fn get_project( #[derive(Serialize)] pub struct ProjectMap { + pub kind: &'static str, pub contracts: Vec, + pub programs: Vec, pub relationships: Vec, } +#[derive(Serialize)] +pub struct MapProgram { + pub name: String, + pub program_id: String, + pub instructions: Vec, + pub account_types: Vec, +} + +#[derive(Serialize)] +pub struct MapInstruction { + pub name: String, + pub args_count: usize, + pub accounts_count: usize, + pub has_pdas: bool, +} + +#[derive(Serialize)] +pub struct MapAccountType { + pub name: String, +} + #[derive(Serialize)] pub struct MapContract { pub name: String, @@ -93,8 +116,49 @@ pub struct MapRelationship { pub async fn get_project_map( State(state): State>, -) -> Result, (StatusCode, String)> { - let s = require_solidity_msg(&state)?; +) -> Json { + match &state.backend { + Backend::Solidity(_) => Json(build_solidity_map(&state)), + Backend::Solana(s) => Json(build_solana_map(s)), + } +} + +fn build_solana_map(s: &crate::state::SolanaState) -> ProjectMap { + let programs: Vec = s + .project + .programs + .iter() + .map(|p| MapProgram { + name: p.name.clone(), + program_id: p.program_id.to_string(), + instructions: p + .instructions + .iter() + .map(|ix| MapInstruction { + name: ix.name.clone(), + args_count: ix.args.len(), + accounts_count: ix.accounts.len(), + has_pdas: ix.accounts.iter().any(|a| a.pda.is_some()), + }) + .collect(), + account_types: p + .account_types + .iter() + .map(|a| MapAccountType { name: a.name.clone() }) + .collect(), + }) + .collect(); + + ProjectMap { + kind: "solana", + contracts: Vec::new(), + programs, + relationships: Vec::new(), + } +} + +fn build_solidity_map(state: &Arc) -> ProjectMap { + let s = state.unwrap_solidity(); let mut contracts = Vec::new(); let mut relationships = Vec::new(); @@ -159,8 +223,10 @@ pub async fn get_project_map( } } - Ok(Json(ProjectMap { + ProjectMap { + kind: "solidity", contracts, + programs: Vec::new(), relationships, - })) + } } diff --git a/crates/ilold-web/src/lib.rs b/crates/ilold-web/src/lib.rs index 2c7f626..10c48b3 100644 --- a/crates/ilold-web/src/lib.rs +++ b/crates/ilold-web/src/lib.rs @@ -7,6 +7,8 @@ use std::sync::Arc; use axum::routing::{delete, get, post, put}; use axum::Router; +use ilold_solana_core::ingest::DetectedProject; +use ilold_solana_core::model::{ProgramDef, SolanaProject}; use tower_http::cors::CorsLayer; use state::AppState; @@ -81,3 +83,46 @@ pub async fn start_server( Ok((state, actual_port)) } + +pub async fn serve_solana(detected: DetectedProject, port: u16) -> anyhow::Result<()> { + println!("Analyzing {} IDL(s)...", detected.idl_paths.len()); + let project = build_solana_project(&detected)?; + let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")).await?; + let actual_port = listener.local_addr()?.port(); + let state = Arc::new(AppState::from_solana(project, actual_port, detected.root.clone())); + if let Some(s) = state.solana() { + println!("Ready: {} program(s) analyzed\n", s.project.programs.len()); + } + let app = build_router(state); + println!("Server running at http://localhost:{actual_port}"); + axum::serve(listener, app).await?; + Ok(()) +} + +pub async fn start_solana_server( + detected: DetectedProject, + port: u16, +) -> anyhow::Result<(Arc, u16)> { + let project = build_solana_project(&detected)?; + let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")).await?; + let actual_port = listener.local_addr()?.port(); + let state = Arc::new(AppState::from_solana(project, actual_port, detected.root.clone())); + let app = build_router(state.clone()); + + tokio::spawn(async move { + axum::serve(listener, app).await.ok(); + }); + + Ok((state, actual_port)) +} + +fn build_solana_project(detected: &DetectedProject) -> anyhow::Result { + let mut programs = Vec::with_capacity(detected.idl_paths.len()); + for idl_path in &detected.idl_paths { + let json = std::fs::read_to_string(idl_path)?; + let idl = ilold_solana_core::idl::parse_idl(&json)?; + let program = ProgramDef::from_idl(idl)?; + programs.push(program); + } + Ok(SolanaProject::new(programs)) +} diff --git a/crates/ilold-web/src/state.rs b/crates/ilold-web/src/state.rs index 9a83e45..26bd808 100644 --- a/crates/ilold-web/src/state.rs +++ b/crates/ilold-web/src/state.rs @@ -290,6 +290,27 @@ pub enum AnnotationStatus { } impl AppState { + pub fn from_solana( + project: SolanaProject, + port: u16, + project_root: PathBuf, + ) -> Self { + let (session_tx, _) = broadcast::channel(64); + let default_program = project + .programs + .first() + .map(|p| p.name.clone()) + .unwrap_or_else(|| "unknown".to_string()); + Self { + backend: Backend::Solana(SolanaState { project }), + annotations: RwLock::new(Vec::new()), + scenarios: RwLock::new(ScenarioStore::new_for_contract(default_program)), + session_tx, + port, + project_root, + } + } + pub fn from_paths(paths: &[PathBuf], max_seq_depth: usize, port: u16, project_root: PathBuf) -> anyhow::Result { let parser = SolarParser; let mut project = parser.parse(paths)?; From 6b42ed8c17158123279a9fffa88c6cc7615669d9 Mon Sep 17 00:00:00 2001 From: scab24 Date: Tue, 5 May 2026 20:36:53 +0200 Subject: [PATCH 010/115] feat(cli): detect project kind and route serve/explore - ilold serve and ilold explore now run ilold_solana_core::ingest::detect before any file collection - Solidity branch keeps collect_sol_files plus the existing serve and explore::run paths - Solana branch sends Serve to ilold_web::serve_solana and Explore returns an error pointing at serve until the Solana REPL command set lands - mixed and unknown project errors bubble up from detect with their SolanaError messages --- Cargo.lock | 1 + crates/ilold-cli/Cargo.toml | 1 + crates/ilold-cli/src/main.rs | 41 +++++++++++++++++++++++++----------- 3 files changed, 31 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6293e68..e049613 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1756,6 +1756,7 @@ dependencies = [ "crossterm", "dirs", "ilold-core", + "ilold-solana-core", "ilold-web", "nu-ansi-term", "ratatui", diff --git a/crates/ilold-cli/Cargo.toml b/crates/ilold-cli/Cargo.toml index fef4896..6dd4a38 100644 --- a/crates/ilold-cli/Cargo.toml +++ b/crates/ilold-cli/Cargo.toml @@ -10,6 +10,7 @@ path = "src/main.rs" [dependencies] ilold-core = { path = "../ilold-core" } +ilold-solana-core = { path = "../ilold-solana-core" } ilold-web = { path = "../ilold-web" } anyhow = { workspace = true } clap = { workspace = true } diff --git a/crates/ilold-cli/src/main.rs b/crates/ilold-cli/src/main.rs index 2522f81..72fdce0 100644 --- a/crates/ilold-cli/src/main.rs +++ b/crates/ilold-cli/src/main.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use anyhow::Result; use clap::Parser; +use ilold_solana_core::ingest::{detect, ProjectKind}; mod analyze; mod colors; @@ -74,27 +75,43 @@ async fn main() -> Result<()> { context::run(&path, contract.as_deref(), function.as_deref(), sequence.as_deref(), list) } Commands::Serve { path, port, max_seq_depth } => { - let paths = collect_sol_files(&path)?; - if paths.is_empty() { - anyhow::bail!("No .sol files found at {}", path.display()); + let detected = detect(&path)?; + match detected.kind { + ProjectKind::Solidity => serve_solidity(&path, port, max_seq_depth).await, + ProjectKind::Solana => ilold_web::serve_solana(detected, port).await, } - ilold_web::serve(paths, port, max_seq_depth).await } Commands::Explore { path, port, max_seq_depth, attach } => { if attach.is_some() { - // --attach mode: no local analysis needed, connect to remote server - explore::run(Vec::new(), port, max_seq_depth, attach).await - } else { - let paths = collect_sol_files(&path)?; - if paths.is_empty() { - anyhow::bail!("No .sol files found at {}", path.display()); - } - explore::run(paths, port, max_seq_depth, attach).await + return explore::run(Vec::new(), port, max_seq_depth, attach).await; + } + let detected = detect(&path)?; + match detected.kind { + ProjectKind::Solidity => explore_solidity(&path, port, max_seq_depth, attach).await, + ProjectKind::Solana => anyhow::bail!( + "Explore mode for Solana projects requires the REPL command set planned for a later phase. Use `ilold serve` for now." + ), } } } } +async fn serve_solidity(path: &PathBuf, port: u16, max_seq_depth: usize) -> Result<()> { + let paths = collect_sol_files(path)?; + if paths.is_empty() { + anyhow::bail!("No .sol files found at {}", path.display()); + } + ilold_web::serve(paths, port, max_seq_depth).await +} + +async fn explore_solidity(path: &PathBuf, port: u16, max_seq_depth: usize, attach: Option) -> Result<()> { + let paths = collect_sol_files(path)?; + if paths.is_empty() { + anyhow::bail!("No .sol files found at {}", path.display()); + } + explore::run(paths, port, max_seq_depth, attach).await +} + pub fn collect_sol_files(path: &PathBuf) -> Result> { if path.is_file() { return Ok(vec![path.clone()]); From 0e829a76a12ba5a71e55393633954904cc9269ac Mon Sep 17 00:00:00 2001 From: scab24 Date: Tue, 5 May 2026 21:01:07 +0200 Subject: [PATCH 011/115] feat(solana-core): Borsh decoder for accounts and instruction args - decode_value walks IdlType against a &mut &[u8] cursor and emits serde_json::Value - primitives go through borsh::BorshDeserialize, u/i256 render as 0x-prefixed hex, u/i128 as decimal strings, pubkey as base58 - Defined struct/enum types resolve through the IDL types list, enums use the Anchor SDK shape {kind, value} - decode_account splits the 8-byte discriminator, looks up the matching IdlAccount and decodes the body as a struct - decode_ix_data validates the instruction discriminator and decodes args in IDL order - Generic types and unknown discriminators surface as typed SolanaError variants - 12 new tests with hand-built Borsh bytes cover bool, ints up to u128, string, pubkey, Option, Vec, Array, Defined struct, enum and account discriminator lookup --- Cargo.lock | 42 +++ crates/ilold-solana-core/Cargo.toml | 2 + .../ilold-solana-core/src/decode/account.rs | 50 ++++ crates/ilold-solana-core/src/decode/borsh.rs | 169 +++++++++++ .../src/decode/instruction.rs | 31 +++ crates/ilold-solana-core/src/decode/mod.rs | 7 + crates/ilold-solana-core/src/error.rs | 9 + crates/ilold-solana-core/src/lib.rs | 1 + .../ilold-solana-core/tests/decode_borsh.rs | 262 ++++++++++++++++++ 9 files changed, 573 insertions(+) create mode 100644 crates/ilold-solana-core/src/decode/account.rs create mode 100644 crates/ilold-solana-core/src/decode/borsh.rs create mode 100644 crates/ilold-solana-core/src/decode/instruction.rs create mode 100644 crates/ilold-solana-core/src/decode/mod.rs create mode 100644 crates/ilold-solana-core/tests/decode_borsh.rs diff --git a/Cargo.lock b/Cargo.lock index e049613..081c6d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -554,12 +554,31 @@ dependencies = [ "generic-array", ] +[[package]] +name = "borsh" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" +dependencies = [ + "bytes", + "cfg_aliases", +] + [[package]] name = "boxcar" version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "36f64beae40a84da1b4b26ff2761a5b895c12adc41dc25aaee1c4f2bbfe97a6e" +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -612,6 +631,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.44" @@ -1793,6 +1818,8 @@ version = "0.1.0" dependencies = [ "anchor-lang-idl", "anyhow", + "borsh", + "bs58", "serde", "serde_json", "solana-address", @@ -3759,6 +3786,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.50.0" diff --git a/crates/ilold-solana-core/Cargo.toml b/crates/ilold-solana-core/Cargo.toml index a7ebb8c..3416a3e 100644 --- a/crates/ilold-solana-core/Cargo.toml +++ b/crates/ilold-solana-core/Cargo.toml @@ -11,6 +11,8 @@ serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } anyhow = { workspace = true } +borsh = "1" +bs58 = "0.5" [dev-dependencies] tempfile = "3" diff --git a/crates/ilold-solana-core/src/decode/account.rs b/crates/ilold-solana-core/src/decode/account.rs new file mode 100644 index 0000000..e36d4be --- /dev/null +++ b/crates/ilold-solana-core/src/decode/account.rs @@ -0,0 +1,50 @@ +use anchor_lang_idl::types::{Idl, IdlTypeDefTy}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::decode::borsh::decode_defined_fields; +use crate::error::SolanaError; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DecodedAccount { + pub type_name: String, + pub value: Value, +} + +pub fn decode_account(data: &[u8], idl: &Idl) -> Result { + if data.len() < 8 { + return Err(SolanaError::DecodeFailed(format!( + "account data is {} bytes, need at least 8 for discriminator", + data.len() + ))); + } + let (disc, rest) = data.split_at(8); + let account = idl + .accounts + .iter() + .find(|a| a.discriminator == disc) + .ok_or_else(|| SolanaError::UnknownDiscriminator { + hex: disc.iter().map(|b| format!("{:02x}", b)).collect(), + })?; + let type_def = idl + .types + .iter() + .find(|t| t.name == account.name) + .ok_or_else(|| SolanaError::UnknownType(account.name.clone()))?; + let mut cursor = rest; + let value = match &type_def.ty { + IdlTypeDefTy::Struct { fields } => { + decode_defined_fields(&mut cursor, fields.as_ref(), &idl.types)? + } + _ => { + return Err(SolanaError::DecodeFailed(format!( + "account '{}' is not a struct typedef", + account.name + ))); + } + }; + Ok(DecodedAccount { + type_name: account.name.clone(), + value, + }) +} diff --git a/crates/ilold-solana-core/src/decode/borsh.rs b/crates/ilold-solana-core/src/decode/borsh.rs new file mode 100644 index 0000000..20712ac --- /dev/null +++ b/crates/ilold-solana-core/src/decode/borsh.rs @@ -0,0 +1,169 @@ +use std::io::Read; + +use anchor_lang_idl::types::{ + IdlArrayLen, IdlDefinedFields, IdlEnumVariant, IdlType, IdlTypeDef, IdlTypeDefTy, +}; +use borsh::BorshDeserialize; +use serde_json::{Map, Value}; + +use crate::error::SolanaError; + +pub fn decode_value( + reader: &mut &[u8], + ty: &IdlType, + types: &[IdlTypeDef], +) -> Result { + Ok(match ty { + IdlType::Bool => Value::Bool(read::(reader)?), + IdlType::U8 => Value::Number(read::(reader)?.into()), + IdlType::I8 => Value::Number(read::(reader)?.into()), + IdlType::U16 => Value::Number(read::(reader)?.into()), + IdlType::I16 => Value::Number(read::(reader)?.into()), + IdlType::U32 => Value::Number(read::(reader)?.into()), + IdlType::I32 => Value::Number(read::(reader)?.into()), + IdlType::F32 => json_number_or_string(read::(reader)? as f64), + IdlType::U64 => Value::Number(read::(reader)?.into()), + IdlType::I64 => Value::Number(read::(reader)?.into()), + IdlType::F64 => json_number_or_string(read::(reader)?), + IdlType::U128 => Value::String(read::(reader)?.to_string()), + IdlType::I128 => Value::String(read::(reader)?.to_string()), + IdlType::U256 | IdlType::I256 => { + let mut buf = [0u8; 32]; + read_exact(reader, &mut buf)?; + Value::String(format!("0x{}", hex_le(&buf))) + } + IdlType::String => Value::String(read::(reader)?), + IdlType::Bytes => Value::String(hex_le(&read::>(reader)?)), + IdlType::Pubkey => { + let mut buf = [0u8; 32]; + read_exact(reader, &mut buf)?; + Value::String(bs58::encode(buf).into_string()) + } + IdlType::Option(inner) => match read::(reader)? { + 0 => Value::Null, + 1 => decode_value(reader, inner, types)?, + tag => { + return Err(SolanaError::DecodeFailed(format!( + "invalid Option tag {tag}" + ))); + } + }, + IdlType::Vec(inner) => { + let len = read::(reader)? as usize; + let mut arr = Vec::with_capacity(len); + for _ in 0..len { + arr.push(decode_value(reader, inner, types)?); + } + Value::Array(arr) + } + IdlType::Array(inner, IdlArrayLen::Value(n)) => { + let mut arr = Vec::with_capacity(*n); + for _ in 0..*n { + arr.push(decode_value(reader, inner, types)?); + } + Value::Array(arr) + } + IdlType::Array(_, IdlArrayLen::Generic(name)) => { + return Err(SolanaError::UnsupportedGeneric(format!( + "array length generic '{name}'" + ))); + } + IdlType::Defined { name, .. } => { + let def = types + .iter() + .find(|t| &t.name == name) + .ok_or_else(|| SolanaError::UnknownType(name.clone()))?; + decode_typedef(reader, def, types)? + } + IdlType::Generic(name) => { + return Err(SolanaError::UnsupportedGeneric(name.clone())); + } + _ => return Err(SolanaError::DecodeFailed("unsupported IdlType variant".into())), + }) +} + +pub(crate) fn decode_typedef( + reader: &mut &[u8], + def: &IdlTypeDef, + types: &[IdlTypeDef], +) -> Result { + match &def.ty { + IdlTypeDefTy::Struct { fields } => decode_defined_fields(reader, fields.as_ref(), types), + IdlTypeDefTy::Enum { variants } => decode_enum(reader, variants, types), + IdlTypeDefTy::Type { alias } => decode_value(reader, alias, types), + } +} + +pub(crate) fn decode_defined_fields( + reader: &mut &[u8], + fields: Option<&IdlDefinedFields>, + types: &[IdlTypeDef], +) -> Result { + match fields { + None => Ok(Value::Object(Map::new())), + Some(IdlDefinedFields::Named(items)) => { + let mut obj = Map::with_capacity(items.len()); + for f in items { + obj.insert(f.name.clone(), decode_value(reader, &f.ty, types)?); + } + Ok(Value::Object(obj)) + } + Some(IdlDefinedFields::Tuple(items)) => { + let mut arr = Vec::with_capacity(items.len()); + for ty in items { + arr.push(decode_value(reader, ty, types)?); + } + Ok(Value::Array(arr)) + } + } +} + +fn decode_enum( + reader: &mut &[u8], + variants: &[IdlEnumVariant], + types: &[IdlTypeDef], +) -> Result { + let tag = read::(reader)? as usize; + let variant = variants.get(tag).ok_or_else(|| { + SolanaError::DecodeFailed(format!( + "enum tag {tag} out of range (have {} variants)", + variants.len() + )) + })?; + let payload = decode_defined_fields(reader, variant.fields.as_ref(), types)?; + let payload_empty = payload + .as_object() + .map(|o| o.is_empty()) + .or_else(|| payload.as_array().map(|a| a.is_empty())) + .unwrap_or(false); + let mut obj = Map::new(); + obj.insert("kind".into(), Value::String(variant.name.clone())); + if !payload_empty { + obj.insert("value".into(), payload); + } + Ok(Value::Object(obj)) +} + +fn read(reader: &mut &[u8]) -> Result { + T::deserialize_reader(reader).map_err(|e| SolanaError::DecodeFailed(e.to_string())) +} + +fn read_exact(reader: &mut &[u8], buf: &mut [u8]) -> Result<(), SolanaError> { + reader + .read_exact(buf) + .map_err(|e| SolanaError::DecodeFailed(e.to_string())) +} + +fn hex_le(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len() * 2); + for b in bytes { + out.push_str(&format!("{:02x}", b)); + } + out +} + +fn json_number_or_string(f: f64) -> Value { + serde_json::Number::from_f64(f) + .map(Value::Number) + .unwrap_or_else(|| Value::String(f.to_string())) +} diff --git a/crates/ilold-solana-core/src/decode/instruction.rs b/crates/ilold-solana-core/src/decode/instruction.rs new file mode 100644 index 0000000..c4f333a --- /dev/null +++ b/crates/ilold-solana-core/src/decode/instruction.rs @@ -0,0 +1,31 @@ +use anchor_lang_idl::types::IdlTypeDef; +use serde_json::{Map, Value}; + +use crate::decode::borsh::decode_value; +use crate::error::SolanaError; +use crate::model::InstructionDef; + +pub fn decode_ix_data( + data: &[u8], + ix: &InstructionDef, + types: &[IdlTypeDef], +) -> Result { + if data.len() < 8 { + return Err(SolanaError::DecodeFailed(format!( + "instruction data is {} bytes, need at least 8 for discriminator", + data.len() + ))); + } + let (disc, mut rest) = data.split_at(8); + if disc != ix.discriminator { + return Err(SolanaError::DecodeFailed(format!( + "instruction discriminator does not match '{}'", + ix.name + ))); + } + let mut obj = Map::with_capacity(ix.args.len()); + for arg in &ix.args { + obj.insert(arg.name.clone(), decode_value(&mut rest, &arg.ty, types)?); + } + Ok(Value::Object(obj)) +} diff --git a/crates/ilold-solana-core/src/decode/mod.rs b/crates/ilold-solana-core/src/decode/mod.rs new file mode 100644 index 0000000..eb13695 --- /dev/null +++ b/crates/ilold-solana-core/src/decode/mod.rs @@ -0,0 +1,7 @@ +pub mod account; +pub mod borsh; +pub mod instruction; + +pub use account::{decode_account, DecodedAccount}; +pub use borsh::decode_value; +pub use instruction::decode_ix_data; diff --git a/crates/ilold-solana-core/src/error.rs b/crates/ilold-solana-core/src/error.rs index 31b6fd2..aa797a9 100644 --- a/crates/ilold-solana-core/src/error.rs +++ b/crates/ilold-solana-core/src/error.rs @@ -33,4 +33,13 @@ pub enum SolanaError { #[error("project at '{path}' is neither Anchor nor Solidity (no Anchor.toml, foundry.toml or *.sol found)")] UnknownProjectType { path: PathBuf }, + + #[error("Borsh decode failed: {0}")] + DecodeFailed(String), + + #[error("unknown account discriminator: {hex}")] + UnknownDiscriminator { hex: String }, + + #[error("IDL references unknown type '{0}'")] + UnknownType(String), } diff --git a/crates/ilold-solana-core/src/lib.rs b/crates/ilold-solana-core/src/lib.rs index 14786af..8f1162f 100644 --- a/crates/ilold-solana-core/src/lib.rs +++ b/crates/ilold-solana-core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod decode; pub mod error; pub mod idl; pub mod ingest; diff --git a/crates/ilold-solana-core/tests/decode_borsh.rs b/crates/ilold-solana-core/tests/decode_borsh.rs new file mode 100644 index 0000000..aedc78b --- /dev/null +++ b/crates/ilold-solana-core/tests/decode_borsh.rs @@ -0,0 +1,262 @@ +use anchor_lang_idl::types::{ + Idl, IdlAccount, IdlArrayLen, IdlDefinedFields, IdlEnumVariant, IdlField, IdlMetadata, + IdlSerialization, IdlType, IdlTypeDef, IdlTypeDefTy, +}; +use ilold_solana_core::decode::{decode_account, decode_value}; +use ilold_solana_core::error::SolanaError; +use serde_json::json; + +fn types() -> Vec { + Vec::new() +} + +#[test] +fn primitives_decode_correctly() { + let mut bytes: &[u8] = &[1u8]; + assert_eq!(decode_value(&mut bytes, &IdlType::Bool, &types()).unwrap(), json!(true)); + + let mut bytes: &[u8] = &[42u8]; + assert_eq!(decode_value(&mut bytes, &IdlType::U8, &types()).unwrap(), json!(42)); + + let mut bytes: &[u8] = &42u64.to_le_bytes(); + assert_eq!(decode_value(&mut bytes, &IdlType::U64, &types()).unwrap(), json!(42)); + + let mut bytes: &[u8] = &(-7i32).to_le_bytes(); + assert_eq!(decode_value(&mut bytes, &IdlType::I32, &types()).unwrap(), json!(-7)); +} + +#[test] +fn u128_renders_as_decimal_string() { + let value: u128 = 340_282_366_920_938_463_463_374_607_431_768_211_455; + let mut bytes: &[u8] = &value.to_le_bytes(); + assert_eq!( + decode_value(&mut bytes, &IdlType::U128, &types()).unwrap(), + json!(value.to_string()) + ); +} + +#[test] +fn string_decodes_with_length_prefix() { + let mut buf = Vec::new(); + let s = "hola"; + buf.extend(&(s.len() as u32).to_le_bytes()); + buf.extend(s.as_bytes()); + let mut bytes: &[u8] = &buf; + assert_eq!( + decode_value(&mut bytes, &IdlType::String, &types()).unwrap(), + json!("hola") + ); +} + +#[test] +fn pubkey_renders_base58() { + let bytes = [0u8; 32]; + let mut slice: &[u8] = &bytes; + let v = decode_value(&mut slice, &IdlType::Pubkey, &types()).unwrap(); + assert_eq!(v, json!("11111111111111111111111111111111")); +} + +#[test] +fn option_some_and_none() { + let mut buf: Vec = vec![0]; + let mut bytes: &[u8] = &buf; + let ty = IdlType::Option(Box::new(IdlType::U32)); + assert_eq!(decode_value(&mut bytes, &ty, &types()).unwrap(), json!(null)); + + buf = vec![1]; + buf.extend(&7u32.to_le_bytes()); + let mut bytes: &[u8] = &buf; + assert_eq!(decode_value(&mut bytes, &ty, &types()).unwrap(), json!(7)); +} + +#[test] +fn vec_u32_with_length_prefix() { + let mut buf = (3u32).to_le_bytes().to_vec(); + for v in [10u32, 20, 30] { + buf.extend(&v.to_le_bytes()); + } + let ty = IdlType::Vec(Box::new(IdlType::U32)); + let mut bytes: &[u8] = &buf; + assert_eq!( + decode_value(&mut bytes, &ty, &types()).unwrap(), + json!([10, 20, 30]) + ); +} + +#[test] +fn array_fixed_length() { + let mut buf = Vec::new(); + for v in [1u32, 2, 3] { + buf.extend(&v.to_le_bytes()); + } + let ty = IdlType::Array(Box::new(IdlType::U32), IdlArrayLen::Value(3)); + let mut bytes: &[u8] = &buf; + assert_eq!(decode_value(&mut bytes, &ty, &types()).unwrap(), json!([1, 2, 3])); +} + +#[test] +fn defined_struct_decodes_named_fields() { + let counter_typedef = IdlTypeDef { + name: "Counter".into(), + docs: vec![], + serialization: IdlSerialization::default(), + repr: None, + generics: vec![], + ty: IdlTypeDefTy::Struct { + fields: Some(IdlDefinedFields::Named(vec![ + IdlField { + name: "count".into(), + docs: vec![], + ty: IdlType::U64, + }, + IdlField { + name: "active".into(), + docs: vec![], + ty: IdlType::Bool, + }, + ])), + }, + }; + let types = vec![counter_typedef]; + let ty = IdlType::Defined { + name: "Counter".into(), + generics: vec![], + }; + + let mut buf = 99u64.to_le_bytes().to_vec(); + buf.push(1u8); + let mut bytes: &[u8] = &buf; + assert_eq!( + decode_value(&mut bytes, &ty, &types).unwrap(), + json!({"count": 99, "active": true}) + ); +} + +#[test] +fn enum_with_unit_and_payload_variants() { + let status_typedef = IdlTypeDef { + name: "Status".into(), + docs: vec![], + serialization: IdlSerialization::default(), + repr: None, + generics: vec![], + ty: IdlTypeDefTy::Enum { + variants: vec![ + IdlEnumVariant { + name: "Off".into(), + fields: None, + }, + IdlEnumVariant { + name: "On".into(), + fields: Some(IdlDefinedFields::Named(vec![IdlField { + name: "level".into(), + docs: vec![], + ty: IdlType::U8, + }])), + }, + ], + }, + }; + let types = vec![status_typedef]; + let ty = IdlType::Defined { + name: "Status".into(), + generics: vec![], + }; + + let mut bytes: &[u8] = &[0u8]; + assert_eq!( + decode_value(&mut bytes, &ty, &types).unwrap(), + json!({"kind": "Off"}) + ); + + let buf = vec![1u8, 200u8]; + let mut bytes: &[u8] = &buf; + assert_eq!( + decode_value(&mut bytes, &ty, &types).unwrap(), + json!({"kind": "On", "value": {"level": 200}}) + ); +} + +#[test] +fn decode_account_uses_discriminator_lookup() { + let counter_typedef = IdlTypeDef { + name: "Counter".into(), + docs: vec![], + serialization: IdlSerialization::default(), + repr: None, + generics: vec![], + ty: IdlTypeDefTy::Struct { + fields: Some(IdlDefinedFields::Named(vec![IdlField { + name: "count".into(), + docs: vec![], + ty: IdlType::U64, + }])), + }, + }; + let disc = vec![1u8, 2, 3, 4, 5, 6, 7, 8]; + let idl = Idl { + address: "11111111111111111111111111111111".into(), + metadata: IdlMetadata { + name: "test".into(), + version: "0.1.0".into(), + spec: "0.1.0".into(), + description: None, + repository: None, + dependencies: vec![], + contact: None, + deployments: None, + }, + docs: vec![], + instructions: vec![], + accounts: vec![IdlAccount { + name: "Counter".into(), + discriminator: disc.clone(), + }], + events: vec![], + errors: vec![], + types: vec![counter_typedef], + constants: vec![], + }; + + let mut data = disc.clone(); + data.extend(&123u64.to_le_bytes()); + let decoded = decode_account(&data, &idl).unwrap(); + assert_eq!(decoded.type_name, "Counter"); + assert_eq!(decoded.value, json!({"count": 123})); +} + +#[test] +fn decode_account_unknown_discriminator_errors() { + let idl = Idl { + address: "11111111111111111111111111111111".into(), + metadata: IdlMetadata { + name: "test".into(), + version: "0.1.0".into(), + spec: "0.1.0".into(), + description: None, + repository: None, + dependencies: vec![], + contact: None, + deployments: None, + }, + docs: vec![], + instructions: vec![], + accounts: vec![], + events: vec![], + errors: vec![], + types: vec![], + constants: vec![], + }; + + let data = [9u8; 16]; + let err = decode_account(&data, &idl).unwrap_err(); + assert!(matches!(err, SolanaError::UnknownDiscriminator { .. })); +} + +#[test] +fn generics_rejected() { + let ty = IdlType::Generic("T".into()); + let mut bytes: &[u8] = &[0u8]; + let err = decode_value(&mut bytes, &ty, &types()).unwrap_err(); + assert!(matches!(err, SolanaError::UnsupportedGeneric(_))); +} From b13b184990c7394f7ec97d44f214ecb672ff5b64 Mon Sep 17 00:00:00 2001 From: scab24 Date: Tue, 5 May 2026 21:01:59 +0200 Subject: [PATCH 012/115] test: add cross-program-invocation Anchor fixture - copies Anchor.toml and idls/lever.json from program-examples into tests/fixtures/solana/cpi - enables `ilold serve tests/fixtures/solana/cpi` as a portable smoke test for the Solana branch without depending on /TOOL/SECURITY/program-examples being cloned - ilold-solana-core unit tests already use a separate copy under crates/ilold-solana-core/tests/fixtures, this one is for the cli/web smoke path --- tests/fixtures/solana/cpi/Anchor.toml | 19 +++++++ tests/fixtures/solana/cpi/idls/lever.json | 68 +++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 tests/fixtures/solana/cpi/Anchor.toml create mode 100644 tests/fixtures/solana/cpi/idls/lever.json diff --git a/tests/fixtures/solana/cpi/Anchor.toml b/tests/fixtures/solana/cpi/Anchor.toml new file mode 100644 index 0000000..8134bd5 --- /dev/null +++ b/tests/fixtures/solana/cpi/Anchor.toml @@ -0,0 +1,19 @@ +[toolchain] +solana_version = "3.1.8" + +[features] +resolution = true +skip-lint = false + +[programs.localnet] +hand = "Bi5N7SUQhpGknVcqPTzdFFVueQoxoUu8YTLz75J6fT8A" +lever = "E64FVeubGC4NPNF2UBJYX4AkrVowf74fRJD9q6YhwstN" + +# [registry] section removed — no longer used in Anchor 1.0 + +[provider] +cluster = "Localnet" +wallet = "~/.config/solana/id.json" + +[scripts] +test = "pnpm ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts" diff --git a/tests/fixtures/solana/cpi/idls/lever.json b/tests/fixtures/solana/cpi/idls/lever.json new file mode 100644 index 0000000..3ec862d --- /dev/null +++ b/tests/fixtures/solana/cpi/idls/lever.json @@ -0,0 +1,68 @@ +{ + "address": "E64FVeubGC4NPNF2UBJYX4AkrVowf74fRJD9q6YhwstN", + "metadata": { + "name": "lever", + "version": "0.1.0", + "spec": "0.1.0", + "description": "Created with Anchor" + }, + "instructions": [ + { + "name": "initialize", + "discriminator": [175, 175, 109, 31, 13, 152, 155, 237], + "accounts": [ + { + "name": "power", + "writable": true, + "signer": true + }, + { + "name": "user", + "writable": true, + "signer": true + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [] + }, + { + "name": "switch_power", + "discriminator": [226, 238, 56, 172, 191, 45, 122, 87], + "accounts": [ + { + "name": "power", + "writable": true + } + ], + "args": [ + { + "name": "name", + "type": "string" + } + ] + } + ], + "accounts": [ + { + "name": "PowerStatus", + "discriminator": [145, 147, 198, 35, 253, 101, 231, 26] + } + ], + "types": [ + { + "name": "PowerStatus", + "type": { + "kind": "struct", + "fields": [ + { + "name": "is_on", + "type": "bool" + } + ] + } + } + ] +} From 107e4eb7f6d6e418054a8a44475951e6264cea85 Mon Sep 17 00:00:00 2001 From: scab24 Date: Tue, 5 May 2026 21:09:36 +0200 Subject: [PATCH 013/115] test(fixtures): include Anchor program source for cpi fixture - adds workspace Cargo.toml and programs/{lever,hand}/ source - target/ gitignored so compiled artifacts stay out --- tests/fixtures/solana/cpi/.gitignore | 1 + tests/fixtures/solana/cpi/Cargo.toml | 14 ++++++ .../solana/cpi/programs/hand/Cargo.toml | 27 +++++++++++ .../solana/cpi/programs/hand/Xargo.toml | 2 + .../solana/cpi/programs/hand/src/lib.rs | 33 +++++++++++++ .../solana/cpi/programs/lever/Cargo.toml | 27 +++++++++++ .../solana/cpi/programs/lever/Xargo.toml | 2 + .../solana/cpi/programs/lever/src/lib.rs | 46 +++++++++++++++++++ 8 files changed, 152 insertions(+) create mode 100644 tests/fixtures/solana/cpi/.gitignore create mode 100644 tests/fixtures/solana/cpi/Cargo.toml create mode 100644 tests/fixtures/solana/cpi/programs/hand/Cargo.toml create mode 100644 tests/fixtures/solana/cpi/programs/hand/Xargo.toml create mode 100644 tests/fixtures/solana/cpi/programs/hand/src/lib.rs create mode 100644 tests/fixtures/solana/cpi/programs/lever/Cargo.toml create mode 100644 tests/fixtures/solana/cpi/programs/lever/Xargo.toml create mode 100644 tests/fixtures/solana/cpi/programs/lever/src/lib.rs diff --git a/tests/fixtures/solana/cpi/.gitignore b/tests/fixtures/solana/cpi/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/tests/fixtures/solana/cpi/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/tests/fixtures/solana/cpi/Cargo.toml b/tests/fixtures/solana/cpi/Cargo.toml new file mode 100644 index 0000000..f397704 --- /dev/null +++ b/tests/fixtures/solana/cpi/Cargo.toml @@ -0,0 +1,14 @@ +[workspace] +members = [ + "programs/*" +] +resolver = "2" + +[profile.release] +overflow-checks = true +lto = "fat" +codegen-units = 1 +[profile.release.build-override] +opt-level = 3 +incremental = false +codegen-units = 1 diff --git a/tests/fixtures/solana/cpi/programs/hand/Cargo.toml b/tests/fixtures/solana/cpi/programs/hand/Cargo.toml new file mode 100644 index 0000000..397a22a --- /dev/null +++ b/tests/fixtures/solana/cpi/programs/hand/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "hand" +version = "0.1.0" +description = "Created with Anchor" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "hand" + +[features] +default = [] +cpi = ["no-entrypoint"] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +idl-build = ["anchor-lang/idl-build"] +anchor-debug = [] +custom-heap = [] +custom-panic = [] + +[dependencies] +# Anchor 1.0.0-rc.5 — pin to RC until stable release +anchor-lang = "1.0.0" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } diff --git a/tests/fixtures/solana/cpi/programs/hand/Xargo.toml b/tests/fixtures/solana/cpi/programs/hand/Xargo.toml new file mode 100644 index 0000000..475fb71 --- /dev/null +++ b/tests/fixtures/solana/cpi/programs/hand/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/tests/fixtures/solana/cpi/programs/hand/src/lib.rs b/tests/fixtures/solana/cpi/programs/hand/src/lib.rs new file mode 100644 index 0000000..2602e0e --- /dev/null +++ b/tests/fixtures/solana/cpi/programs/hand/src/lib.rs @@ -0,0 +1,33 @@ +use anchor_lang::prelude::*; + +declare_id!("Bi5N7SUQhpGknVcqPTzdFFVueQoxoUu8YTLz75J6fT8A"); + +// automatically generate module using program idl found in ./idls +declare_program!(lever); +use lever::accounts::PowerStatus; +use lever::cpi::accounts::SwitchPower; +use lever::cpi::switch_power; +use lever::program::Lever; + +#[program] +pub mod hand { + use super::*; + + pub fn pull_lever(ctx: Context, name: String) -> Result<()> { + let cpi_ctx = CpiContext::new( + ctx.accounts.lever_program.key(), + SwitchPower { + power: ctx.accounts.power.to_account_info(), + }, + ); + switch_power(cpi_ctx, name)?; + Ok(()) + } +} + +#[derive(Accounts)] +pub struct PullLever<'info> { + #[account(mut)] + pub power: Account<'info, PowerStatus>, + pub lever_program: Program<'info, Lever>, +} diff --git a/tests/fixtures/solana/cpi/programs/lever/Cargo.toml b/tests/fixtures/solana/cpi/programs/lever/Cargo.toml new file mode 100644 index 0000000..a70f02e --- /dev/null +++ b/tests/fixtures/solana/cpi/programs/lever/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "lever" +version = "0.1.0" +description = "Created with Anchor" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "lever" + +[features] +default = [] +cpi = ["no-entrypoint"] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +idl-build = ["anchor-lang/idl-build"] +anchor-debug = [] +custom-heap = [] +custom-panic = [] + +[dependencies] +# Anchor 1.0.0-rc.5 — pin to RC until stable release +anchor-lang = "1.0.0" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } diff --git a/tests/fixtures/solana/cpi/programs/lever/Xargo.toml b/tests/fixtures/solana/cpi/programs/lever/Xargo.toml new file mode 100644 index 0000000..475fb71 --- /dev/null +++ b/tests/fixtures/solana/cpi/programs/lever/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/tests/fixtures/solana/cpi/programs/lever/src/lib.rs b/tests/fixtures/solana/cpi/programs/lever/src/lib.rs new file mode 100644 index 0000000..d3ebd3f --- /dev/null +++ b/tests/fixtures/solana/cpi/programs/lever/src/lib.rs @@ -0,0 +1,46 @@ +use anchor_lang::prelude::*; + +declare_id!("E64FVeubGC4NPNF2UBJYX4AkrVowf74fRJD9q6YhwstN"); + +#[program] +pub mod lever { + use super::*; + + pub fn initialize(_ctx: Context) -> Result<()> { + Ok(()) + } + + pub fn switch_power(ctx: Context, name: String) -> Result<()> { + let power = &mut ctx.accounts.power; + power.is_on = !power.is_on; + + msg!("{} is pulling the power switch!", &name); + + match power.is_on { + true => msg!("The power is now on."), + false => msg!("The power is now off!"), + }; + + Ok(()) + } +} + +#[derive(Accounts)] +pub struct InitializeLever<'info> { + #[account(init, payer = user, space = 8 + 8)] + pub power: Account<'info, PowerStatus>, + #[account(mut)] + pub user: Signer<'info>, + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +pub struct SetPowerStatus<'info> { + #[account(mut)] + pub power: Account<'info, PowerStatus>, +} + +#[account] +pub struct PowerStatus { + pub is_on: bool, +} From 0c663025ba97d93647b95c0f3528ede798dd93e3 Mon Sep 17 00:00:00 2001 From: scab24 Date: Tue, 5 May 2026 21:30:06 +0200 Subject: [PATCH 014/115] feat(solana-core): VmHost wrapping LiteSVM with payer and clock control - boot sets Clock first, airdrops a fresh payer, then loads programs - airdrop, balance, warp_clock and clock helpers expose the typical session ops - 4 tests cover empty boot funding, airdrop, warp_clock and invalid ELF rejection - adds litesvm 0.11, solana-keypair 3.1, solana-clock 3.0, solana-signer 3.0 as deps --- Cargo.lock | 2757 +++++++++++++++++-- crates/ilold-solana-core/Cargo.toml | 4 + crates/ilold-solana-core/src/error.rs | 6 + crates/ilold-solana-core/src/execute/mod.rs | 3 + crates/ilold-solana-core/src/execute/vm.rs | 74 + crates/ilold-solana-core/src/lib.rs | 1 + crates/ilold-solana-core/tests/vm.rs | 43 + 7 files changed, 2709 insertions(+), 179 deletions(-) create mode 100644 crates/ilold-solana-core/src/execute/mod.rs create mode 100644 crates/ilold-solana-core/src/execute/vm.rs create mode 100644 crates/ilold-solana-core/tests/vm.rs diff --git a/Cargo.lock b/Cargo.lock index 081c6d9..3cf8c91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,110 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common 0.1.7", + "generic-array", +] + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-gcm-siv" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae0784134ba9375416d469ec31e7c5f9fa94405049cf08c5ce5b4698be673e0d" +dependencies = [ + "aead", + "aes", + "cipher", + "ctr", + "polyval", + "subtle", + "zeroize", +] + +[[package]] +name = "agave-feature-set" +version = "3.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe79fc4c114c51ea8461d829bb49853a21a76c7c8ef20e9041b071558f628ce" +dependencies = [ + "ahash", + "solana-epoch-schedule", + "solana-hash 3.1.0", + "solana-pubkey 3.0.0", + "solana-sha256-hasher", + "solana-svm-feature-set", +] + +[[package]] +name = "agave-reserved-account-keys" +version = "3.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e8ceb5117fa390898f473b0d165f88482a2b36fb4a47441d8b40e22823207cb" +dependencies = [ + "agave-feature-set", + "solana-pubkey 3.0.0", + "solana-sdk-ids", +] + +[[package]] +name = "agave-syscalls" +version = "3.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98807b80e4367cc38c2b24ea30d6d16466553982aeedb0b0cb2c70bbae8ba5b0" +dependencies = [ + "bincode", + "libsecp256k1", + "num-traits", + "solana-account", + "solana-account-info", + "solana-big-mod-exp", + "solana-blake3-hasher", + "solana-bn254", + "solana-clock", + "solana-cpi", + "solana-curve25519", + "solana-hash 3.1.0", + "solana-instruction", + "solana-keccak-hasher", + "solana-loader-v3-interface", + "solana-poseidon", + "solana-program-entrypoint", + "solana-program-runtime", + "solana-pubkey 3.0.0", + "solana-sbpf", + "solana-sdk-ids", + "solana-secp256k1-recover", + "solana-sha256-hasher", + "solana-stable-layout", + "solana-stake-interface", + "solana-svm-callback", + "solana-svm-feature-set", + "solana-svm-log-collector", + "solana-svm-measure", + "solana-svm-timings", + "solana-svm-type-overrides", + "solana-sysvar", + "solana-sysvar-id", + "solana-transaction-context", + "thiserror 2.0.18", +] + [[package]] name = "ahash" version = "0.8.12" @@ -100,7 +204,7 @@ dependencies = [ "regex", "serde", "serde_json", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -132,6 +236,15 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + [[package]] name = "anstream" version = "0.6.21" @@ -212,6 +325,66 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "ark-bn254" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a22f4561524cd949590d78d7d4c5df8f592430d221f7f3c9497bbafd8972120f" +dependencies = [ + "ark-ec 0.4.2", + "ark-ff 0.4.2", + "ark-std 0.4.0", +] + +[[package]] +name = "ark-bn254" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d69eab57e8d2663efa5c63135b2af4f396d66424f88954c21104125ab6b3e6bc" +dependencies = [ + "ark-ec 0.5.0", + "ark-ff 0.5.0", + "ark-std 0.5.0", +] + +[[package]] +name = "ark-ec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defd9a439d56ac24968cca0571f598a61bc8c55f71d50a89cda591cb750670ba" +dependencies = [ + "ark-ff 0.4.2", + "ark-poly 0.4.2", + "ark-serialize 0.4.2", + "ark-std 0.4.0", + "derivative", + "hashbrown 0.13.2", + "itertools 0.10.5", + "num-traits", + "zeroize", +] + +[[package]] +name = "ark-ec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d68f2d516162846c1238e755a7c4d131b892b70cc70c471a8e3ca3ed818fce" +dependencies = [ + "ahash", + "ark-ff 0.5.0", + "ark-poly 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", + "educe", + "fnv", + "hashbrown 0.15.5", + "itertools 0.13.0", + "num-bigint 0.4.6", + "num-integer", + "num-traits", + "zeroize", +] + [[package]] name = "ark-ff" version = "0.3.0" @@ -223,7 +396,7 @@ dependencies = [ "ark-serialize 0.3.0", "ark-std 0.3.0", "derivative", - "num-bigint", + "num-bigint 0.4.6", "num-traits", "paste", "rustc_version 0.3.3", @@ -243,7 +416,7 @@ dependencies = [ "derivative", "digest 0.10.7", "itertools 0.10.5", - "num-bigint", + "num-bigint 0.4.6", "num-traits", "paste", "rustc_version 0.4.1", @@ -264,7 +437,7 @@ dependencies = [ "digest 0.10.7", "educe", "itertools 0.13.0", - "num-bigint", + "num-bigint 0.4.6", "num-traits", "paste", "zeroize", @@ -306,7 +479,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20" dependencies = [ - "num-bigint", + "num-bigint 0.4.6", "num-traits", "quote", "syn 1.0.109", @@ -318,7 +491,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" dependencies = [ - "num-bigint", + "num-bigint 0.4.6", "num-traits", "proc-macro2", "quote", @@ -331,13 +504,41 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" dependencies = [ - "num-bigint", + "num-bigint 0.4.6", "num-traits", "proc-macro2", "quote", "syn 2.0.117", ] +[[package]] +name = "ark-poly" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d320bfc44ee185d899ccbadfa8bc31aab923ce1558716e1997a1e74057fe86bf" +dependencies = [ + "ark-ff 0.4.2", + "ark-serialize 0.4.2", + "ark-std 0.4.0", + "derivative", + "hashbrown 0.13.2", +] + +[[package]] +name = "ark-poly" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "579305839da207f02b89cd1679e50e67b4331e2f9294a57693e5051b7703fe27" +dependencies = [ + "ahash", + "ark-ff 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", + "educe", + "fnv", + "hashbrown 0.15.5", +] + [[package]] name = "ark-serialize" version = "0.3.0" @@ -354,9 +555,10 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" dependencies = [ + "ark-serialize-derive 0.4.2", "ark-std 0.4.0", "digest 0.10.7", - "num-bigint", + "num-bigint 0.4.6", ] [[package]] @@ -365,10 +567,33 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" dependencies = [ + "ark-serialize-derive 0.5.0", "ark-std 0.5.0", "arrayvec", "digest 0.10.7", - "num-bigint", + "num-bigint 0.4.6", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae3281bc6d0fd7e549af32b52511e1302185bd688fd3359fa36423346ff682ea" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-serialize-derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "213888f660fddcca0d257e88e54ac05bca01885f258ccdf695bafd77031bb69d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -401,12 +626,24 @@ dependencies = [ "rand 0.8.5", ] +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + [[package]] name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "ascii" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eab1c04a571841102f5345a8fc0f6bb3d31c315dec879b5c6e42e40ce7ffa34e" + [[package]] name = "atomic-waker" version = "1.1.2" @@ -437,7 +674,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", - "base64", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -491,6 +728,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base64" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff" + [[package]] name = "base64" version = "0.22.1" @@ -503,6 +746,15 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -545,6 +797,30 @@ dependencies = [ "wyz", ] +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -554,16 +830,39 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "borsh" version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" dependencies = [ + "borsh-derive", "bytes", "cfg_aliases", ] +[[package]] +name = "borsh-derive" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "boxcar" version = "0.2.14" @@ -585,12 +884,42 @@ version = "3.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +[[package]] +name = "bv" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8834bb1d8ee5dc048ee3124f2c7c1afcc6bc9aed03f11e9dfd8c69470a5db340" +dependencies = [ + "feature-probe", + "serde", +] + [[package]] name = "byte-slice-cast" version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "byteorder" version = "1.5.0" @@ -637,6 +966,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "cfg_eval" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45565fc9416b9896014f5732ac776f810ee53a66730c17e4020c3ec064a8f88f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "chrono" version = "0.4.44" @@ -649,6 +989,16 @@ dependencies = [ "windows-link", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common 0.1.7", + "inout", +] + [[package]] name = "clap" version = "4.6.0" @@ -689,6 +1039,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmov" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f88a43d011fc4a6876cb7344703e297c71dda42494fee094d5f7c76bf13f746" + [[package]] name = "colorchoice" version = "1.0.5" @@ -704,6 +1060,19 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "combine" +version = "3.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3da6baa321ec19e1cc41d31bf599f00c783d0517095cdaf0332e3fe8d20680" +dependencies = [ + "ascii", + "byteorder", + "either", + "memchr", + "unreachable", +] + [[package]] name = "compact_str" version = "0.9.0" @@ -725,7 +1094,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "531185e432bb31db1ecda541e9e7ab21468d4d844ad7505e0546a49b4945d49b" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "proptest", "serde_core", ] @@ -756,6 +1125,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "convert_case" version = "0.10.0" @@ -800,6 +1175,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crossbeam-deque" version = "0.8.6" @@ -878,39 +1262,131 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] [[package]] -name = "darling" -version = "0.23.0" +name = "crypto-common" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" dependencies = [ - "darling_core", - "darling_macro", + "hybrid-array", ] [[package]] -name = "darling_core" -version = "0.23.0" +name = "ctr" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" dependencies = [ - "ident_case", - "proc-macro2", - "quote", + "cipher", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rand_core 0.6.4", + "rustc_version 0.4.1", + "serde", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", "strsim", "syn 2.0.117", ] +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", + "quote", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.117", ] @@ -954,6 +1430,12 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derivation-path" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e5c37193a1db1d8ed868c03ec7b152175f26160a5b740e5e484143877e0adf0" + [[package]] name = "derivative" version = "2.2.0" @@ -1003,12 +1485,23 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", "const-oid", - "crypto-common", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.0", + "crypto-common 0.2.1", + "ctutils", +] + [[package]] name = "dirs" version = "6.0.0" @@ -1062,6 +1555,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "eager" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe71d579d1812060163dff96056261deb5bf6729b100fa2e36a68b9649ba3d3" + [[package]] name = "ecdsa" version = "0.16.9" @@ -1076,6 +1575,31 @@ dependencies = [ "spki", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "educe" version = "0.6.0" @@ -1122,6 +1646,26 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "enum-iterator" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fd242f399be1da0a5354aa462d57b4ab2b4ee0683cc552f7c007d2d12d36e94" +dependencies = [ + "enum-iterator-derive", +] + +[[package]] +name = "enum-iterator-derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685adfa4d6f3d765a26bc5dbc936577de9abf756c1feeb3089b01dd395034842" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "enum-ordinalize" version = "4.3.2" @@ -1197,6 +1741,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "feature-probe" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835a3dc7d1ec9e75e2b5fb4ba75396837112d2060b03f7d43bc1897c7f7211da" + [[package]] name = "ff" version = "0.13.1" @@ -1207,6 +1757,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filedescriptor" version = "0.8.3" @@ -1377,6 +1933,17 @@ dependencies = [ "zeroize", ] +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1384,8 +1951,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1443,6 +2012,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a3c133739dddd0d2990f9a4bdf8eb4b21ef50e4851ca85ab661199821d510e" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -1455,6 +2042,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", "foldhash 0.1.5", ] @@ -1552,6 +2140,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.8.1" @@ -1613,7 +2210,7 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", @@ -1820,9 +2417,13 @@ dependencies = [ "anyhow", "borsh", "bs58", + "litesvm", "serde", "serde_json", - "solana-address", + "solana-address 2.6.0", + "solana-clock", + "solana-keypair", + "solana-signer", "tempfile", "thiserror 2.0.18", ] @@ -1892,13 +2493,22 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "instability" version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" dependencies = [ - "darling", + "darling 0.23.0", "indoc", "proc-macro2", "quote", @@ -1958,6 +2568,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.13.0" @@ -2004,7 +2623,8 @@ dependencies = [ "ecdsa", "elliptic-curve", "once_cell", - "sha2", + "sha2 0.10.9", + "signature", ] [[package]] @@ -2024,7 +2644,7 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -2070,6 +2690,76 @@ dependencies = [ "libc", ] +[[package]] +name = "libsecp256k1" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9d220bc1feda2ac231cb78c3d26f27676b8cf82c96971f7aeef3d0cf2797c73" +dependencies = [ + "arrayref", + "base64 0.12.3", + "digest 0.9.0", + "libsecp256k1-core", + "libsecp256k1-gen-ecmult", + "libsecp256k1-gen-genmult", + "rand 0.7.3", + "serde", + "sha2 0.9.9", +] + +[[package]] +name = "libsecp256k1-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f6ab710cec28cef759c5f18671a27dae2a5f952cdaaee1d8e2908cb2478a80" +dependencies = [ + "crunchy", + "digest 0.9.0", + "subtle", +] + +[[package]] +name = "libsecp256k1-gen-ecmult" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccab96b584d38fac86a83f07e659f0deafd0253dc096dab5a36d53efe653c5c3" +dependencies = [ + "libsecp256k1-core", +] + +[[package]] +name = "libsecp256k1-gen-genmult" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67abfe149395e3aa1c48a2beb32b068e2334402df8181f818d3aee2b304c4f5d" +dependencies = [ + "libsecp256k1-core", +] + +[[package]] +name = "light-poseidon" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c9a85a9752c549ceb7578064b4ed891179d20acd85f27318573b64d2d7ee7ee" +dependencies = [ + "ark-bn254 0.4.0", + "ark-ff 0.4.2", + "num-bigint 0.4.6", + "thiserror 1.0.69", +] + +[[package]] +name = "light-poseidon" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a1ccadd0bb5a32c196da536fd72c59183de24a055f6bf0513bf845fefab862" +dependencies = [ + "ark-bn254 0.5.0", + "ark-ff 0.5.0", + "num-bigint 0.4.6", + "thiserror 1.0.69", +] + [[package]] name = "line-clipping" version = "0.3.7" @@ -2091,6 +2781,70 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litesvm" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "347d8c652d592c618ac996f2ab21f8c0b0f2da3fbbca227a6887ee61bb75f2de" +dependencies = [ + "agave-feature-set", + "agave-reserved-account-keys", + "agave-syscalls", + "ansi_term", + "bincode", + "indexmap", + "itertools 0.14.0", + "log", + "serde", + "solana-account", + "solana-address 2.6.0", + "solana-address-lookup-table-interface", + "solana-bpf-loader-program", + "solana-builtins", + "solana-clock", + "solana-compute-budget", + "solana-compute-budget-instruction", + "solana-epoch-rewards", + "solana-epoch-schedule", + "solana-feature-gate-interface", + "solana-fee", + "solana-fee-structure", + "solana-hash 3.1.0", + "solana-instruction", + "solana-instructions-sysvar", + "solana-keypair", + "solana-last-restart-slot", + "solana-loader-v3-interface", + "solana-loader-v4-interface", + "solana-message", + "solana-native-token", + "solana-nonce", + "solana-nonce-account", + "solana-precompile-error", + "solana-program-error", + "solana-program-runtime", + "solana-rent 3.1.0", + "solana-sdk-ids", + "solana-sha256-hasher", + "solana-signature", + "solana-signer", + "solana-slot-hashes", + "solana-slot-history", + "solana-stake-interface", + "solana-svm-callback", + "solana-svm-log-collector", + "solana-svm-timings", + "solana-svm-transaction", + "solana-system-interface 2.0.0", + "solana-system-program", + "solana-sysvar", + "solana-sysvar-id", + "solana-transaction", + "solana-transaction-context", + "solana-transaction-error", + "thiserror 2.0.18", +] + [[package]] name = "litrs" version = "1.0.0" @@ -2142,6 +2896,18 @@ dependencies = [ "autocfg", ] +[[package]] +name = "merlin" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d" +dependencies = [ + "byteorder", + "keccak", + "rand_core 0.6.4", + "zeroize", +] + [[package]] name = "mime" version = "0.3.17" @@ -2166,7 +2932,7 @@ checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", "log", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -2217,37 +2983,106 @@ dependencies = [ ] [[package]] -name = "num-bigint" -version = "0.4.6" +name = "num" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "b8536030f9fea7127f841b45bb6243b27255787fb4eb83958aa1ef9d2fdc0c36" dependencies = [ + "num-bigint 0.2.6", + "num-complex", "num-integer", + "num-iter", + "num-rational 0.2.4", "num-traits", ] [[package]] -name = "num-conv" -version = "0.2.1" +name = "num-bigint" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "090c7f9998ee0ff65aa5b723e4009f7b217707f1fb5ea551329cc4d6231fb304" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] [[package]] -name = "num-integer" -version = "0.1.46" +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b19411a9719e753aff12e5187b74d60d3dc449ec3f4dc21e3989c3f554bc95" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "num-integer" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c000134b5dbf44adc5cb772486d335293351644b801551abe8f75c84cfa4aef" +dependencies = [ + "autocfg", + "num-bigint 0.2.6", + "num-integer", + "num-traits", +] + [[package]] name = "num-rational" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" dependencies = [ - "num-bigint", + "num-bigint 0.4.6", "num-integer", "num-traits", ] @@ -2295,6 +3130,12 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "openssl" version = "0.10.76" @@ -2402,12 +3243,36 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +[[package]] +name = "pastey" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5a797f0e07bdf071d15742978fc3128ec6c22891c31a3a931513263904c982a" + +[[package]] +name = "pbkdf2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "percent-encoding" version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "percentage" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fd23b938276f14057220b707937bcb42fa76dda7560e57a2da30cb52d557937" +dependencies = [ + "num", +] + [[package]] name = "pest" version = "2.8.6" @@ -2458,6 +3323,18 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polyval" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "opaque-debug", + "universal-hash", +] + [[package]] name = "portable-atomic" version = "1.13.1" @@ -2567,6 +3444,26 @@ dependencies = [ "unarray", ] +[[package]] +name = "qstring" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d464fae65fff2680baf48019211ce37aaec0c78e9264c84a3e484717f965104e" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "qualifier_attr" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e2e25ee72f5b24d773cae88422baddefff7714f97aab68d96fe2b6fc4a28fb2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -2600,6 +3497,19 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", +] + [[package]] name = "rand" version = "0.8.5" @@ -2622,6 +3532,16 @@ dependencies = [ "serde", ] +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2642,6 +3562,15 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -2661,6 +3590,15 @@ dependencies = [ "serde", ] +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + [[package]] name = "rand_xorshift" version = "0.4.0" @@ -2838,7 +3776,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", @@ -2919,7 +3857,7 @@ dependencies = [ "bytes", "fastrlp 0.3.1", "fastrlp 0.4.0", - "num-bigint", + "num-bigint 0.4.6", "num-integer", "num-traits", "parity-scale-codec", @@ -2940,6 +3878,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "rustc-hash" version = "2.1.1" @@ -3117,265 +4061,1654 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" name = "semver-parser" version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9900206b54a3527fdc7b8a938bffd94a568bac4f4aa8113b209df75a09c0dec2" +checksum = "9900206b54a3527fdc7b8a938bffd94a568bac4f4aa8113b209df75a09c0dec2" +dependencies = [ + "pest", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-big-array" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11fc7cc2c76d73e0f27ee52abbd64eec84d46f370c88371120433196934e4b7f" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05839ce67618e14a09b286535c0d9c94e85ef25469b0e13cb4f844e5593eb19" +dependencies = [ + "serde_core", + "serde_with_macros", +] + +[[package]] +name = "serde_with_macros" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf2ebbe86054f9b45bc3881e865683ccfaccce97b9b4cb53f3039d67f355a334" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serial" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" +dependencies = [ + "serial-core", + "serial-unix", + "serial-windows", +] + +[[package]] +name = "serial-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +dependencies = [ + "libc", +] + +[[package]] +name = "serial-unix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +dependencies = [ + "ioctl-rs", + "libc", + "serial-core", + "termios", +] + +[[package]] +name = "serial-windows" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +dependencies = [ + "libc", + "serial-core", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.9.0", + "opaque-debug", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2-const-stable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f179d4e11094a893b82fff208f74d448a7512f99f5a0acbd5c679b705f83ed9" + +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest 0.10.7", + "keccak", +] + +[[package]] +name = "sha3-asm" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59cbb88c189d6352cc8ae96a39d19c7ecad8f7330b29461187f2587fdc2988d5" +dependencies = [ + "cc", + "cfg-if", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest 0.10.7", + "rand_core 0.6.4", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "solana-account" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efc0ed36decb689413b9da5d57f2be49eea5bebb3cf7897015167b0c4336e731" +dependencies = [ + "bincode", + "serde", + "serde_bytes", + "serde_derive", + "solana-account-info", + "solana-clock", + "solana-instruction-error", + "solana-pubkey 4.2.0", + "solana-sdk-ids", + "solana-sysvar", +] + +[[package]] +name = "solana-account-info" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9cf16495d9eb53e3d04e72366a33bb1c20c24e78c171d8b8f5978357b63ae95" +dependencies = [ + "solana-address 2.6.0", + "solana-program-error", + "solana-program-memory", +] + +[[package]] +name = "solana-address" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2ecac8e1b7f74c2baa9e774c42817e3e75b20787134b76cc4d45e8a604488f5" +dependencies = [ + "solana-address 2.6.0", +] + +[[package]] +name = "solana-address" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1384b52c435a750cc9c538760fc7bb472fd78e65a9900a2d07312c5bb335b72" +dependencies = [ + "bytemuck", + "bytemuck_derive", + "curve25519-dalek", + "five8", + "five8_const", + "serde", + "serde_derive", + "sha2-const-stable", + "solana-atomic-u64", + "solana-define-syscall 5.1.0", + "solana-program-error", + "solana-sanitize", + "solana-sha256-hasher", +] + +[[package]] +name = "solana-address-lookup-table-interface" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115b4f773acc4f3f3cb986b0d335e9845c0368c82b0940410935bc11ae065578" +dependencies = [ + "bincode", + "bytemuck", + "serde", + "serde_derive", + "solana-clock", + "solana-instruction", + "solana-instruction-error", + "solana-pubkey 4.2.0", + "solana-sdk-ids", + "solana-slot-hashes", +] + +[[package]] +name = "solana-atomic-u64" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "085db4906d89324cef2a30840d59eaecf3d4231c560ec7c9f6614a93c652f501" +dependencies = [ + "parking_lot", +] + +[[package]] +name = "solana-big-mod-exp" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30c80fb6d791b3925d5ec4bf23a7c169ef5090c013059ec3ed7d0b2c04efa085" +dependencies = [ + "num-bigint 0.4.6", + "num-traits", + "solana-define-syscall 3.0.0", +] + +[[package]] +name = "solana-bincode" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "278a1a5bad62cd9da89ac8d4b7ec444e83caa8ae96aa656dfc27684b28d49a5d" +dependencies = [ + "bincode", + "serde_core", + "solana-instruction-error", +] + +[[package]] +name = "solana-blake3-hasher" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7116e1d942a2432ca3f514625104757ab8a56233787e95144c93950029e31176" +dependencies = [ + "blake3", + "solana-define-syscall 4.0.1", + "solana-hash 4.3.0", +] + +[[package]] +name = "solana-bn254" +version = "3.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ff13a8867fcc7b0f1114764e1bf6191b4551dcaf93729ddc676cd4ec6abc9f" +dependencies = [ + "ark-bn254 0.5.0", + "ark-ec 0.5.0", + "ark-ff 0.5.0", + "ark-serialize 0.5.0", + "bytemuck", + "solana-define-syscall 5.1.0", + "thiserror 2.0.18", +] + +[[package]] +name = "solana-borsh" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c04abbae16f57178a163125805637b8a076175bb5c0002fb04f4792bea901cf7" +dependencies = [ + "borsh", +] + +[[package]] +name = "solana-bpf-loader-program" +version = "3.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb423db3faa08533a122f867456bb5b7aab211818af004552ea6df5f3c43ef49" +dependencies = [ + "agave-syscalls", + "bincode", + "qualifier_attr", + "solana-account", + "solana-bincode", + "solana-clock", + "solana-instruction", + "solana-loader-v3-interface", + "solana-loader-v4-interface", + "solana-packet", + "solana-program-entrypoint", + "solana-program-runtime", + "solana-pubkey 3.0.0", + "solana-sbpf", + "solana-sdk-ids", + "solana-svm-feature-set", + "solana-svm-log-collector", + "solana-svm-measure", + "solana-svm-type-overrides", + "solana-system-interface 2.0.0", + "solana-transaction-context", +] + +[[package]] +name = "solana-builtins" +version = "3.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc47a5aefa70261825037efd942c2c78a600f4dcc110d59808b359c5d37aa941" +dependencies = [ + "agave-feature-set", + "solana-bpf-loader-program", + "solana-compute-budget-program", + "solana-hash 3.1.0", + "solana-loader-v4-program", + "solana-program-runtime", + "solana-pubkey 3.0.0", + "solana-sdk-ids", + "solana-system-program", + "solana-vote-program", + "solana-zk-elgamal-proof-program", + "solana-zk-token-proof-program", +] + +[[package]] +name = "solana-builtins-default-costs" +version = "3.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a91f5db54bebaffb93e8bd0d85575139597de7cb1ac32f040442fd66bc90ed0" +dependencies = [ + "agave-feature-set", + "ahash", + "log", + "solana-bpf-loader-program", + "solana-compute-budget-program", + "solana-loader-v4-program", + "solana-pubkey 3.0.0", + "solana-sdk-ids", + "solana-system-program", + "solana-vote-program", +] + +[[package]] +name = "solana-clock" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95cf11109c3b6115cc510f1e31f06fdd52f504271bc24ef5f1249fbbcae5f9f3" +dependencies = [ + "serde", + "serde_derive", + "solana-sdk-ids", + "solana-sdk-macro", + "solana-sysvar-id", +] + +[[package]] +name = "solana-compute-budget" +version = "3.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de86231371bf26dbcf473a0ea7ca424184db0c7720fafbb899d2fca2eaf1ac2" +dependencies = [ + "solana-fee-structure", + "solana-program-runtime", +] + +[[package]] +name = "solana-compute-budget-instruction" +version = "3.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27f3d546bf7f979423b8cca3c16ac9b51c80104b5f6bba77ef90b41aa00ec96d" +dependencies = [ + "agave-feature-set", + "log", + "solana-borsh", + "solana-builtins-default-costs", + "solana-compute-budget", + "solana-compute-budget-interface", + "solana-instruction", + "solana-packet", + "solana-pubkey 3.0.0", + "solana-sdk-ids", + "solana-svm-transaction", + "solana-transaction-error", + "thiserror 2.0.18", +] + +[[package]] +name = "solana-compute-budget-interface" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8292c436b269ad23cecc8b24f7da3ab07ca111661e25e00ce0e1d22771951ab9" +dependencies = [ + "borsh", + "solana-instruction", + "solana-sdk-ids", +] + +[[package]] +name = "solana-compute-budget-program" +version = "3.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b54b78862ca94a2a86354c22f2789ffd095c5f972c15ca104020697dd2cf3409" +dependencies = [ + "solana-program-runtime", +] + +[[package]] +name = "solana-cpi" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dea26709d867aada85d0d3617db0944215c8bb28d3745b912de7db13a23280c" +dependencies = [ + "solana-account-info", + "solana-define-syscall 4.0.1", + "solana-instruction", + "solana-program-error", + "solana-pubkey 4.2.0", + "solana-stable-layout", +] + +[[package]] +name = "solana-curve25519" +version = "3.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aff7432cdf2ec6a44ac06b4d64d2ee006f6c0066d6456e032a7fe25be40cd5c" +dependencies = [ + "bytemuck", + "bytemuck_derive", + "curve25519-dalek", + "solana-define-syscall 3.0.0", + "subtle", + "thiserror 2.0.18", +] + +[[package]] +name = "solana-define-syscall" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9697086a4e102d28a156b8d6b521730335d6951bd39a5e766512bbe09007cee" + +[[package]] +name = "solana-define-syscall" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57e5b1c0bc1d4a4d10c88a4100499d954c09d3fecfae4912c1a074dff68b1738" + +[[package]] +name = "solana-define-syscall" +version = "5.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21e14a4f604117f379840956a8fc8695e4c84f5b0ebed192f31f60d9b85d581d" + +[[package]] +name = "solana-derivation-path" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff71743072690fdbdfcdc37700ae1cb77485aaad49019473a81aee099b1e0b8c" +dependencies = [ + "derivation-path", + "qstring", + "uriparse", +] + +[[package]] +name = "solana-epoch-rewards" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5e7b0ba210593ba8ddd39d6d234d81795d1671cebf3026baa10d5dc23ac42f0" +dependencies = [ + "serde", + "serde_derive", + "solana-hash 4.3.0", + "solana-sdk-ids", + "solana-sdk-macro", + "solana-sysvar-id", +] + +[[package]] +name = "solana-epoch-schedule" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce264b7b42322325947c4136a09460bf5c73d9aa8262c9b0a2064be63ba8639" +dependencies = [ + "serde", + "serde_derive", + "solana-sdk-ids", + "solana-sdk-macro", + "solana-sysvar-id", +] + +[[package]] +name = "solana-feature-gate-interface" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75ca9b5cbb6f500f7fd73db5bd95640f71a83f04d6121a0e59a43b202dca2731" +dependencies = [ + "bincode", + "serde", + "serde_derive", + "solana-account", + "solana-account-info", + "solana-instruction", + "solana-program-error", + "solana-pubkey 4.2.0", + "solana-rent 4.2.0", + "solana-sdk-ids", + "solana-system-interface 3.2.0", +] + +[[package]] +name = "solana-fee" +version = "3.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c276ea9723bfb6bf9fa2bcde1fa652140b0879d258c78a482533c9c01f71f416" +dependencies = [ + "agave-feature-set", + "solana-fee-structure", + "solana-svm-transaction", +] + +[[package]] +name = "solana-fee-calculator" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57e8add96b5741573e9f7529c4bb7719cfcfa999c3847a68cdfaef0cb6adf567" +dependencies = [ + "log", + "serde", + "serde_derive", +] + +[[package]] +name = "solana-fee-structure" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2abdb1223eea8ec64136f39cb1ffcf257e00f915c957c35c0dd9e3f4e700b0" + +[[package]] +name = "solana-hash" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "337c246447142f660f778cf6cb582beba8e28deb05b3b24bfb9ffd7c562e5f41" +dependencies = [ + "solana-hash 4.3.0", +] + +[[package]] +name = "solana-hash" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1b113239362cee7093bfb250467138f079a2a03673181dc15bff6ccd677912d" +dependencies = [ + "borsh", + "bytemuck", + "bytemuck_derive", + "five8", + "serde", + "serde_derive", + "solana-atomic-u64", + "solana-sanitize", + "wincode", +] + +[[package]] +name = "solana-instruction" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37ebb0ffd19263051bc3f683fcc086134b8ff23af894dcb63f7563c7137b42f1" +dependencies = [ + "bincode", + "serde", + "serde_derive", + "solana-define-syscall 5.1.0", + "solana-instruction-error", + "solana-pubkey 4.2.0", +] + +[[package]] +name = "solana-instruction-error" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b188842592fdf6cb96f55263ae1bf11713ab5114401d1d5a881ed7cc41bef6" +dependencies = [ + "num-traits", + "serde", + "serde_derive", + "solana-program-error", +] + +[[package]] +name = "solana-instructions-sysvar" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ddf67876c541aa1e21ee1acae35c95c6fbc61119814bfef70579317a5e26955" +dependencies = [ + "bitflags 2.11.0", + "solana-account-info", + "solana-instruction", + "solana-instruction-error", + "solana-program-error", + "solana-pubkey 3.0.0", + "solana-sanitize", + "solana-sdk-ids", + "solana-serialize-utils", + "solana-sysvar-id", +] + +[[package]] +name = "solana-keccak-hasher" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed1c0d16d6fdeba12291a1f068cdf0d479d9bff1141bf44afd7aa9d485f65ef8" +dependencies = [ + "sha3", + "solana-define-syscall 4.0.1", + "solana-hash 4.3.0", +] + +[[package]] +name = "solana-keypair" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "263d614c12aa267a3278703175fd6440552ca61bc960b5a02a4482720c53438b" +dependencies = [ + "ed25519-dalek", + "five8", + "five8_core", + "rand 0.9.2", + "solana-address 2.6.0", + "solana-seed-phrase", + "solana-signature", + "solana-signer", +] + +[[package]] +name = "solana-last-restart-slot" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcda154ec827f5fc1e4da0af3417951b7e9b8157540f81f936c4a8b1156134d0" +dependencies = [ + "serde", + "serde_derive", + "solana-sdk-ids", + "solana-sdk-macro", + "solana-sysvar-id", +] + +[[package]] +name = "solana-loader-v3-interface" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e0538d4dbc9022e01616f1c58f2db98ece739c5d5ed4a2ef8737a953e76a2d4" +dependencies = [ + "serde", + "serde_bytes", + "serde_derive", + "solana-instruction", + "solana-pubkey 4.2.0", + "solana-sdk-ids", +] + +[[package]] +name = "solana-loader-v4-interface" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4c948b33ff81fa89699911b207059e493defdba9647eaf18f23abdf3674e0fb" +dependencies = [ + "serde", + "serde_bytes", + "serde_derive", + "solana-instruction", + "solana-pubkey 3.0.0", + "solana-sdk-ids", + "solana-system-interface 2.0.0", +] + +[[package]] +name = "solana-loader-v4-program" +version = "3.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4495b9ef97f369302d882f752465c563ac2aaf7f52cd1a9cf15891a90f986f5f" +dependencies = [ + "log", + "solana-account", + "solana-bincode", + "solana-bpf-loader-program", + "solana-instruction", + "solana-loader-v3-interface", + "solana-loader-v4-interface", + "solana-packet", + "solana-program-runtime", + "solana-pubkey 3.0.0", + "solana-sbpf", + "solana-sdk-ids", + "solana-svm-log-collector", + "solana-svm-measure", + "solana-svm-type-overrides", + "solana-transaction-context", +] + +[[package]] +name = "solana-message" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0448b1fd891c5f46491e5dc7d9986385ba3c852c340db2911dd29faa01d2b08d" +dependencies = [ + "bincode", + "blake3", + "lazy_static", + "serde", + "serde_derive", + "solana-address 2.6.0", + "solana-hash 4.3.0", + "solana-instruction", + "solana-sanitize", + "solana-sdk-ids", + "solana-short-vec", + "solana-transaction-error", +] + +[[package]] +name = "solana-msg" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "726b7cbbc6be6f1c6f29146ac824343b9415133eee8cce156452ad1db93f8008" +dependencies = [ + "solana-define-syscall 5.1.0", +] + +[[package]] +name = "solana-native-token" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae8dd4c280dca9d046139eb5b7a5ac9ad10403fbd64964c7d7571214950d758f" + +[[package]] +name = "solana-nonce" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95dbc9f2e33b6c10e231df15cb2a3bff9ea7eab6347f9e316fe75c97fd67bbb" +dependencies = [ + "serde", + "serde_derive", + "solana-fee-calculator", + "solana-hash 4.3.0", + "solana-pubkey 4.2.0", + "solana-sha256-hasher", +] + +[[package]] +name = "solana-nonce-account" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "805fd25b29e5a1a0e6c3dd6320c9da80f275fbe4ff6e392617c303a2085c435e" +dependencies = [ + "solana-account", + "solana-hash 3.1.0", + "solana-nonce", + "solana-sdk-ids", +] + +[[package]] +name = "solana-packet" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edf2f25743c95229ac0fdc32f8f5893ef738dbf332c669e9861d33ddb0f469d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "solana-poseidon" +version = "3.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13ac13134287d7af80717353a8136e3c515d7f34d88e6f116b47350bd623e338" +dependencies = [ + "ark-bn254 0.4.0", + "ark-bn254 0.5.0", + "light-poseidon 0.2.0", + "light-poseidon 0.4.0", + "solana-define-syscall 3.0.0", + "thiserror 2.0.18", +] + +[[package]] +name = "solana-precompile-error" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cafcd950de74c6c39d55dc8ca108bbb007799842ab370ef26cf45a34453c31e1" +dependencies = [ + "num-traits", +] + +[[package]] +name = "solana-program-entrypoint" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c9b0a1ff494e05f503a08b3d51150b73aa639544631e510279d6375f290997" +dependencies = [ + "solana-account-info", + "solana-define-syscall 4.0.1", + "solana-program-error", + "solana-pubkey 4.2.0", +] + +[[package]] +name = "solana-program-error" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f04fa578707b3612b095f0c8e19b66a1233f7c42ca8082fcb3b745afcc0add6" + +[[package]] +name = "solana-program-memory" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4068648649653c2c50546e9a7fb761791b5ab0cda054c771bb5808d3a4b9eb52" +dependencies = [ + "solana-define-syscall 4.0.1", +] + +[[package]] +name = "solana-program-runtime" +version = "3.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c03c5100c43bf28fd03a11b66345ccdc28c1b7e5a7d49dbcff64e6442595627" +dependencies = [ + "base64 0.22.1", + "bincode", + "itertools 0.12.1", + "log", + "percentage", + "rand 0.8.5", + "serde", + "solana-account", + "solana-account-info", + "solana-clock", + "solana-epoch-rewards", + "solana-epoch-schedule", + "solana-fee-structure", + "solana-hash 3.1.0", + "solana-instruction", + "solana-last-restart-slot", + "solana-loader-v3-interface", + "solana-program-entrypoint", + "solana-pubkey 3.0.0", + "solana-rent 3.1.0", + "solana-sbpf", + "solana-sdk-ids", + "solana-slot-hashes", + "solana-stable-layout", + "solana-stake-interface", + "solana-svm-callback", + "solana-svm-feature-set", + "solana-svm-log-collector", + "solana-svm-measure", + "solana-svm-timings", + "solana-svm-transaction", + "solana-svm-type-overrides", + "solana-system-interface 2.0.0", + "solana-sysvar", + "solana-sysvar-id", + "solana-transaction-context", + "thiserror 2.0.18", +] + +[[package]] +name = "solana-pubkey" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8909d399deb0851aa524420beeb5646b115fd253ef446e35fe4504c904da3941" +dependencies = [ + "solana-address 1.1.0", +] + +[[package]] +name = "solana-pubkey" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7db719574990de7e8b0f55a8593ac92a5ccb42c8ce67b3e4bf05b139d5d9ee71" +dependencies = [ + "solana-address 2.6.0", +] + +[[package]] +name = "solana-rent" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e860d5499a705369778647e97d760f7670adfb6fc8419dd3d568deccd46d5487" +dependencies = [ + "serde", + "serde_derive", + "solana-sdk-ids", + "solana-sdk-macro", + "solana-sysvar-id", +] + +[[package]] +name = "solana-rent" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9809b081e99bc142ce803bcd7ee18306759ce3b30a96a9da3f6f41c45e50ef0" +dependencies = [ + "solana-sdk-macro", +] + +[[package]] +name = "solana-sanitize" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcf09694a0fc14e5ffb18f9b7b7c0f15ecb6eac5b5610bf76a1853459d19daf9" + +[[package]] +name = "solana-sbpf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b15b079e08471a9dbfe1e48b2c7439c85aa2a055cbd54eddd8bd257b0a7dbb29" +dependencies = [ + "byteorder", + "combine", + "hash32", + "libc", + "log", + "rand 0.8.5", + "rustc-demangle", + "thiserror 2.0.18", + "winapi", +] + +[[package]] +name = "solana-sdk-ids" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def234c1956ff616d46c9dd953f251fa7096ddbaa6d52b165218de97882b7280" +dependencies = [ + "solana-address 2.6.0", +] + +[[package]] +name = "solana-sdk-macro" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8765316242300c48242d84a41614cb3388229ec353ba464f6fe62a733e41806f" +dependencies = [ + "bs58", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "solana-secp256k1-recover" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c5f18893d62e6c73117dcba48f8f5e3266d90e5ec3d0a0a90f9785adac36c1" +dependencies = [ + "k256", + "solana-define-syscall 5.1.0", + "thiserror 2.0.18", +] + +[[package]] +name = "solana-seed-derivable" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff7bdb72758e3bec33ed0e2658a920f1f35dfb9ed576b951d20d63cb61ecd95c" +dependencies = [ + "solana-derivation-path", +] + +[[package]] +name = "solana-seed-phrase" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc905b200a95f2ea9146e43f2a7181e3aeb55de6bc12afb36462d00a3c7310de" +dependencies = [ + "hmac", + "pbkdf2", + "sha2 0.10.9", +] + +[[package]] +name = "solana-serde-varint" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950e5b83e839dc0f92c66afc124bb8f40e89bc90f0579e8ec5499296d27f54e3" +dependencies = [ + "serde", +] + +[[package]] +name = "solana-serialize-utils" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d7cc401931d178472358e6b78dc72d031dc08f752d7410f0e8bd259dd6f02fa" +dependencies = [ + "solana-instruction-error", + "solana-pubkey 4.2.0", + "solana-sanitize", +] + +[[package]] +name = "solana-sha256-hasher" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db7dc3011ea4c0334aaaa7e7128cb390ecf546b28d412e9bf2064680f57f588f" dependencies = [ - "pest", + "sha2 0.10.9", + "solana-define-syscall 4.0.1", + "solana-hash 4.3.0", ] [[package]] -name = "serde" -version = "1.0.228" +name = "solana-short-vec" +version = "3.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "2bb8cc883fc7b8ce4a7814cb1441b48c06437049ec11847005cf63bcfa85c546" dependencies = [ "serde_core", - "serde_derive", ] [[package]] -name = "serde_core" -version = "1.0.228" +name = "solana-signature" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "e7a73c6e97cc2108be0adf6a6ea326434f8398df9d7eed81da2a4548b69e971c" dependencies = [ + "ed25519-dalek", + "five8", + "serde", + "serde-big-array", "serde_derive", + "solana-sanitize", + "wincode", ] [[package]] -name = "serde_derive" -version = "1.0.228" +name = "solana-signer" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "5bfea97951fee8bae0d6038f39a5efcb6230ecdfe33425ac75196d1a1e3e3235" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", + "solana-pubkey 3.0.0", + "solana-signature", + "solana-transaction-error", ] [[package]] -name = "serde_json" -version = "1.0.149" +name = "solana-slot-hashes" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "2585f70191623887329dfb5078da3a00e15e3980ea67f42c2e10b07028419f43" dependencies = [ - "itoa", - "memchr", "serde", - "serde_core", - "zmij", + "serde_derive", + "solana-hash 4.3.0", + "solana-sdk-ids", + "solana-sysvar-id", ] [[package]] -name = "serde_path_to_error" -version = "0.1.20" +name = "solana-slot-history" +version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +checksum = "f914f6b108f5bba14a280b458d023e3621c9973f27f015a4d755b50e88d89e97" dependencies = [ - "itoa", + "bv", "serde", - "serde_core", + "serde_derive", + "solana-sdk-ids", + "solana-sysvar-id", ] [[package]] -name = "serde_urlencoded" -version = "0.7.1" +name = "solana-stable-layout" +version = "3.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +checksum = "c9f6a291ba063a37780af29e7db14bdd3dc447584d8ba5b3fc4b88e2bbc982fa" dependencies = [ - "form_urlencoded", - "itoa", - "ryu", - "serde", + "solana-instruction", + "solana-pubkey 4.2.0", ] [[package]] -name = "serial" -version = "0.4.0" +name = "solana-stake-interface" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" +checksum = "b9bc26191b533f9a6e5a14cca05174119819ced680a80febff2f5051a713f0db" dependencies = [ - "serial-core", - "serial-unix", - "serial-windows", + "num-traits", + "serde", + "serde_derive", + "solana-clock", + "solana-cpi", + "solana-instruction", + "solana-program-error", + "solana-pubkey 3.0.0", + "solana-system-interface 2.0.0", + "solana-sysvar", + "solana-sysvar-id", ] [[package]] -name = "serial-core" -version = "0.4.0" +name = "solana-svm-callback" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +checksum = "012617d16d2994673d98792f7f6d93f612dea00b1b747a3c4aec24c12547875b" dependencies = [ - "libc", + "solana-account", + "solana-clock", + "solana-precompile-error", + "solana-pubkey 3.0.0", ] [[package]] -name = "serial-unix" -version = "0.4.0" +name = "solana-svm-feature-set" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +checksum = "7cc2e2fdebd77159b7a14ee45c9dbb3f1d202e8e7ccc14e4cda78c006a7a78a9" + +[[package]] +name = "solana-svm-log-collector" +version = "3.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ce188c2c438ced63a975af79f06db2ff5accaf1a4027a26e35783be566f6070" dependencies = [ - "ioctl-rs", - "libc", - "serial-core", - "termios", + "log", ] [[package]] -name = "serial-windows" -version = "0.4.0" +name = "solana-svm-measure" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +checksum = "fea64909ba06fa651c95c4db35614430b1a0bc722e51996e97b5b779e3528bad" + +[[package]] +name = "solana-svm-timings" +version = "3.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a05b09e2caac9b4d7c35c5997d754433e15ee5f506509117eb77032e1718ac" dependencies = [ - "libc", - "serial-core", + "eager", + "enum-iterator", + "solana-pubkey 3.0.0", ] [[package]] -name = "sha1" -version = "0.10.6" +name = "solana-svm-transaction" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "be3250a278a769ba59059e13d0f16c2aba0ca1de7595fb0e02556091751560c8" dependencies = [ - "cfg-if", - "cpufeatures", - "digest 0.10.7", + "solana-hash 3.1.0", + "solana-message", + "solana-pubkey 3.0.0", + "solana-sdk-ids", + "solana-signature", + "solana-transaction", ] [[package]] -name = "sha2" -version = "0.10.9" +name = "solana-svm-type-overrides" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +checksum = "3b78cd0bfb102d4197ce8c590f800a119ba0d358369ca57b0f66e94d1317fd0e" dependencies = [ - "cfg-if", - "cpufeatures", - "digest 0.10.7", + "rand 0.8.5", ] [[package]] -name = "sha3" -version = "0.10.8" +name = "solana-system-interface" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +checksum = "4e1790547bfc3061f1ee68ea9d8dc6c973c02a163697b24263a8e9f2e6d4afa2" dependencies = [ - "digest 0.10.7", - "keccak", + "num-traits", + "serde", + "serde_derive", + "solana-instruction", + "solana-msg", + "solana-program-error", + "solana-pubkey 3.0.0", ] [[package]] -name = "sha3-asm" -version = "0.1.6" +name = "solana-system-interface" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59cbb88c189d6352cc8ae96a39d19c7ecad8f7330b29461187f2587fdc2988d5" +checksum = "55b54965bf0b76fa8e2b35376583efddd4d916618cfe595bf48c7d7b55a9e628" dependencies = [ - "cc", - "cfg-if", + "num-traits", + "serde", + "serde_derive", + "solana-address 2.6.0", + "solana-instruction", + "solana-msg", + "solana-program-error", ] [[package]] -name = "shared_library" -version = "0.1.9" +name = "solana-system-program" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +checksum = "8b4b6faeddf5a62c06991a9a077fd1097da6867060f884595a659b3b24dc3a4a" +dependencies = [ + "bincode", + "log", + "serde", + "solana-account", + "solana-bincode", + "solana-fee-calculator", + "solana-instruction", + "solana-nonce", + "solana-nonce-account", + "solana-packet", + "solana-program-runtime", + "solana-pubkey 3.0.0", + "solana-sdk-ids", + "solana-svm-log-collector", + "solana-svm-type-overrides", + "solana-system-interface 2.0.0", + "solana-sysvar", + "solana-transaction-context", +] + +[[package]] +name = "solana-sysvar" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6690d3dd88f15c21edff68eb391ef8800df7a1f5cec84ee3e8d1abf05affdf74" dependencies = [ + "base64 0.22.1", + "bincode", "lazy_static", - "libc", + "serde", + "serde_derive", + "solana-account-info", + "solana-clock", + "solana-define-syscall 4.0.1", + "solana-epoch-rewards", + "solana-epoch-schedule", + "solana-fee-calculator", + "solana-hash 4.3.0", + "solana-instruction", + "solana-last-restart-slot", + "solana-program-entrypoint", + "solana-program-error", + "solana-program-memory", + "solana-pubkey 4.2.0", + "solana-rent 3.1.0", + "solana-sdk-ids", + "solana-sdk-macro", + "solana-slot-hashes", + "solana-slot-history", + "solana-sysvar-id", ] [[package]] -name = "shell-words" -version = "1.1.1" +name = "solana-sysvar-id" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" +checksum = "17358d1e9a13e5b9c2264d301102126cf11a47fd394cdf3dec174fe7bc96e1de" +dependencies = [ + "solana-address 2.6.0", + "solana-sdk-ids", +] [[package]] -name = "shlex" -version = "1.3.0" +name = "solana-transaction" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "96697cff5075a028265324255efed226099f6d761ca67342b230d09f72cc48d2" +dependencies = [ + "bincode", + "serde", + "serde_derive", + "solana-address 2.6.0", + "solana-hash 4.3.0", + "solana-instruction", + "solana-instruction-error", + "solana-message", + "solana-sanitize", + "solana-sdk-ids", + "solana-short-vec", + "solana-signature", + "solana-signer", + "solana-transaction-error", +] [[package]] -name = "signal-hook" -version = "0.3.18" +name = "solana-transaction-context" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +checksum = "b1a3c3a69688293a195b02c60a5384d855b8de19981f404c71ccb9e7f139b98f" dependencies = [ - "libc", - "signal-hook-registry", + "bincode", + "serde", + "solana-account", + "solana-instruction", + "solana-instructions-sysvar", + "solana-pubkey 3.0.0", + "solana-rent 3.1.0", + "solana-sbpf", + "solana-sdk-ids", ] [[package]] -name = "signal-hook-mio" -version = "0.2.5" +name = "solana-transaction-error" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +checksum = "4a2165ad25b694c654d5395fc7a049452a192376e4c96a7fad05580f6ba5ba1c" dependencies = [ - "libc", - "mio", - "signal-hook", + "serde", + "serde_derive", + "solana-instruction-error", + "solana-sanitize", ] [[package]] -name = "signal-hook-registry" -version = "1.4.8" +name = "solana-vote-interface" +version = "4.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +checksum = "db6e123e16bfdd7a81d71b4c4699e0b29580b619f4cd2ef5b6aae1eb85e8979f" dependencies = [ - "errno", - "libc", + "bincode", + "cfg_eval", + "num-derive", + "num-traits", + "serde", + "serde_derive", + "serde_with", + "solana-clock", + "solana-hash 3.1.0", + "solana-instruction", + "solana-instruction-error", + "solana-pubkey 3.0.0", + "solana-rent 3.1.0", + "solana-sdk-ids", + "solana-serde-varint", + "solana-serialize-utils", + "solana-short-vec", + "solana-system-interface 2.0.0", ] [[package]] -name = "signature" -version = "2.2.0" +name = "solana-vote-program" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +checksum = "4164d0eb4760cbdb3dd46457999dba735079774381fe4042a70ec7484930a297" dependencies = [ - "digest 0.10.7", - "rand_core 0.6.4", + "agave-feature-set", + "bincode", + "log", + "num-derive", + "num-traits", + "serde", + "solana-account", + "solana-bincode", + "solana-clock", + "solana-epoch-schedule", + "solana-hash 3.1.0", + "solana-instruction", + "solana-keypair", + "solana-packet", + "solana-program-runtime", + "solana-pubkey 3.0.0", + "solana-rent 3.1.0", + "solana-sdk-ids", + "solana-signer", + "solana-slot-hashes", + "solana-transaction", + "solana-transaction-context", + "solana-vote-interface", + "thiserror 2.0.18", ] [[package]] -name = "slab" -version = "0.4.12" +name = "solana-zk-elgamal-proof-program" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +checksum = "14f30c80edc4aac841745f7e93bbf1afc27d2b496b8ae9fe9777935151cb9352" +dependencies = [ + "agave-feature-set", + "bytemuck", + "num-derive", + "num-traits", + "solana-instruction", + "solana-program-runtime", + "solana-sdk-ids", + "solana-svm-log-collector", + "solana-zk-sdk", +] [[package]] -name = "smallvec" -version = "1.15.1" +name = "solana-zk-sdk" +version = "4.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "9602bcb1f7af15caef92b91132ec2347e1c51a72ecdbefdaefa3eac4b8711475" +dependencies = [ + "aes-gcm-siv", + "base64 0.22.1", + "bincode", + "bytemuck", + "bytemuck_derive", + "curve25519-dalek", + "getrandom 0.2.17", + "itertools 0.12.1", + "js-sys", + "merlin", + "num-derive", + "num-traits", + "rand 0.8.5", + "serde", + "serde_derive", + "serde_json", + "sha3", + "solana-derivation-path", + "solana-instruction", + "solana-pubkey 3.0.0", + "solana-sdk-ids", + "solana-seed-derivable", + "solana-seed-phrase", + "solana-signature", + "solana-signer", + "subtle", + "thiserror 2.0.18", + "wasm-bindgen", + "zeroize", +] [[package]] -name = "socket2" -version = "0.6.3" +name = "solana-zk-token-proof-program" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "962938a9994cc6d54b46b5f0d6a978024f4847272f560f8f11edd1575a0d8e8f" dependencies = [ - "libc", - "windows-sys 0.61.2", + "agave-feature-set", + "bytemuck", + "num-derive", + "num-traits", + "solana-instruction", + "solana-program-runtime", + "solana-sdk-ids", + "solana-svm-log-collector", + "solana-zk-token-sdk", ] [[package]] -name = "solana-address" -version = "2.6.0" +name = "solana-zk-token-sdk" +version = "3.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1384b52c435a750cc9c538760fc7bb472fd78e65a9900a2d07312c5bb335b72" +checksum = "6e5fe47f0389206960e272a6f1af3b06c2b32551be77f9e4254564b6d1177b83" dependencies = [ - "five8", - "five8_const", + "aes-gcm-siv", + "base64 0.22.1", + "bincode", + "bytemuck", + "bytemuck_derive", + "curve25519-dalek", + "itertools 0.12.1", + "merlin", + "num-derive", + "num-traits", + "rand 0.8.5", "serde", - "serde_derive", - "solana-program-error", + "serde_json", + "sha3", + "solana-curve25519", + "solana-derivation-path", + "solana-instruction", + "solana-pubkey 3.0.0", + "solana-sdk-ids", + "solana-seed-derivable", + "solana-seed-phrase", + "solana-signature", + "solana-signer", + "subtle", + "thiserror 2.0.18", + "zeroize", ] -[[package]] -name = "solana-program-error" -version = "3.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f04fa578707b3612b095f0c8e19b66a1233f7c42ca8082fcb3b745afcc0add6" - [[package]] name = "solar-ast" version = "0.1.8" @@ -3385,7 +5718,7 @@ dependencies = [ "alloy-primitives", "bumpalo", "either", - "num-rational", + "num-rational 0.4.2", "semver 1.0.27", "solar-data-structures", "solar-interface", @@ -3484,8 +5817,8 @@ dependencies = [ "bumpalo", "itertools 0.14.0", "memchr", - "num-bigint", - "num-rational", + "num-bigint 0.4.6", + "num-rational 0.4.2", "num-traits", "ruint", "smallvec", @@ -4116,12 +6449,41 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common 0.1.7", + "subtle", +] + +[[package]] +name = "unreachable" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "382810877fe448991dfc7f0dd6e3ae5d58088fd0ea5e35189655f84e6814fa56" +dependencies = [ + "void", +] + [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "uriparse" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0200d0fc04d809396c2ad43f3c95da3582a2556eba8d453c1087f4120ee352ff" +dependencies = [ + "fnv", + "lazy_static", +] + [[package]] name = "url" version = "2.5.8" @@ -4170,6 +6532,12 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "void" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" + [[package]] name = "vte" version = "0.14.1" @@ -4197,6 +6565,12 @@ dependencies = [ "try-lock", ] +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -4342,6 +6716,31 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "wincode" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c754f1fc41250f2f742a27ba0fcc9f73df1dec23f6878490770855d43c322d" +dependencies = [ + "pastey", + "proc-macro2", + "quote", + "thiserror 2.0.18", + "wincode-derive", +] + +[[package]] +name = "wincode-derive" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e070787599c7c067b89598cd3eda440cca1b69eda9e0ff7c725fc8679ce9eb4" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/crates/ilold-solana-core/Cargo.toml b/crates/ilold-solana-core/Cargo.toml index 3416a3e..56f2028 100644 --- a/crates/ilold-solana-core/Cargo.toml +++ b/crates/ilold-solana-core/Cargo.toml @@ -7,6 +7,10 @@ description = "Anchor IDL parser and (later) Solana execution backend for Ilold. [dependencies] anchor-lang-idl = { git = "https://github.com/solana-foundation/anchor", tag = "v1.0.2", features = ["build", "convert"] } solana-address = { version = "2.0", features = ["serde", "decode"] } +solana-clock = "3.0" +solana-keypair = "3.1" +solana-signer = "3.0" +litesvm = "0.11" serde = { workspace = true } serde_json = { workspace = true } thiserror = { workspace = true } diff --git a/crates/ilold-solana-core/src/error.rs b/crates/ilold-solana-core/src/error.rs index aa797a9..07377b0 100644 --- a/crates/ilold-solana-core/src/error.rs +++ b/crates/ilold-solana-core/src/error.rs @@ -42,4 +42,10 @@ pub enum SolanaError { #[error("IDL references unknown type '{0}'")] UnknownType(String), + + #[error("VM boot failed: {0}")] + VmBootFailed(String), + + #[error("VM operation failed: {0}")] + VmOperationFailed(String), } diff --git a/crates/ilold-solana-core/src/execute/mod.rs b/crates/ilold-solana-core/src/execute/mod.rs new file mode 100644 index 0000000..2f6422a --- /dev/null +++ b/crates/ilold-solana-core/src/execute/mod.rs @@ -0,0 +1,3 @@ +pub mod vm; + +pub use vm::VmHost; diff --git a/crates/ilold-solana-core/src/execute/vm.rs b/crates/ilold-solana-core/src/execute/vm.rs new file mode 100644 index 0000000..00ab82a --- /dev/null +++ b/crates/ilold-solana-core/src/execute/vm.rs @@ -0,0 +1,74 @@ +use litesvm::LiteSVM; +use solana_address::Address; +use solana_clock::Clock; +use solana_keypair::Keypair; +use solana_signer::Signer; + +use crate::error::SolanaError; + +pub const DEFAULT_PAYER_LAMPORTS: u64 = 1_000_000_000_000; + +pub struct VmHost { + svm: LiteSVM, + payer: Keypair, +} + +impl VmHost { + pub fn boot(programs: Vec<(Address, Vec)>) -> Result { + let mut svm = LiteSVM::new(); + svm.set_sysvar(&Clock::default()); + + let payer = Keypair::new(); + svm.airdrop(&payer.pubkey(), DEFAULT_PAYER_LAMPORTS) + .map_err(|meta| { + SolanaError::VmBootFailed(format!("airdrop payer: {:?}", meta.err)) + })?; + + for (program_id, bytes) in programs { + svm.add_program(program_id, &bytes).map_err(|e| { + SolanaError::VmBootFailed(format!( + "add_program {program_id}: {e:?}" + )) + })?; + } + + Ok(Self { svm, payer }) + } + + pub fn payer(&self) -> &Keypair { + &self.payer + } + + pub fn payer_pubkey(&self) -> Address { + self.payer.pubkey() + } + + pub fn svm(&self) -> &LiteSVM { + &self.svm + } + + pub fn svm_mut(&mut self) -> &mut LiteSVM { + &mut self.svm + } + + pub fn airdrop(&mut self, address: Address, lamports: u64) -> Result<(), SolanaError> { + self.svm.airdrop(&address, lamports).map(|_| ()).map_err(|meta| { + SolanaError::VmOperationFailed(format!("airdrop: {:?}", meta.err)) + }) + } + + pub fn balance(&self, address: &Address) -> u64 { + self.svm.get_balance(address).unwrap_or(0) + } + + pub fn warp_clock(&mut self, slot: u64, unix_timestamp: i64) { + let mut clock = self.svm.get_sysvar::(); + clock.slot = slot; + clock.unix_timestamp = unix_timestamp; + self.svm.set_sysvar(&clock); + } + + pub fn clock(&self) -> Clock { + self.svm.get_sysvar::() + } +} diff --git a/crates/ilold-solana-core/src/lib.rs b/crates/ilold-solana-core/src/lib.rs index 8f1162f..eff9aab 100644 --- a/crates/ilold-solana-core/src/lib.rs +++ b/crates/ilold-solana-core/src/lib.rs @@ -1,5 +1,6 @@ pub mod decode; pub mod error; +pub mod execute; pub mod idl; pub mod ingest; pub mod model; diff --git a/crates/ilold-solana-core/tests/vm.rs b/crates/ilold-solana-core/tests/vm.rs new file mode 100644 index 0000000..6d7ee65 --- /dev/null +++ b/crates/ilold-solana-core/tests/vm.rs @@ -0,0 +1,43 @@ +use ilold_solana_core::error::SolanaError; +use ilold_solana_core::execute::{vm::DEFAULT_PAYER_LAMPORTS, VmHost}; +use solana_keypair::Keypair; +use solana_signer::Signer; + +#[test] +fn boot_empty_vm_funds_payer() { + let host = VmHost::boot(Vec::new()).expect("empty boot should succeed"); + assert_eq!(host.balance(&host.payer_pubkey()), DEFAULT_PAYER_LAMPORTS); +} + +#[test] +fn airdrop_credits_target_address() { + let mut host = VmHost::boot(Vec::new()).unwrap(); + let target = Keypair::new().pubkey(); + + let amount = 5_000_000_000; + host.airdrop(target, amount).unwrap(); + + assert_eq!(host.balance(&target), amount); +} + +#[test] +fn warp_clock_updates_slot_and_timestamp() { + let mut host = VmHost::boot(Vec::new()).unwrap(); + + host.warp_clock(1_000, 1_700_000_000); + let clock = host.clock(); + + assert_eq!(clock.slot, 1_000); + assert_eq!(clock.unix_timestamp, 1_700_000_000); +} + +#[test] +fn boot_with_invalid_program_bytes_returns_boot_failed() { + let fake_program_id = solana_keypair::Keypair::new().pubkey(); + let invalid_elf = vec![0u8; 16]; + + match VmHost::boot(vec![(fake_program_id, invalid_elf)]) { + Ok(_) => panic!("expected VmBootFailed for invalid ELF bytes"), + Err(e) => assert!(matches!(e, SolanaError::VmBootFailed(_))), + } +} From d797287657109cecb86a541f5861b68a1696ab5d Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 09:46:56 +0200 Subject: [PATCH 015/115] chore(solana-core): enable curve25519 feature on solana-address - find_program_address requires curve25519 on host (non-BPF) targets --- crates/ilold-solana-core/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ilold-solana-core/Cargo.toml b/crates/ilold-solana-core/Cargo.toml index 56f2028..99a7b85 100644 --- a/crates/ilold-solana-core/Cargo.toml +++ b/crates/ilold-solana-core/Cargo.toml @@ -6,7 +6,7 @@ description = "Anchor IDL parser and (later) Solana execution backend for Ilold. [dependencies] anchor-lang-idl = { git = "https://github.com/solana-foundation/anchor", tag = "v1.0.2", features = ["build", "convert"] } -solana-address = { version = "2.0", features = ["serde", "decode"] } +solana-address = { version = "2.0", features = ["serde", "decode", "curve25519"] } solana-clock = "3.0" solana-keypair = "3.1" solana-signer = "3.0" From 88943dbc8092d33620ce3ee98bfdfbafcaa8a738 Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 09:48:17 +0200 Subject: [PATCH 016/115] feat(solana-core): PDA derivation from IDL seed specs - derive_pda walks SeedSpec into byte slices and calls Address::find_program_address - Const, Account and typed Arg seeds covered (string utf-8, integers LE, pubkey base58, bytes hex) - typed errors for missing args, type mismatches and the rejected arg-in-program-slot case --- crates/ilold-solana-core/src/error.rs | 10 ++ crates/ilold-solana-core/src/execute/mod.rs | 2 + crates/ilold-solana-core/src/execute/pda.rs | 128 ++++++++++++++++++++ 3 files changed, 140 insertions(+) create mode 100644 crates/ilold-solana-core/src/execute/pda.rs diff --git a/crates/ilold-solana-core/src/error.rs b/crates/ilold-solana-core/src/error.rs index 07377b0..7837c8e 100644 --- a/crates/ilold-solana-core/src/error.rs +++ b/crates/ilold-solana-core/src/error.rs @@ -48,4 +48,14 @@ pub enum SolanaError { #[error("VM operation failed: {0}")] VmOperationFailed(String), + + #[error("PDA seed type mismatch at '{path}': expected {expected}, got {got}")] + SeedTypeMismatch { + path: String, + expected: String, + got: String, + }, + + #[error("PDA program override is bound to an arg seed '{path}', which is not supported")] + PdaProgramArgUnsupported { path: String }, } diff --git a/crates/ilold-solana-core/src/execute/mod.rs b/crates/ilold-solana-core/src/execute/mod.rs index 2f6422a..9149948 100644 --- a/crates/ilold-solana-core/src/execute/mod.rs +++ b/crates/ilold-solana-core/src/execute/mod.rs @@ -1,3 +1,5 @@ +pub mod pda; pub mod vm; +pub use pda::derive_pda; pub use vm::VmHost; diff --git a/crates/ilold-solana-core/src/execute/pda.rs b/crates/ilold-solana-core/src/execute/pda.rs new file mode 100644 index 0000000..695edd6 --- /dev/null +++ b/crates/ilold-solana-core/src/execute/pda.rs @@ -0,0 +1,128 @@ +use std::collections::HashMap; + +use anchor_lang_idl::types::IdlType; +use serde_json::Value; +use solana_address::Address; + +use crate::error::SolanaError; +use crate::model::{PdaSpec, SeedSpec}; + +pub fn derive_pda( + spec: &PdaSpec, + program: Address, + args: &Value, + accounts: &HashMap, +) -> Result<(Address, u8), SolanaError> { + let mut seed_bytes: Vec> = Vec::with_capacity(spec.seeds.len()); + for seed in &spec.seeds { + seed_bytes.push(resolve_seed(seed, args, accounts)?); + } + + let program_id = match &spec.program { + None => program, + Some(SeedSpec::Const { value }) => Address::try_from(value.as_slice()) + .map_err(|_| SolanaError::InvalidProgramId(format!("{:02x?}", value)))?, + Some(SeedSpec::Account { path }) => *accounts + .get(path) + .ok_or_else(|| SolanaError::SeedArgUnresolved { path: path.clone() })?, + Some(SeedSpec::Arg { path, .. }) => { + return Err(SolanaError::PdaProgramArgUnsupported { path: path.clone() }); + } + }; + + let refs: Vec<&[u8]> = seed_bytes.iter().map(Vec::as_slice).collect(); + let (pda, bump) = Address::find_program_address(&refs, &program_id); + Ok((pda, bump)) +} + +fn resolve_seed( + seed: &SeedSpec, + args: &Value, + accounts: &HashMap, +) -> Result, SolanaError> { + match seed { + SeedSpec::Const { value } => Ok(value.clone()), + SeedSpec::Account { path } => accounts + .get(path) + .map(|pk| pk.to_bytes().to_vec()) + .ok_or_else(|| SolanaError::SeedArgUnresolved { path: path.clone() }), + SeedSpec::Arg { path, ty } => { + let value = args + .pointer(&format!("/{}", path.replace('.', "/"))) + .ok_or_else(|| SolanaError::SeedArgUnresolved { path: path.clone() })?; + encode_arg_seed(value, ty, path) + } + } +} + +fn encode_arg_seed(value: &Value, ty: &IdlType, path: &str) -> Result, SolanaError> { + let mismatch = |expected: &str| SolanaError::SeedTypeMismatch { + path: path.to_string(), + expected: expected.to_string(), + got: format!("{value}"), + }; + match (ty, value) { + (IdlType::String, Value::String(s)) => Ok(s.as_bytes().to_vec()), + (IdlType::Bytes, Value::String(hex)) => decode_hex(hex).ok_or_else(|| mismatch("bytes hex")), + (IdlType::Pubkey, Value::String(b58)) => bs58::decode(b58) + .into_vec() + .map_err(|_| mismatch("pubkey base58")), + (IdlType::U8, Value::Number(n)) => { + let v = u8::try_from(n.as_u64().ok_or_else(|| mismatch("u8"))?) + .map_err(|_| mismatch("u8"))?; + Ok(v.to_le_bytes().to_vec()) + } + (IdlType::U16, Value::Number(n)) => { + let v = u16::try_from(n.as_u64().ok_or_else(|| mismatch("u16"))?) + .map_err(|_| mismatch("u16"))?; + Ok(v.to_le_bytes().to_vec()) + } + (IdlType::U32, Value::Number(n)) => { + let v = u32::try_from(n.as_u64().ok_or_else(|| mismatch("u32"))?) + .map_err(|_| mismatch("u32"))?; + Ok(v.to_le_bytes().to_vec()) + } + (IdlType::U64, Value::Number(n)) => Ok(n + .as_u64() + .ok_or_else(|| mismatch("u64"))? + .to_le_bytes() + .to_vec()), + (IdlType::I8, Value::Number(n)) => { + let v = i8::try_from(n.as_i64().ok_or_else(|| mismatch("i8"))?) + .map_err(|_| mismatch("i8"))?; + Ok(v.to_le_bytes().to_vec()) + } + (IdlType::I16, Value::Number(n)) => { + let v = i16::try_from(n.as_i64().ok_or_else(|| mismatch("i16"))?) + .map_err(|_| mismatch("i16"))?; + Ok(v.to_le_bytes().to_vec()) + } + (IdlType::I32, Value::Number(n)) => { + let v = i32::try_from(n.as_i64().ok_or_else(|| mismatch("i32"))?) + .map_err(|_| mismatch("i32"))?; + Ok(v.to_le_bytes().to_vec()) + } + (IdlType::I64, Value::Number(n)) => Ok(n + .as_i64() + .ok_or_else(|| mismatch("i64"))? + .to_le_bytes() + .to_vec()), + _ => Err(SolanaError::SeedTypeMismatch { + path: path.to_string(), + expected: format!("{ty:?}"), + got: format!("{value}"), + }), + } +} + +fn decode_hex(s: &str) -> Option> { + let s = s.strip_prefix("0x").unwrap_or(s); + if s.len() % 2 != 0 { + return None; + } + let mut out = Vec::with_capacity(s.len() / 2); + for i in (0..s.len()).step_by(2) { + out.push(u8::from_str_radix(&s[i..i + 2], 16).ok()?); + } + Some(out) +} From 2606e157b695235acaa6b55e67f1223a58bec2e5 Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 09:49:40 +0200 Subject: [PATCH 017/115] test(solana-core): PDA derivation against native helper - 7 tests assert outputs match Address::find_program_address directly - covers Const, Arg(string), Arg(u64), Account variants and three error paths --- crates/ilold-solana-core/tests/pda.rs | 146 ++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 crates/ilold-solana-core/tests/pda.rs diff --git a/crates/ilold-solana-core/tests/pda.rs b/crates/ilold-solana-core/tests/pda.rs new file mode 100644 index 0000000..099cc67 --- /dev/null +++ b/crates/ilold-solana-core/tests/pda.rs @@ -0,0 +1,146 @@ +use std::collections::HashMap; + +use anchor_lang_idl::types::IdlType; +use ilold_solana_core::error::SolanaError; +use ilold_solana_core::execute::derive_pda; +use ilold_solana_core::model::{PdaSpec, SeedSpec}; +use serde_json::json; +use solana_address::Address; + +fn fixed_program_id() -> Address { + "E64FVeubGC4NPNF2UBJYX4AkrVowf74fRJD9q6YhwstN" + .parse() + .unwrap() +} + +#[test] +fn const_seed_matches_native_find_program_address() { + let program = fixed_program_id(); + let spec = PdaSpec { + seeds: vec![SeedSpec::Const { + value: b"vault".to_vec(), + }], + program: None, + bump_arg: None, + }; + + let (got_pk, got_bump) = derive_pda(&spec, program, &json!({}), &HashMap::new()).unwrap(); + let (expected_pk, expected_bump) = Address::find_program_address(&[b"vault"], &program); + + assert_eq!(got_pk, expected_pk); + assert_eq!(got_bump, expected_bump); +} + +#[test] +fn string_arg_seed_uses_utf8_bytes() { + let program = fixed_program_id(); + let spec = PdaSpec { + seeds: vec![ + SeedSpec::Const { + value: b"user".to_vec(), + }, + SeedSpec::Arg { + path: "name".into(), + ty: IdlType::String, + }, + ], + program: None, + bump_arg: None, + }; + let args = json!({"name": "alice"}); + + let (got, _bump) = derive_pda(&spec, program, &args, &HashMap::new()).unwrap(); + let (expected, _) = Address::find_program_address(&[b"user", b"alice"], &program); + assert_eq!(got, expected); +} + +#[test] +fn u64_arg_seed_uses_le_bytes() { + let program = fixed_program_id(); + let spec = PdaSpec { + seeds: vec![SeedSpec::Arg { + path: "id".into(), + ty: IdlType::U64, + }], + program: None, + bump_arg: None, + }; + let args = json!({"id": 42_u64}); + + let (got, _) = derive_pda(&spec, program, &args, &HashMap::new()).unwrap(); + let (expected, _) = Address::find_program_address(&[&42_u64.to_le_bytes()], &program); + assert_eq!(got, expected); +} + +#[test] +fn account_seed_uses_pubkey_bytes() { + let program = fixed_program_id(); + let user_pk: Address = "11111111111111111111111111111111".parse().unwrap(); + let spec = PdaSpec { + seeds: vec![SeedSpec::Account { + path: "user".into(), + }], + program: None, + bump_arg: None, + }; + let mut accounts = HashMap::new(); + accounts.insert("user".to_string(), user_pk); + + let (got, _) = derive_pda(&spec, program, &json!({}), &accounts).unwrap(); + let (expected, _) = Address::find_program_address(&[&user_pk.to_bytes()], &program); + assert_eq!(got, expected); +} + +#[test] +fn missing_arg_returns_seed_arg_unresolved() { + let program = fixed_program_id(); + let spec = PdaSpec { + seeds: vec![SeedSpec::Arg { + path: "missing".into(), + ty: IdlType::String, + }], + program: None, + bump_arg: None, + }; + + let err = derive_pda(&spec, program, &json!({}), &HashMap::new()).unwrap_err(); + assert!(matches!( + err, + SolanaError::SeedArgUnresolved { ref path } if path == "missing" + )); +} + +#[test] +fn arg_type_mismatch_returns_typed_error() { + let program = fixed_program_id(); + let spec = PdaSpec { + seeds: vec![SeedSpec::Arg { + path: "name".into(), + ty: IdlType::String, + }], + program: None, + bump_arg: None, + }; + let args = json!({"name": 123}); + + let err = derive_pda(&spec, program, &args, &HashMap::new()).unwrap_err(); + assert!(matches!(err, SolanaError::SeedTypeMismatch { .. })); +} + +#[test] +fn arg_in_program_slot_is_rejected() { + let program = fixed_program_id(); + let spec = PdaSpec { + seeds: vec![SeedSpec::Const { + value: b"x".to_vec(), + }], + program: Some(SeedSpec::Arg { + path: "p".into(), + ty: IdlType::Pubkey, + }), + bump_arg: None, + }; + + let err = derive_pda(&spec, program, &json!({}), &HashMap::new()).unwrap_err(); + assert!(matches!(err, SolanaError::PdaProgramArgUnsupported { .. })); +} From 133d22d35d8c6e8d1a3099fc43195de912aa5b08 Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 10:12:27 +0200 Subject: [PATCH 018/115] chore(solana-core): add solana-account and solana-sdk-ids deps - needed for VmSnapshot to read AccountSharedData and discriminate bpf_loader_upgradeable owners during restore --- Cargo.lock | 2 ++ crates/ilold-solana-core/Cargo.toml | 2 ++ 2 files changed, 4 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 3cf8c91..0d9e8b6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2420,9 +2420,11 @@ dependencies = [ "litesvm", "serde", "serde_json", + "solana-account", "solana-address 2.6.0", "solana-clock", "solana-keypair", + "solana-sdk-ids", "solana-signer", "tempfile", "thiserror 2.0.18", diff --git a/crates/ilold-solana-core/Cargo.toml b/crates/ilold-solana-core/Cargo.toml index 99a7b85..51b7c2e 100644 --- a/crates/ilold-solana-core/Cargo.toml +++ b/crates/ilold-solana-core/Cargo.toml @@ -6,9 +6,11 @@ description = "Anchor IDL parser and (later) Solana execution backend for Ilold. [dependencies] anchor-lang-idl = { git = "https://github.com/solana-foundation/anchor", tag = "v1.0.2", features = ["build", "convert"] } +solana-account = "3" solana-address = { version = "2.0", features = ["serde", "decode", "curve25519"] } solana-clock = "3.0" solana-keypair = "3.1" +solana-sdk-ids = "3" solana-signer = "3.0" litesvm = "0.11" serde = { workspace = true } From 7b8b3dee5a5e88e702436151bf14749e0c78efdf Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 10:18:11 +0200 Subject: [PATCH 019/115] feat(solana-core): VmSnapshot with Path 4 fork - VmHost retains program binaries so a forked VM reloads them without external state - snapshot captures accounts_db.inner, Clock and payer keypair - restore reboots LiteSVM honoring the verified order: Clock first, programs reloaded, programdata accounts before regular ones - typed errors for invalid payer bytes or set_account failures during restore --- crates/ilold-solana-core/src/execute/fork.rs | 79 ++++++++++++++++++++ crates/ilold-solana-core/src/execute/mod.rs | 2 + crates/ilold-solana-core/src/execute/vm.rs | 19 ++++- 3 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 crates/ilold-solana-core/src/execute/fork.rs diff --git a/crates/ilold-solana-core/src/execute/fork.rs b/crates/ilold-solana-core/src/execute/fork.rs new file mode 100644 index 0000000..bd9a1e5 --- /dev/null +++ b/crates/ilold-solana-core/src/execute/fork.rs @@ -0,0 +1,79 @@ +use litesvm::LiteSVM; +use solana_account::{AccountSharedData, ReadableAccount}; +use solana_address::Address; +use solana_clock::Clock; +use solana_keypair::Keypair; +use solana_sdk_ids::bpf_loader_upgradeable; +use solana_signer::Signer; + +use crate::error::SolanaError; +use crate::execute::vm::VmHost; + +const PROGRAMDATA_DISCRIMINATOR_BYTE: u8 = 3; + +#[derive(Clone)] +pub struct VmSnapshot { + pub accounts: Vec<(Address, AccountSharedData)>, + pub clock: Clock, + pub payer_bytes: [u8; 64], + pub programs: Vec<(Address, Vec)>, +} + +impl VmHost { + pub fn snapshot(&self) -> VmSnapshot { + let accounts: Vec<(Address, AccountSharedData)> = self + .svm() + .accounts_db() + .inner + .iter() + .map(|(k, v)| (*k, v.clone())) + .collect(); + VmSnapshot { + accounts, + clock: self.svm().get_sysvar::(), + payer_bytes: self.payer().to_bytes(), + programs: self.programs().to_vec(), + } + } + + pub fn restore(snap: VmSnapshot) -> Result { + let mut svm = LiteSVM::new(); + svm.set_sysvar(&snap.clock); + + for (program_id, bytes) in &snap.programs { + svm.add_program(*program_id, bytes).map_err(|e| { + SolanaError::VmBootFailed(format!("restore add_program {program_id}: {e:?}")) + })?; + } + + let upgradeable = bpf_loader_upgradeable::id(); + let (programdata, other): (Vec<_>, Vec<_>) = + snap.accounts.iter().cloned().partition(|(_, acc)| { + acc.owner() == &upgradeable + && acc.data().first() == Some(&PROGRAMDATA_DISCRIMINATOR_BYTE) + }); + + for (pk, acc) in programdata { + svm.set_account(pk, acc.into()).map_err(|e| { + SolanaError::VmOperationFailed(format!("restore programdata {pk}: {e:?}")) + })?; + } + for (pk, acc) in other { + svm.set_account(pk, acc.into()) + .map_err(|e| SolanaError::VmOperationFailed(format!("restore account {pk}: {e:?}")))?; + } + + let payer = Keypair::try_from(snap.payer_bytes.as_slice()) + .map_err(|_| SolanaError::VmBootFailed("invalid payer bytes in snapshot".into()))?; + + Ok(VmHost::from_parts(svm, payer, snap.programs)) + } +} + +impl VmSnapshot { + pub fn payer_pubkey(&self) -> Result { + let kp = Keypair::try_from(self.payer_bytes.as_slice()) + .map_err(|_| SolanaError::VmBootFailed("invalid payer bytes in snapshot".into()))?; + Ok(kp.pubkey()) + } +} diff --git a/crates/ilold-solana-core/src/execute/mod.rs b/crates/ilold-solana-core/src/execute/mod.rs index 9149948..9048653 100644 --- a/crates/ilold-solana-core/src/execute/mod.rs +++ b/crates/ilold-solana-core/src/execute/mod.rs @@ -1,5 +1,7 @@ +pub mod fork; pub mod pda; pub mod vm; +pub use fork::VmSnapshot; pub use pda::derive_pda; pub use vm::VmHost; diff --git a/crates/ilold-solana-core/src/execute/vm.rs b/crates/ilold-solana-core/src/execute/vm.rs index 00ab82a..fc1453f 100644 --- a/crates/ilold-solana-core/src/execute/vm.rs +++ b/crates/ilold-solana-core/src/execute/vm.rs @@ -11,6 +11,7 @@ pub const DEFAULT_PAYER_LAMPORTS: u64 = 1_000_000_000_000; pub struct VmHost { svm: LiteSVM, payer: Keypair, + programs: Vec<(Address, Vec)>, } impl VmHost { @@ -24,15 +25,27 @@ impl VmHost { SolanaError::VmBootFailed(format!("airdrop payer: {:?}", meta.err)) })?; - for (program_id, bytes) in programs { - svm.add_program(program_id, &bytes).map_err(|e| { + for (program_id, bytes) in &programs { + svm.add_program(*program_id, bytes).map_err(|e| { SolanaError::VmBootFailed(format!( "add_program {program_id}: {e:?}" )) })?; } - Ok(Self { svm, payer }) + Ok(Self { svm, payer, programs }) + } + + pub(crate) fn programs(&self) -> &[(Address, Vec)] { + &self.programs + } + + pub(crate) fn from_parts( + svm: LiteSVM, + payer: Keypair, + programs: Vec<(Address, Vec)>, + ) -> Self { + Self { svm, payer, programs } } pub fn payer(&self) -> &Keypair { From 6c8ef8e36e3dfe606c579b323e68f17d70c20029 Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 10:18:25 +0200 Subject: [PATCH 020/115] test(solana-core): fork showcase with divergent branches - snapshot/restore preserves payer and warped Clock - main and forked branches diverge after independent airdrops, common state from the snapshot persists in both - run with cargo test --test fork -- --nocapture to see the per-branch balance dump - restore rejects zeroed payer bytes --- crates/ilold-solana-core/tests/fork.rs | 66 ++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 crates/ilold-solana-core/tests/fork.rs diff --git a/crates/ilold-solana-core/tests/fork.rs b/crates/ilold-solana-core/tests/fork.rs new file mode 100644 index 0000000..db4306b --- /dev/null +++ b/crates/ilold-solana-core/tests/fork.rs @@ -0,0 +1,66 @@ +use ilold_solana_core::execute::VmHost; +use solana_keypair::Keypair; +use solana_signer::Signer; + +#[test] +fn snapshot_then_restore_preserves_payer_and_clock() { + let mut host = VmHost::boot(Vec::new()).unwrap(); + host.warp_clock(123, 1_700_000_000); + + let snap = host.snapshot(); + let restored = VmHost::restore(snap).unwrap(); + + assert_eq!(restored.payer_pubkey(), host.payer_pubkey()); + let clock = restored.clock(); + assert_eq!(clock.slot, 123); + assert_eq!(clock.unix_timestamp, 1_700_000_000); +} + +#[test] +fn fork_branches_diverge_independently() { + let mut main = VmHost::boot(Vec::new()).unwrap(); + + let alice = Keypair::new().pubkey(); + main.airdrop(alice, 1_000_000_000).unwrap(); + + let snap = main.snapshot(); + + let bob = Keypair::new().pubkey(); + main.airdrop(bob, 2_000_000_000).unwrap(); + + let mut branch = VmHost::restore(snap).unwrap(); + let charlie = Keypair::new().pubkey(); + branch.airdrop(charlie, 3_000_000_000).unwrap(); + + println!("\n=== fork showcase ==="); + println!("main branch:"); + println!(" alice = {} lamports", main.balance(&alice)); + println!(" bob = {} lamports", main.balance(&bob)); + println!(" charlie = {} lamports", main.balance(&charlie)); + println!("forked branch:"); + println!(" alice = {} lamports", branch.balance(&alice)); + println!(" bob = {} lamports", branch.balance(&bob)); + println!(" charlie = {} lamports", branch.balance(&charlie)); + + assert_eq!(main.balance(&alice), 1_000_000_000); + assert_eq!(main.balance(&bob), 2_000_000_000); + assert_eq!(main.balance(&charlie), 0); + + assert_eq!(branch.balance(&alice), 1_000_000_000); + assert_eq!(branch.balance(&bob), 0); + assert_eq!(branch.balance(&charlie), 3_000_000_000); +} + +#[test] +fn restore_with_invalid_payer_bytes_fails() { + let mut snap = VmHost::boot(Vec::new()).unwrap().snapshot(); + snap.payer_bytes = [0u8; 64]; + + match VmHost::restore(snap) { + Ok(_) => panic!("expected restore to fail with zeroed payer bytes"), + Err(e) => assert!( + matches!(e, ilold_solana_core::error::SolanaError::VmBootFailed(_)), + "got {e:?}" + ), + } +} From 332ab74a55d0f0436b2be0f4a1e946f5314e3d9f Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 11:12:57 +0200 Subject: [PATCH 021/115] feat(solana-core): Borsh encoder symmetric to decoder - encode_value walks IdlType against a serde_json::Value and emits Borsh bytes - primitives serialized via borsh::BorshSerialize, u/i256 from 0x-prefixed hex, u/i128 from decimal strings, pubkey from base58 - Defined struct/enum types resolve through the IDL types list, enums expect {kind, value?} input mirroring the decoder - encode_ix_data prepends the 8-byte discriminator and serializes args in IDL order - typed errors EncodeFailed and EncodeTypeMismatch for clear diagnostics --- crates/ilold-solana-core/src/encode/borsh.rs | 280 +++++++++++++++++++ crates/ilold-solana-core/src/encode/mod.rs | 3 + crates/ilold-solana-core/src/error.rs | 6 + crates/ilold-solana-core/src/lib.rs | 1 + 4 files changed, 290 insertions(+) create mode 100644 crates/ilold-solana-core/src/encode/borsh.rs create mode 100644 crates/ilold-solana-core/src/encode/mod.rs diff --git a/crates/ilold-solana-core/src/encode/borsh.rs b/crates/ilold-solana-core/src/encode/borsh.rs new file mode 100644 index 0000000..47a6406 --- /dev/null +++ b/crates/ilold-solana-core/src/encode/borsh.rs @@ -0,0 +1,280 @@ +use anchor_lang_idl::types::{ + IdlArrayLen, IdlDefinedFields, IdlEnumVariant, IdlType, IdlTypeDef, IdlTypeDefTy, +}; +use borsh::BorshSerialize; +use serde_json::Value; + +use crate::error::SolanaError; +use crate::model::InstructionDef; + +pub fn encode_value( + value: &Value, + ty: &IdlType, + types: &[IdlTypeDef], +) -> Result, SolanaError> { + let mut buf = Vec::new(); + write_value(&mut buf, value, ty, types)?; + Ok(buf) +} + +pub fn encode_ix_data( + ix: &InstructionDef, + args: &Value, + types: &[IdlTypeDef], +) -> Result, SolanaError> { + let mut buf = Vec::with_capacity(8); + buf.extend_from_slice(&ix.discriminator); + let obj = args + .as_object() + .ok_or_else(|| SolanaError::EncodeTypeMismatch { + expected: "object with named args".into(), + got: format!("{args}"), + })?; + for arg in &ix.args { + let v = obj.get(&arg.name).ok_or_else(|| SolanaError::EncodeFailed(format!( + "missing instruction arg '{}'", + arg.name + )))?; + write_value(&mut buf, v, &arg.ty, types)?; + } + Ok(buf) +} + +fn write_value( + buf: &mut Vec, + value: &Value, + ty: &IdlType, + types: &[IdlTypeDef], +) -> Result<(), SolanaError> { + match ty { + IdlType::Bool => write_primitive::(buf, value.as_bool().ok_or_else(|| mismatch("bool", value))?), + IdlType::U8 => write_primitive::( + buf, + u8::try_from(value.as_u64().ok_or_else(|| mismatch("u8", value))?) + .map_err(|_| mismatch("u8", value))?, + ), + IdlType::I8 => write_primitive::( + buf, + i8::try_from(value.as_i64().ok_or_else(|| mismatch("i8", value))?) + .map_err(|_| mismatch("i8", value))?, + ), + IdlType::U16 => write_primitive::( + buf, + u16::try_from(value.as_u64().ok_or_else(|| mismatch("u16", value))?) + .map_err(|_| mismatch("u16", value))?, + ), + IdlType::I16 => write_primitive::( + buf, + i16::try_from(value.as_i64().ok_or_else(|| mismatch("i16", value))?) + .map_err(|_| mismatch("i16", value))?, + ), + IdlType::U32 => write_primitive::( + buf, + u32::try_from(value.as_u64().ok_or_else(|| mismatch("u32", value))?) + .map_err(|_| mismatch("u32", value))?, + ), + IdlType::I32 => write_primitive::( + buf, + i32::try_from(value.as_i64().ok_or_else(|| mismatch("i32", value))?) + .map_err(|_| mismatch("i32", value))?, + ), + IdlType::F32 => write_primitive::(buf, value.as_f64().ok_or_else(|| mismatch("f32", value))? as f32), + IdlType::U64 => write_primitive::(buf, value.as_u64().ok_or_else(|| mismatch("u64", value))?), + IdlType::I64 => write_primitive::(buf, value.as_i64().ok_or_else(|| mismatch("i64", value))?), + IdlType::F64 => write_primitive::(buf, value.as_f64().ok_or_else(|| mismatch("f64", value))?), + IdlType::U128 => { + let s = value.as_str().ok_or_else(|| mismatch("u128 decimal string", value))?; + let v: u128 = s.parse().map_err(|_| mismatch("u128 decimal string", value))?; + write_primitive::(buf, v) + } + IdlType::I128 => { + let s = value.as_str().ok_or_else(|| mismatch("i128 decimal string", value))?; + let v: i128 = s.parse().map_err(|_| mismatch("i128 decimal string", value))?; + write_primitive::(buf, v) + } + IdlType::U256 | IdlType::I256 => { + let s = value.as_str().ok_or_else(|| mismatch("u/i256 hex string", value))?; + let stripped = s.strip_prefix("0x").unwrap_or(s); + let bytes = decode_hex(stripped).ok_or_else(|| mismatch("u/i256 hex string", value))?; + if bytes.len() != 32 { + return Err(mismatch("u/i256 32-byte hex", value)); + } + buf.extend_from_slice(&bytes); + Ok(()) + } + IdlType::String => { + let s = value.as_str().ok_or_else(|| mismatch("string", value))?; + write_primitive::(buf, s.to_string()) + } + IdlType::Bytes => { + let s = value.as_str().ok_or_else(|| mismatch("bytes hex string", value))?; + let bytes = decode_hex(s).ok_or_else(|| mismatch("bytes hex string", value))?; + write_primitive::>(buf, bytes) + } + IdlType::Pubkey => { + let s = value.as_str().ok_or_else(|| mismatch("pubkey base58", value))?; + let bytes = bs58::decode(s) + .into_vec() + .map_err(|_| mismatch("pubkey base58", value))?; + if bytes.len() != 32 { + return Err(mismatch("pubkey 32 bytes", value)); + } + buf.extend_from_slice(&bytes); + Ok(()) + } + IdlType::Option(inner) => { + if value.is_null() { + buf.push(0); + Ok(()) + } else { + buf.push(1); + write_value(buf, value, inner, types) + } + } + IdlType::Vec(inner) => { + let arr = value.as_array().ok_or_else(|| mismatch("array", value))?; + let len = u32::try_from(arr.len()) + .map_err(|_| SolanaError::EncodeFailed("vec length exceeds u32".into()))?; + write_primitive::(buf, len)?; + for item in arr { + write_value(buf, item, inner, types)?; + } + Ok(()) + } + IdlType::Array(inner, IdlArrayLen::Value(n)) => { + let arr = value.as_array().ok_or_else(|| mismatch("array", value))?; + if arr.len() != *n { + return Err(SolanaError::EncodeFailed(format!( + "expected array of {} elements, got {}", + n, + arr.len() + ))); + } + for item in arr { + write_value(buf, item, inner, types)?; + } + Ok(()) + } + IdlType::Array(_, IdlArrayLen::Generic(name)) => { + Err(SolanaError::UnsupportedGeneric(format!("array length generic '{name}'"))) + } + IdlType::Defined { name, .. } => { + let def = types + .iter() + .find(|t| &t.name == name) + .ok_or_else(|| SolanaError::UnknownType(name.clone()))?; + write_typedef(buf, value, def, types) + } + IdlType::Generic(name) => Err(SolanaError::UnsupportedGeneric(name.clone())), + _ => Err(SolanaError::EncodeFailed("unsupported IdlType variant".into())), + } +} + +fn write_typedef( + buf: &mut Vec, + value: &Value, + def: &IdlTypeDef, + types: &[IdlTypeDef], +) -> Result<(), SolanaError> { + match &def.ty { + IdlTypeDefTy::Struct { fields } => write_defined_fields(buf, value, fields.as_ref(), types), + IdlTypeDefTy::Enum { variants } => write_enum(buf, value, variants, types), + IdlTypeDefTy::Type { alias } => write_value(buf, value, alias, types), + } +} + +fn write_defined_fields( + buf: &mut Vec, + value: &Value, + fields: Option<&IdlDefinedFields>, + types: &[IdlTypeDef], +) -> Result<(), SolanaError> { + match fields { + None => Ok(()), + Some(IdlDefinedFields::Named(items)) => { + let obj = value + .as_object() + .ok_or_else(|| mismatch("object with named fields", value))?; + for f in items { + let v = obj.get(&f.name).ok_or_else(|| { + SolanaError::EncodeFailed(format!("missing struct field '{}'", f.name)) + })?; + write_value(buf, v, &f.ty, types)?; + } + Ok(()) + } + Some(IdlDefinedFields::Tuple(items)) => { + let arr = value + .as_array() + .ok_or_else(|| mismatch("array for tuple struct", value))?; + if arr.len() != items.len() { + return Err(SolanaError::EncodeFailed(format!( + "tuple expected {} elements, got {}", + items.len(), + arr.len() + ))); + } + for (v, ty) in arr.iter().zip(items.iter()) { + write_value(buf, v, ty, types)?; + } + Ok(()) + } + } +} + +fn write_enum( + buf: &mut Vec, + value: &Value, + variants: &[IdlEnumVariant], + types: &[IdlTypeDef], +) -> Result<(), SolanaError> { + let obj = value + .as_object() + .ok_or_else(|| mismatch("enum object {kind, value?}", value))?; + let kind = obj + .get("kind") + .and_then(|v| v.as_str()) + .ok_or_else(|| mismatch("enum kind string", value))?; + let (idx, variant) = variants + .iter() + .enumerate() + .find(|(_, v)| v.name == kind) + .ok_or_else(|| SolanaError::EncodeFailed(format!("unknown enum variant '{kind}'")))?; + let tag = u8::try_from(idx).map_err(|_| SolanaError::EncodeFailed("enum tag > 255".into()))?; + buf.push(tag); + + match (&variant.fields, obj.get("value")) { + (None, _) => Ok(()), + (Some(fields), Some(payload)) => { + write_defined_fields(buf, payload, Some(fields), types) + } + (Some(_), None) => Err(SolanaError::EncodeFailed(format!( + "enum variant '{kind}' expects payload but got none" + ))), + } +} + +fn write_primitive(buf: &mut Vec, v: T) -> Result<(), SolanaError> { + v.serialize(buf) + .map_err(|e| SolanaError::EncodeFailed(e.to_string())) +} + +fn mismatch(expected: &str, got: &Value) -> SolanaError { + SolanaError::EncodeTypeMismatch { + expected: expected.to_string(), + got: format!("{got}"), + } +} + +fn decode_hex(s: &str) -> Option> { + let s = s.strip_prefix("0x").unwrap_or(s); + if s.len() % 2 != 0 { + return None; + } + let mut out = Vec::with_capacity(s.len() / 2); + for i in (0..s.len()).step_by(2) { + out.push(u8::from_str_radix(&s[i..i + 2], 16).ok()?); + } + Some(out) +} + diff --git a/crates/ilold-solana-core/src/encode/mod.rs b/crates/ilold-solana-core/src/encode/mod.rs new file mode 100644 index 0000000..69925f6 --- /dev/null +++ b/crates/ilold-solana-core/src/encode/mod.rs @@ -0,0 +1,3 @@ +pub mod borsh; + +pub use borsh::{encode_ix_data, encode_value}; diff --git a/crates/ilold-solana-core/src/error.rs b/crates/ilold-solana-core/src/error.rs index 7837c8e..37f6e31 100644 --- a/crates/ilold-solana-core/src/error.rs +++ b/crates/ilold-solana-core/src/error.rs @@ -58,4 +58,10 @@ pub enum SolanaError { #[error("PDA program override is bound to an arg seed '{path}', which is not supported")] PdaProgramArgUnsupported { path: String }, + + #[error("Borsh encode failed: {0}")] + EncodeFailed(String), + + #[error("Borsh encode type mismatch: expected {expected}, got {got}")] + EncodeTypeMismatch { expected: String, got: String }, } diff --git a/crates/ilold-solana-core/src/lib.rs b/crates/ilold-solana-core/src/lib.rs index eff9aab..bd8d459 100644 --- a/crates/ilold-solana-core/src/lib.rs +++ b/crates/ilold-solana-core/src/lib.rs @@ -1,4 +1,5 @@ pub mod decode; +pub mod encode; pub mod error; pub mod execute; pub mod idl; From 23b9c5a5942a2850f7bd36b18cb278a9b5952e3c Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 11:46:57 +0200 Subject: [PATCH 022/115] chore(solana-core): add solana-instruction, message, transaction, hash deps - needed by the upcoming transaction builder to construct VersionedTransactions --- Cargo.lock | 4 ++++ crates/ilold-solana-core/Cargo.toml | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 0d9e8b6..798aedd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2423,9 +2423,13 @@ dependencies = [ "solana-account", "solana-address 2.6.0", "solana-clock", + "solana-hash 3.1.0", + "solana-instruction", "solana-keypair", + "solana-message", "solana-sdk-ids", "solana-signer", + "solana-transaction", "tempfile", "thiserror 2.0.18", ] diff --git a/crates/ilold-solana-core/Cargo.toml b/crates/ilold-solana-core/Cargo.toml index 51b7c2e..186e9fa 100644 --- a/crates/ilold-solana-core/Cargo.toml +++ b/crates/ilold-solana-core/Cargo.toml @@ -9,9 +9,13 @@ anchor-lang-idl = { git = "https://github.com/solana-foundation/anchor", tag = " solana-account = "3" solana-address = { version = "2.0", features = ["serde", "decode", "curve25519"] } solana-clock = "3.0" +solana-hash = "3.1" +solana-instruction = "3.0" solana-keypair = "3.1" +solana-message = "3.0" solana-sdk-ids = "3" solana-signer = "3.0" +solana-transaction = "3.0" litesvm = "0.11" serde = { workspace = true } serde_json = { workspace = true } From c33ad4d624bb15d578dc5af3103bd1e436cc8ec4 Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 11:51:00 +0200 Subject: [PATCH 023/115] feat(solana-core): instruction and transaction builders - build_instruction resolves PDAs via derive_pda, injects bump_arg, encodes data with encode_ix_data and produces a solana-instruction Instruction - build_transaction wraps an Instruction into a Legacy VersionedMessage and signs it with the payer keypair, mirroring the canonical pattern used by LiteSVM and Surfpool - AccountNotProvided error variant for ix accounts that are neither constant nor PDA nor explicitly supplied --- crates/ilold-solana-core/src/error.rs | 3 + .../ilold-solana-core/src/execute/builder.rs | 84 +++++++++++++++++++ crates/ilold-solana-core/src/execute/mod.rs | 2 + 3 files changed, 89 insertions(+) create mode 100644 crates/ilold-solana-core/src/execute/builder.rs diff --git a/crates/ilold-solana-core/src/error.rs b/crates/ilold-solana-core/src/error.rs index 37f6e31..fc5b9ef 100644 --- a/crates/ilold-solana-core/src/error.rs +++ b/crates/ilold-solana-core/src/error.rs @@ -64,4 +64,7 @@ pub enum SolanaError { #[error("Borsh encode type mismatch: expected {expected}, got {got}")] EncodeTypeMismatch { expected: String, got: String }, + + #[error("instruction account '{path}' is not constant, not a PDA, and not provided")] + AccountNotProvided { path: String }, } diff --git a/crates/ilold-solana-core/src/execute/builder.rs b/crates/ilold-solana-core/src/execute/builder.rs new file mode 100644 index 0000000..c3a6b11 --- /dev/null +++ b/crates/ilold-solana-core/src/execute/builder.rs @@ -0,0 +1,84 @@ +use std::collections::HashMap; + +use anchor_lang_idl::types::IdlTypeDef; +use serde_json::Value; +use solana_address::Address; +use solana_hash::Hash; +use solana_instruction::{AccountMeta, Instruction}; +use solana_keypair::Keypair; +use solana_message::{Message, VersionedMessage}; +use solana_signer::Signer; +use solana_transaction::versioned::VersionedTransaction; + +use crate::encode::encode_ix_data; +use crate::error::SolanaError; +use crate::execute::pda::derive_pda; +use crate::model::InstructionDef; + +pub fn build_instruction( + program_id: Address, + ix: &InstructionDef, + mut args: Value, + accounts: HashMap, + types: &[IdlTypeDef], +) -> Result { + let mut resolved = accounts; + + for spec in &ix.accounts { + if resolved.contains_key(&spec.path) || resolved.contains_key(&spec.name) { + continue; + } + if let Some(pda_spec) = &spec.pda { + let (pda, bump) = derive_pda(pda_spec, program_id, &args, &resolved)?; + resolved.insert(spec.path.clone(), pda); + if let Some(arg_name) = &pda_spec.bump_arg { + if let Some(obj) = args.as_object_mut() { + obj.insert(arg_name.clone(), Value::from(bump)); + } + } + } else if let Some(addr) = spec.address { + resolved.insert(spec.path.clone(), addr); + } else { + return Err(SolanaError::AccountNotProvided { + path: spec.path.clone(), + }); + } + } + + let metas: Vec = ix + .accounts + .iter() + .map(|spec| { + let addr = resolved + .get(&spec.path) + .or_else(|| resolved.get(&spec.name)) + .copied() + .ok_or_else(|| SolanaError::AccountNotProvided { + path: spec.path.clone(), + })?; + Ok(if spec.writable { + AccountMeta::new(addr, spec.signer) + } else { + AccountMeta::new_readonly(addr, spec.signer) + }) + }) + .collect::, SolanaError>>()?; + + let data = encode_ix_data(ix, &args, types)?; + + Ok(Instruction { + program_id, + accounts: metas, + data, + }) +} + +pub fn build_transaction( + ix: Instruction, + payer: &Keypair, + blockhash: Hash, +) -> Result { + let msg = Message::new_with_blockhash(&[ix], Some(&payer.pubkey()), &blockhash); + VersionedTransaction::try_new(VersionedMessage::Legacy(msg), &[payer]) + .map_err(|e| SolanaError::EncodeFailed(format!("transaction sign: {e:?}"))) +} diff --git a/crates/ilold-solana-core/src/execute/mod.rs b/crates/ilold-solana-core/src/execute/mod.rs index 9048653..191ffdf 100644 --- a/crates/ilold-solana-core/src/execute/mod.rs +++ b/crates/ilold-solana-core/src/execute/mod.rs @@ -1,7 +1,9 @@ +pub mod builder; pub mod fork; pub mod pda; pub mod vm; +pub use builder::{build_instruction, build_transaction}; pub use fork::VmSnapshot; pub use pda::derive_pda; pub use vm::VmHost; From 7b9d94f6d8079a0534315a2fb83230e946c15ba8 Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 11:59:25 +0200 Subject: [PATCH 024/115] feat(solana-core): add_solana_step orchestrates ix execution and account diff - runs build_instruction + build_transaction, sends via VmHost, captures pre/post state via get_account - AccountDiff lists raw before/after data, lamports_delta, owner_changed for each declared ix account - diffs project into StateMutation entries that fit the shared ExplorationStep - failed transactions still produce a RuntimeTrace with logs and error so the session timeline records the attempt - ilold-session-core wired as a dep so ExplorationSession, ExplorationStep, RuntimeTrace and AccountDiff are reused as-is --- Cargo.lock | 1 + crates/ilold-solana-core/Cargo.toml | 1 + .../src/exploration/add_step.rs | 172 ++++++++++++++++++ .../ilold-solana-core/src/exploration/mod.rs | 3 + crates/ilold-solana-core/src/lib.rs | 1 + 5 files changed, 178 insertions(+) create mode 100644 crates/ilold-solana-core/src/exploration/add_step.rs create mode 100644 crates/ilold-solana-core/src/exploration/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 798aedd..595ab50 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2417,6 +2417,7 @@ dependencies = [ "anyhow", "borsh", "bs58", + "ilold-session-core", "litesvm", "serde", "serde_json", diff --git a/crates/ilold-solana-core/Cargo.toml b/crates/ilold-solana-core/Cargo.toml index 186e9fa..713fcb3 100644 --- a/crates/ilold-solana-core/Cargo.toml +++ b/crates/ilold-solana-core/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true description = "Anchor IDL parser and (later) Solana execution backend for Ilold." [dependencies] +ilold-session-core = { path = "../ilold-session-core" } anchor-lang-idl = { git = "https://github.com/solana-foundation/anchor", tag = "v1.0.2", features = ["build", "convert"] } solana-account = "3" solana-address = { version = "2.0", features = ["serde", "decode", "curve25519"] } diff --git a/crates/ilold-solana-core/src/exploration/add_step.rs b/crates/ilold-solana-core/src/exploration/add_step.rs new file mode 100644 index 0000000..85b0036 --- /dev/null +++ b/crates/ilold-solana-core/src/exploration/add_step.rs @@ -0,0 +1,172 @@ +use std::collections::HashMap; + +use ilold_session_core::exploration::assign_operator::AssignOperator; +use ilold_session_core::exploration::session::{ + ExplorationSession, ExplorationStep, MutationScope, StateMutation, TraceConfig, +}; +use ilold_session_core::journal::types::JournalEntry; +use ilold_session_core::runtime_trace::{AccountDiff, RuntimeTrace}; +use serde_json::Value; +use solana_account::{Account, ReadableAccount}; +use solana_address::Address; + +use crate::error::SolanaError; +use crate::execute::{build_instruction, build_transaction, VmHost}; +use crate::model::{InstructionDef, ProgramDef}; + +#[allow(clippy::too_many_arguments)] +pub fn add_solana_step<'a>( + session: &'a mut ExplorationSession, + program: &ProgramDef, + ix: &InstructionDef, + vm: &mut VmHost, + args: Value, + accounts: HashMap, + timestamp: &str, +) -> Result<&'a ExplorationStep, SolanaError> { + let step_index = session.steps.len(); + let types = &program.types; + + let instruction = + build_instruction(program.program_id, ix, args, accounts, types)?; + + let pre_state: Vec<(Address, Option)> = instruction + .accounts + .iter() + .map(|m| (m.pubkey, vm.svm().get_account(&m.pubkey))) + .collect(); + + let blockhash = vm.svm().latest_blockhash(); + let tx = build_transaction(instruction.clone(), vm.payer(), blockhash)?; + let result = vm.svm_mut().send_transaction(tx); + + let (runtime_trace, mutations) = match result { + Ok(meta) => { + let post_state: Vec<(Address, Option)> = pre_state + .iter() + .map(|(addr, _)| (*addr, vm.svm().get_account(addr))) + .collect(); + + let diffs = compute_diffs(&pre_state, &post_state, ix); + let mutations = diffs_to_mutations(&diffs, step_index); + let return_data = if meta.return_data.data.is_empty() { + None + } else { + Some(meta.return_data.data) + }; + ( + RuntimeTrace { + logs: meta.logs, + compute_units: meta.compute_units_consumed, + inner_instructions: vec![], + account_diffs: diffs, + return_data, + error: None, + }, + mutations, + ) + } + Err(failed) => ( + RuntimeTrace { + logs: failed.meta.logs, + compute_units: failed.meta.compute_units_consumed, + inner_instructions: vec![], + account_diffs: vec![], + return_data: None, + error: Some(format!("{:?}", failed.err)), + }, + vec![], + ), + }; + + let trace_value = serde_json::to_value(&runtime_trace).ok(); + + session.steps.push(ExplorationStep { + function: ix.name.clone(), + mutations, + flow_tree: None, + trace_config: TraceConfig::default(), + runtime_trace: trace_value, + }); + + session + .journal + .record(JournalEntry::SequenceExplored { + steps: session.steps.iter().map(|s| s.function.clone()).collect(), + timestamp: timestamp.into(), + }); + + Ok(session.steps.last().unwrap()) +} + +fn compute_diffs( + pre: &[(Address, Option)], + post: &[(Address, Option)], + ix: &InstructionDef, +) -> Vec { + pre.iter() + .zip(post.iter()) + .enumerate() + .filter_map(|(idx, ((addr, before), (_, after)))| { + let lamports_before = before.as_ref().map(|a| a.lamports()).unwrap_or(0); + let lamports_after = after.as_ref().map(|a| a.lamports()).unwrap_or(0); + let data_before = before.as_ref().map(|a| a.data().to_vec()); + let data_after = after.as_ref().map(|a| a.data().to_vec()); + let owner_before = before.as_ref().map(|a| a.owner); + let owner_after = after.as_ref().map(|a| a.owner); + + let lamports_changed = lamports_before != lamports_after; + let data_changed = data_before != data_after; + let owner_changed = owner_before != owner_after; + + if !lamports_changed && !data_changed && !owner_changed { + return None; + } + + Some(AccountDiff { + address: addr.to_string(), + name: ix.accounts.get(idx).map(|s| s.name.clone()), + before: data_before, + after: data_after, + lamports_delta: (lamports_after as i128) - (lamports_before as i128), + owner_changed, + decoded_before: None, + decoded_after: None, + }) + }) + .collect() +} + +fn diffs_to_mutations(diffs: &[AccountDiff], step_index: usize) -> Vec { + let mut out = Vec::new(); + for d in diffs { + let label = d.name.clone().unwrap_or_else(|| d.address.clone()); + if d.lamports_delta != 0 { + out.push(StateMutation { + variable: format!("{label}.lamports"), + operator: AssignOperator::Assign, + value_expr: d.lamports_delta.to_string(), + step_index, + via: None, + flow_step_id: None, + scope: MutationScope::State, + }); + } + if d.before != d.after { + out.push(StateMutation { + variable: format!("{label}.data"), + operator: AssignOperator::Assign, + value_expr: d + .after + .as_ref() + .map(|b| format!("{} bytes", b.len())) + .unwrap_or_else(|| "".into()), + step_index, + via: None, + flow_step_id: None, + scope: MutationScope::State, + }); + } + } + out +} diff --git a/crates/ilold-solana-core/src/exploration/mod.rs b/crates/ilold-solana-core/src/exploration/mod.rs new file mode 100644 index 0000000..bf995c3 --- /dev/null +++ b/crates/ilold-solana-core/src/exploration/mod.rs @@ -0,0 +1,3 @@ +pub mod add_step; + +pub use add_step::add_solana_step; diff --git a/crates/ilold-solana-core/src/lib.rs b/crates/ilold-solana-core/src/lib.rs index bd8d459..137cf87 100644 --- a/crates/ilold-solana-core/src/lib.rs +++ b/crates/ilold-solana-core/src/lib.rs @@ -2,6 +2,7 @@ pub mod decode; pub mod encode; pub mod error; pub mod execute; +pub mod exploration; pub mod idl; pub mod ingest; pub mod model; From 169bbf045e9474c8a19a5a5df31b091397119432 Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 12:29:35 +0200 Subject: [PATCH 025/115] test(solana-core): encoder roundtrip with the decoder - 8 tests assert decode(encode(v)) == v across primitives, u128 string, string, bytes hex, pubkey, Option, Vec, Array, struct and enum - proves the encoder mirrors the T-06 decoder exactly --- .../tests/encode_roundtrip.rs | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 crates/ilold-solana-core/tests/encode_roundtrip.rs diff --git a/crates/ilold-solana-core/tests/encode_roundtrip.rs b/crates/ilold-solana-core/tests/encode_roundtrip.rs new file mode 100644 index 0000000..9b960f2 --- /dev/null +++ b/crates/ilold-solana-core/tests/encode_roundtrip.rs @@ -0,0 +1,134 @@ +use anchor_lang_idl::types::{ + IdlArrayLen, IdlDefinedFields, IdlEnumVariant, IdlField, IdlSerialization, IdlType, + IdlTypeDef, IdlTypeDefTy, +}; +use ilold_solana_core::decode::decode_value; +use ilold_solana_core::encode::encode_value; +use serde_json::{json, Value}; + +fn types() -> Vec { + Vec::new() +} + +fn roundtrip(value: Value, ty: &IdlType, types: &[IdlTypeDef]) -> Value { + let bytes = encode_value(&value, ty, types).expect("encode"); + let mut cursor: &[u8] = &bytes; + decode_value(&mut cursor, ty, types).expect("decode") +} + +#[test] +fn primitives_roundtrip() { + assert_eq!(roundtrip(json!(true), &IdlType::Bool, &types()), json!(true)); + assert_eq!(roundtrip(json!(42), &IdlType::U8, &types()), json!(42)); + assert_eq!(roundtrip(json!(-7), &IdlType::I32, &types()), json!(-7)); + assert_eq!(roundtrip(json!(1_234_567_890_u64), &IdlType::U64, &types()), json!(1_234_567_890_u64)); +} + +#[test] +fn u128_roundtrip_as_decimal_string() { + let value = json!("340282366920938463463374607431768211455"); + let result = roundtrip(value.clone(), &IdlType::U128, &types()); + assert_eq!(result, value); +} + +#[test] +fn string_and_bytes_roundtrip() { + assert_eq!( + roundtrip(json!("hola"), &IdlType::String, &types()), + json!("hola") + ); + let bytes_hex = json!("0102030a"); + assert_eq!(roundtrip(bytes_hex.clone(), &IdlType::Bytes, &types()), bytes_hex); +} + +#[test] +fn pubkey_roundtrip() { + let pk = json!("11111111111111111111111111111111"); + assert_eq!(roundtrip(pk.clone(), &IdlType::Pubkey, &types()), pk); +} + +#[test] +fn option_some_and_none_roundtrip() { + let ty = IdlType::Option(Box::new(IdlType::U32)); + assert_eq!(roundtrip(json!(null), &ty, &types()), json!(null)); + assert_eq!(roundtrip(json!(7), &ty, &types()), json!(7)); +} + +#[test] +fn vec_and_array_roundtrip() { + let vec_ty = IdlType::Vec(Box::new(IdlType::U16)); + assert_eq!(roundtrip(json!([10, 20, 30]), &vec_ty, &types()), json!([10, 20, 30])); + + let arr_ty = IdlType::Array(Box::new(IdlType::U32), IdlArrayLen::Value(3)); + assert_eq!(roundtrip(json!([1, 2, 3]), &arr_ty, &types()), json!([1, 2, 3])); +} + +#[test] +fn defined_struct_roundtrip() { + let counter = IdlTypeDef { + name: "Counter".into(), + docs: vec![], + serialization: IdlSerialization::default(), + repr: None, + generics: vec![], + ty: IdlTypeDefTy::Struct { + fields: Some(IdlDefinedFields::Named(vec![ + IdlField { + name: "count".into(), + docs: vec![], + ty: IdlType::U64, + }, + IdlField { + name: "active".into(), + docs: vec![], + ty: IdlType::Bool, + }, + ])), + }, + }; + let types = vec![counter]; + let ty = IdlType::Defined { + name: "Counter".into(), + generics: vec![], + }; + let value = json!({"count": 99, "active": true}); + assert_eq!(roundtrip(value.clone(), &ty, &types), value); +} + +#[test] +fn enum_with_unit_and_payload_roundtrip() { + let status = IdlTypeDef { + name: "Status".into(), + docs: vec![], + serialization: IdlSerialization::default(), + repr: None, + generics: vec![], + ty: IdlTypeDefTy::Enum { + variants: vec![ + IdlEnumVariant { + name: "Off".into(), + fields: None, + }, + IdlEnumVariant { + name: "On".into(), + fields: Some(IdlDefinedFields::Named(vec![IdlField { + name: "level".into(), + docs: vec![], + ty: IdlType::U8, + }])), + }, + ], + }, + }; + let types = vec![status]; + let ty = IdlType::Defined { + name: "Status".into(), + generics: vec![], + }; + + let off = json!({"kind": "Off"}); + assert_eq!(roundtrip(off.clone(), &ty, &types), off); + + let on = json!({"kind": "On", "value": {"level": 200}}); + assert_eq!(roundtrip(on.clone(), &ty, &types), on); +} From 04447864b8f87bf9082c7125ba2afafd2007430e Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 12:30:17 +0200 Subject: [PATCH 026/115] test(solana-core): compute_diffs and diffs_to_mutations units - diff_skips_unchanged, detects lamports+data, handles account creation - mutations emit lamports and data entries with the right step_index --- .../src/exploration/add_step.rs | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/crates/ilold-solana-core/src/exploration/add_step.rs b/crates/ilold-solana-core/src/exploration/add_step.rs index 85b0036..2877f57 100644 --- a/crates/ilold-solana-core/src/exploration/add_step.rs +++ b/crates/ilold-solana-core/src/exploration/add_step.rs @@ -170,3 +170,108 @@ fn diffs_to_mutations(diffs: &[AccountDiff], step_index: usize) -> Vec InstructionDef { + InstructionDef { + name: "test".into(), + discriminator: [0u8; 8], + args: vec![], + accounts: names + .iter() + .map(|n| AccountSpec { + path: n.to_string(), + name: n.to_string(), + writable: true, + signer: false, + optional: false, + address: None, + pda: None, + relations: vec![], + }) + .collect(), + returns: None, + } + } + + fn account_with(lamports: u64, data: Vec) -> Account { + Account { + lamports, + data, + owner: Address::default(), + executable: false, + rent_epoch: 0, + } + } + + #[test] + fn diff_skips_unchanged_accounts() { + let addr = Keypair::new().pubkey(); + let acc = account_with(100, vec![1, 2, 3]); + let pre = vec![(addr, Some(acc.clone()))]; + let post = vec![(addr, Some(acc))]; + let ix = ix_with_accounts(&["a"]); + + let diffs = compute_diffs(&pre, &post, &ix); + assert!(diffs.is_empty()); + } + + #[test] + fn diff_detects_lamports_and_data_changes() { + let addr = Keypair::new().pubkey(); + let pre = vec![(addr, Some(account_with(100, vec![1])))]; + let post = vec![(addr, Some(account_with(150, vec![1, 2, 3])))]; + let ix = ix_with_accounts(&["counter"]); + + let diffs = compute_diffs(&pre, &post, &ix); + assert_eq!(diffs.len(), 1); + let d = &diffs[0]; + assert_eq!(d.lamports_delta, 50); + assert_eq!(d.before, Some(vec![1])); + assert_eq!(d.after, Some(vec![1, 2, 3])); + assert_eq!(d.name.as_deref(), Some("counter")); + } + + #[test] + fn diff_handles_account_creation() { + let addr = Keypair::new().pubkey(); + let pre = vec![(addr, None)]; + let post = vec![(addr, Some(account_with(890, vec![0; 8])))]; + let ix = ix_with_accounts(&["new_acc"]); + + let diffs = compute_diffs(&pre, &post, &ix); + assert_eq!(diffs.len(), 1); + let d = &diffs[0]; + assert_eq!(d.lamports_delta, 890); + assert_eq!(d.before, None); + assert_eq!(d.after, Some(vec![0; 8])); + } + + #[test] + fn mutations_emit_lamports_and_data_entries() { + let diffs = vec![AccountDiff { + address: "abc".into(), + name: Some("counter".into()), + before: Some(vec![0]), + after: Some(vec![42]), + lamports_delta: 1_000, + owner_changed: false, + decoded_before: None, + decoded_after: None, + }]; + + let muts = diffs_to_mutations(&diffs, 3); + assert_eq!(muts.len(), 2); + assert_eq!(muts[0].variable, "counter.lamports"); + assert_eq!(muts[0].value_expr, "1000"); + assert_eq!(muts[0].step_index, 3); + assert_eq!(muts[1].variable, "counter.data"); + assert_eq!(muts[1].value_expr, "1 bytes"); + } +} From 08d1abc8af00549790cace2df0d96231c34cda9c Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 12:36:24 +0200 Subject: [PATCH 027/115] test(solana-core): e2e lever stub gated on compiled program - ignored test exercises add_solana_step against the lever Anchor program - runs once tests/programs/lever.so is committed by anchor build --- crates/ilold-solana-core/tests/e2e_lever.rs | 66 +++++++++++++++++++ .../ilold-solana-core/tests/programs/.gitkeep | 0 2 files changed, 66 insertions(+) create mode 100644 crates/ilold-solana-core/tests/e2e_lever.rs create mode 100644 crates/ilold-solana-core/tests/programs/.gitkeep diff --git a/crates/ilold-solana-core/tests/e2e_lever.rs b/crates/ilold-solana-core/tests/e2e_lever.rs new file mode 100644 index 0000000..a2ba310 --- /dev/null +++ b/crates/ilold-solana-core/tests/e2e_lever.rs @@ -0,0 +1,66 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use ilold_session_core::exploration::session::ExplorationSession; +use ilold_solana_core::execute::VmHost; +use ilold_solana_core::exploration::add_solana_step; +use ilold_solana_core::idl::parse_idl; +use ilold_solana_core::model::ProgramDef; +use solana_address::Address; +use solana_keypair::Keypair; +use solana_signer::Signer; + +const LEVER_JSON: &str = include_str!("fixtures/lever.json"); + +fn lever_so_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/programs/lever.so") +} + +fn read_lever_so() -> Vec { + std::fs::read(lever_so_path()).expect( + "lever.so missing — run `cd tests/fixtures/solana/cpi && anchor build` and copy \ + target/deploy/lever.so to crates/ilold-solana-core/tests/programs/lever.so", + ) +} + +#[test] +#[ignore = "requires lever.so produced by anchor build"] +fn add_solana_step_executes_initialize_against_real_program() { + let idl = parse_idl(LEVER_JSON).expect("parse lever idl"); + let program = ProgramDef::from_idl(idl).expect("build ProgramDef"); + let so_bytes = read_lever_so(); + + let mut vm = VmHost::boot(vec![(program.program_id, so_bytes)]).expect("boot vm"); + + let power_status = Keypair::new(); + let mut accounts: HashMap = HashMap::new(); + accounts.insert("power".into(), power_status.pubkey()); + accounts.insert("user".into(), vm.payer_pubkey()); + accounts.insert( + "system_program".into(), + "11111111111111111111111111111111".parse().unwrap(), + ); + + let initialize = program + .instructions + .iter() + .find(|ix| ix.name == "initialize") + .expect("initialize ix"); + + let mut session = ExplorationSession::new("lever", "ilold"); + let step = add_solana_step( + &mut session, + &program, + initialize, + &mut vm, + serde_json::json!({}), + accounts, + "2026-05-06T00:00:00Z", + ) + .expect("add_solana_step"); + + assert_eq!(step.function, "initialize"); + let trace = step.runtime_trace.as_ref().expect("runtime_trace populated"); + let logs = trace.get("logs").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + assert!(!logs.is_empty(), "expected non-empty logs after initialize"); +} diff --git a/crates/ilold-solana-core/tests/programs/.gitkeep b/crates/ilold-solana-core/tests/programs/.gitkeep new file mode 100644 index 0000000..e69de29 From 8514739440ce288fba2b7e44561c0d271ac401b7 Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 13:03:37 +0200 Subject: [PATCH 028/115] chore(solana-core): vendor lever.so for e2e test - compiled with anchor 0.32 + solana 3.1.8 from tests/fixtures/solana/cpi - 146KB binary checked in alongside the IDL fixture, mirrors LiteSVM and Anchor monorepo test layout --- .../ilold-solana-core/tests/programs/lever.so | Bin 0 -> 146360 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100755 crates/ilold-solana-core/tests/programs/lever.so diff --git a/crates/ilold-solana-core/tests/programs/lever.so b/crates/ilold-solana-core/tests/programs/lever.so new file mode 100755 index 0000000000000000000000000000000000000000..7ae7974f58e5dad13d7f2837b9ab666891d97976 GIT binary patch literal 146360 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

32GO-9-2T?xTBs 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

2{VTEz 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 literal 0 HcmV?d00001 From 9f8f197033017d3282d8e42b2c317b6e3fd63555 Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 13:03:46 +0200 Subject: [PATCH 029/115] test(solana-core): exercise add_solana_step on lever switch_power - seeds a PowerStatus account owned by the program with the right discriminator - calls switch_power and asserts log line plus the is_on bit flips on chain - single-payer signer keeps the test inside the current transaction builder API --- crates/ilold-solana-core/tests/e2e_lever.rs | 69 ++++++++++++++++----- 1 file changed, 52 insertions(+), 17 deletions(-) diff --git a/crates/ilold-solana-core/tests/e2e_lever.rs b/crates/ilold-solana-core/tests/e2e_lever.rs index a2ba310..3656c41 100644 --- a/crates/ilold-solana-core/tests/e2e_lever.rs +++ b/crates/ilold-solana-core/tests/e2e_lever.rs @@ -6,6 +6,7 @@ use ilold_solana_core::execute::VmHost; use ilold_solana_core::exploration::add_solana_step; use ilold_solana_core::idl::parse_idl; use ilold_solana_core::model::ProgramDef; +use solana_account::Account; use solana_address::Address; use solana_keypair::Keypair; use solana_signer::Signer; @@ -24,43 +25,77 @@ fn read_lever_so() -> Vec { } #[test] -#[ignore = "requires lever.so produced by anchor build"] -fn add_solana_step_executes_initialize_against_real_program() { +fn add_solana_step_runs_switch_power_against_real_program() { let idl = parse_idl(LEVER_JSON).expect("parse lever idl"); let program = ProgramDef::from_idl(idl).expect("build ProgramDef"); let so_bytes = read_lever_so(); let mut vm = VmHost::boot(vec![(program.program_id, so_bytes)]).expect("boot vm"); - let power_status = Keypair::new(); + let power_status_def = program + .account_types + .iter() + .find(|a| a.name == "PowerStatus") + .expect("PowerStatus account in IDL"); + + let power_kp = Keypair::new(); + let power_pk = power_kp.pubkey(); + let mut data = Vec::with_capacity(9); + data.extend_from_slice(&power_status_def.discriminator); + data.push(0u8); + let lamports = vm.svm().minimum_balance_for_rent_exemption(data.len()); + let acc = Account { + lamports, + data, + owner: program.program_id, + executable: false, + rent_epoch: 0, + }; + vm.svm_mut().set_account(power_pk, acc).expect("seed power account"); + let mut accounts: HashMap = HashMap::new(); - accounts.insert("power".into(), power_status.pubkey()); - accounts.insert("user".into(), vm.payer_pubkey()); - accounts.insert( - "system_program".into(), - "11111111111111111111111111111111".parse().unwrap(), - ); + accounts.insert("power".into(), power_pk); - let initialize = program + let switch = program .instructions .iter() - .find(|ix| ix.name == "initialize") - .expect("initialize ix"); + .find(|ix| ix.name == "switch_power") + .expect("switch_power ix"); let mut session = ExplorationSession::new("lever", "ilold"); let step = add_solana_step( &mut session, &program, - initialize, + switch, &mut vm, - serde_json::json!({}), + serde_json::json!({"name": "claude"}), accounts, "2026-05-06T00:00:00Z", ) .expect("add_solana_step"); - assert_eq!(step.function, "initialize"); + assert_eq!(step.function, "switch_power"); let trace = step.runtime_trace.as_ref().expect("runtime_trace populated"); - let logs = trace.get("logs").and_then(|v| v.as_array()).cloned().unwrap_or_default(); - assert!(!logs.is_empty(), "expected non-empty logs after initialize"); + assert!( + trace.get("error").map(|v| v.is_null()).unwrap_or(true), + "transaction errored: {:?}", + trace.get("error") + ); + let logs = trace + .get("logs") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + let joined = logs + .iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join("\n"); + assert!( + joined.contains("pulling the power switch"), + "expected lever log line, got:\n{joined}" + ); + + let after = vm.svm().get_account(&power_pk).expect("power account after"); + assert_eq!(after.data[8], 1, "is_on flag should flip to true"); } From b52a65ea8444c14b4c56b031af5e5cb529ed08f0 Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 18:45:15 +0200 Subject: [PATCH 030/115] refactor(session-core): pull shared scenario+access types from core - AccessLevel, ScenarioAction, ScenarioInfo, ScenarioEvent, CanvasPatch and validate_scenario_name now live in ilold-session-core under exploration/{access,scenario,canvas}.rs - ilold-core re-exports preserve existing import paths for callers - unblocks ilold-solana-core reusing the same types without depending on ilold-core --- .../ilold-core/src/classify/entry_points.rs | 38 +---------- crates/ilold-core/src/exploration/commands.rs | 64 ++----------------- .../src/exploration/access.rs | 37 +++++++++++ .../src/exploration/canvas.rs | 13 ++++ .../ilold-session-core/src/exploration/mod.rs | 3 + .../src/exploration/scenario.rs | 48 ++++++++++++++ 6 files changed, 106 insertions(+), 97 deletions(-) create mode 100644 crates/ilold-session-core/src/exploration/access.rs create mode 100644 crates/ilold-session-core/src/exploration/canvas.rs create mode 100644 crates/ilold-session-core/src/exploration/scenario.rs diff --git a/crates/ilold-core/src/classify/entry_points.rs b/crates/ilold-core/src/classify/entry_points.rs index b5f3a78..80ce6a9 100644 --- a/crates/ilold-core/src/classify/entry_points.rs +++ b/crates/ilold-core/src/classify/entry_points.rs @@ -1,46 +1,10 @@ -use std::fmt; - -use serde::{Deserialize, Serialize}; +pub use ilold_session_core::exploration::access::AccessLevel; use crate::model::contract::ContractDef; use crate::model::expression::{BinaryOperator, Expression, ExpressionKind}; use crate::model::function::{FunctionDef, FunctionKind, Visibility}; use crate::model::statement::{Statement, StatementKind}; -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub enum AccessLevel { - Public, - Restricted { role: String }, - Internal, - Special { kind: String }, -} - -impl fmt::Display for AccessLevel { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match self { - AccessLevel::Public => write!(f, "Public"), - AccessLevel::Restricted { role } => write!(f, "Restricted({})", role), - AccessLevel::Internal => write!(f, "Internal"), - AccessLevel::Special { kind } => write!(f, "Special({})", kind), - } - } -} - -impl AccessLevel { - pub fn short_label(&self) -> &str { - match self { - AccessLevel::Public => "P", - AccessLevel::Restricted { .. } => "R", - AccessLevel::Internal => "I", - AccessLevel::Special { .. } => "S", - } - } - - pub fn is_unrestricted(&self) -> bool { - matches!(self, AccessLevel::Public) - } -} - // Modifiers that restrict WHO can call (access control) const ACCESS_MODIFIERS: &[&str] = &[ "onlyowner", "onlyadmin", "onlyrole", "onlygovernance", diff --git a/crates/ilold-core/src/exploration/commands.rs b/crates/ilold-core/src/exploration/commands.rs index fac19de..4be5443 100644 --- a/crates/ilold-core/src/exploration/commands.rs +++ b/crates/ilold-core/src/exploration/commands.rs @@ -59,18 +59,10 @@ pub struct AnalysisData<'a> { pub all_classifications: &'a HashMap>, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ScenarioAction { - New { name: String }, - List, - Switch { name: String }, - Fork { - name: String, - #[serde(default)] - at_step: Option, - }, - Delete { name: String }, -} +pub use ilold_session_core::exploration::canvas::CanvasPatch; +pub use ilold_session_core::exploration::scenario::{ + validate_scenario_name, ScenarioAction, ScenarioEvent, ScenarioInfo, +}; #[derive(Debug, Clone, Serialize, Deserialize)] pub enum SessionCommand { @@ -96,13 +88,6 @@ pub enum SessionCommand { Scenario { sub: ScenarioAction }, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ScenarioInfo { - pub name: String, - pub active: bool, - pub step_count: usize, -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub enum CommandResult { StepAdded { @@ -162,47 +147,6 @@ pub enum CommandResult { ScenarioDeleted { name: String }, } -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum CanvasPatch { - AddNode { scenario: String, function: String, access: AccessLevel, step_index: usize }, - RemoveLastNode { scenario: String }, - ClearAll { scenario: String }, - Highlight { scenario: String, function: String }, - ScenarioEvent(ScenarioEvent), -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub enum ScenarioEvent { - Created { name: String }, - Switched { from: String, to: String }, - Deleted { name: String }, - Forked { from: String, to: String, at_step: usize }, - /// Emitted after a successful `LoadSession`. Carries the new active - /// scenario name so the frontend can update its activeScenario eagerly, - /// and triggers a full resync to pull every scenario + forkOrigin. - Reloaded { active: String }, -} - -/// Validates scenario name against `^[a-z][a-z0-9_-]{0,31}$`. -/// Manual ASCII check — avoids pulling in the `regex` crate. -pub fn validate_scenario_name(name: &str) -> Result<(), String> { - const ERR: &str = "Invalid scenario name: must match ^[a-z][a-z0-9_-]{0,31}$"; - if name.is_empty() || name.len() > 32 { - return Err(ERR.to_string()); - } - let mut chars = name.chars(); - let first = chars.next().ok_or_else(|| ERR.to_string())?; - if !first.is_ascii_lowercase() { - return Err(ERR.to_string()); - } - for c in chars { - if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') { - return Err(ERR.to_string()); - } - } - Ok(()) -} - pub fn canvas_patch_from(result: &CommandResult, active_scenario: &str) -> Option { match result { CommandResult::StepAdded { function, access, step_index, .. } => { diff --git a/crates/ilold-session-core/src/exploration/access.rs b/crates/ilold-session-core/src/exploration/access.rs new file mode 100644 index 0000000..667782d --- /dev/null +++ b/crates/ilold-session-core/src/exploration/access.rs @@ -0,0 +1,37 @@ +use std::fmt; + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum AccessLevel { + Public, + Restricted { role: String }, + Internal, + Special { kind: String }, +} + +impl fmt::Display for AccessLevel { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + match self { + AccessLevel::Public => write!(f, "Public"), + AccessLevel::Restricted { role } => write!(f, "Restricted({})", role), + AccessLevel::Internal => write!(f, "Internal"), + AccessLevel::Special { kind } => write!(f, "Special({})", kind), + } + } +} + +impl AccessLevel { + pub fn short_label(&self) -> &str { + match self { + AccessLevel::Public => "P", + AccessLevel::Restricted { .. } => "R", + AccessLevel::Internal => "I", + AccessLevel::Special { .. } => "S", + } + } + + pub fn is_unrestricted(&self) -> bool { + matches!(self, AccessLevel::Public) + } +} diff --git a/crates/ilold-session-core/src/exploration/canvas.rs b/crates/ilold-session-core/src/exploration/canvas.rs new file mode 100644 index 0000000..a9d9306 --- /dev/null +++ b/crates/ilold-session-core/src/exploration/canvas.rs @@ -0,0 +1,13 @@ +use serde::{Deserialize, Serialize}; + +use super::access::AccessLevel; +use super::scenario::ScenarioEvent; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum CanvasPatch { + AddNode { scenario: String, function: String, access: AccessLevel, step_index: usize }, + RemoveLastNode { scenario: String }, + ClearAll { scenario: String }, + Highlight { scenario: String, function: String }, + ScenarioEvent(ScenarioEvent), +} diff --git a/crates/ilold-session-core/src/exploration/mod.rs b/crates/ilold-session-core/src/exploration/mod.rs index 1982598..85ea170 100644 --- a/crates/ilold-session-core/src/exploration/mod.rs +++ b/crates/ilold-session-core/src/exploration/mod.rs @@ -1,2 +1,5 @@ +pub mod access; pub mod assign_operator; +pub mod canvas; +pub mod scenario; pub mod session; diff --git a/crates/ilold-session-core/src/exploration/scenario.rs b/crates/ilold-session-core/src/exploration/scenario.rs new file mode 100644 index 0000000..bc732f0 --- /dev/null +++ b/crates/ilold-session-core/src/exploration/scenario.rs @@ -0,0 +1,48 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ScenarioAction { + New { name: String }, + List, + Switch { name: String }, + Fork { + name: String, + #[serde(default)] + at_step: Option, + }, + Delete { name: String }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ScenarioInfo { + pub name: String, + pub active: bool, + pub step_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ScenarioEvent { + Created { name: String }, + Switched { from: String, to: String }, + Deleted { name: String }, + Forked { from: String, to: String, at_step: usize }, + Reloaded { active: String }, +} + +pub fn validate_scenario_name(name: &str) -> Result<(), String> { + const ERR: &str = "Invalid scenario name: must match ^[a-z][a-z0-9_-]{0,31}$"; + if name.is_empty() || name.len() > 32 { + return Err(ERR.to_string()); + } + let mut chars = name.chars(); + let first = chars.next().ok_or_else(|| ERR.to_string())?; + if !first.is_ascii_lowercase() { + return Err(ERR.to_string()); + } + for c in chars { + if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') { + return Err(ERR.to_string()); + } + } + Ok(()) +} From f986590a586d1ed5ae42de2dd37f338fe2a6f09f Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 18:49:26 +0200 Subject: [PATCH 031/115] feat(solana-core): SolanaCommand and SolanaCommandResult types - enum SolanaCommand mirrors the Solidity REPL surface plus Solana-native ops: users, airdrop, time-warp, pda, inspect - SolanaCommandResult covers step lifecycle, journal entries and scenario events - canvas_patch_from_solana reuses ilold-session-core CanvasPatch with AccessLevel::Public default - shared subtypes (InstructionEntry, AccountSummary, UserEntry, PdaEntry) for the wire payloads --- .../src/exploration/commands.rs | 239 ++++++++++++++++++ .../ilold-solana-core/src/exploration/mod.rs | 5 + 2 files changed, 244 insertions(+) create mode 100644 crates/ilold-solana-core/src/exploration/commands.rs diff --git a/crates/ilold-solana-core/src/exploration/commands.rs b/crates/ilold-solana-core/src/exploration/commands.rs new file mode 100644 index 0000000..e6e5377 --- /dev/null +++ b/crates/ilold-solana-core/src/exploration/commands.rs @@ -0,0 +1,239 @@ +use std::collections::HashMap; + +use ilold_session_core::exploration::access::AccessLevel; +use ilold_session_core::exploration::canvas::CanvasPatch; +use ilold_session_core::exploration::scenario::{ScenarioAction, ScenarioEvent, ScenarioInfo}; +use ilold_session_core::journal::types::{ReviewStatus, Severity}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum SolanaCommand { + Call { + ix: String, + #[serde(default)] + args: Value, + #[serde(default)] + accounts: HashMap, + #[serde(default)] + signers: Vec, + }, + Back, + Clear, + Funcs, + State, + Session, + Users, + UsersNew { + name: String, + #[serde(default = "default_initial_lamports")] + lamports: u64, + }, + Airdrop { + user: String, + lamports: u64, + }, + TimeWarp { + delta_seconds: i64, + }, + Pda { + instruction: String, + }, + Inspect { + pubkey: String, + }, + Finding { + severity: Severity, + title: String, + description: String, + }, + Note { + text: String, + }, + Status { + ix: String, + status: ReviewStatus, + }, + SaveSession, + LoadSession { + json: String, + }, + Scenario { + sub: ScenarioAction, + }, +} + +fn default_initial_lamports() -> u64 { + 10_000_000_000 +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct InstructionEntry { + pub name: String, + pub args_count: usize, + pub accounts_count: usize, + pub has_pdas: bool, + pub signers: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountSummary { + pub label: String, + pub pubkey: String, + pub owner: String, + pub lamports: u64, + pub decoded: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserEntry { + pub name: String, + pub pubkey: String, + pub lamports: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PdaEntry { + pub account_name: String, + pub address: String, + pub bump: u8, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum SolanaCommandResult { + StepAdded { + step_index: usize, + instruction: String, + logs_excerpt: Vec, + account_diffs_count: usize, + compute_units: u64, + }, + StepRemoved { + remaining: usize, + }, + Cleared, + InstructionList { + items: Vec, + }, + StateView { + accounts: Vec, + }, + SessionView { + program: String, + scenario: String, + steps: Vec, + findings_count: usize, + }, + UserList { + users: Vec, + }, + UserCreated { + name: String, + pubkey: String, + lamports: u64, + }, + Airdropped { + name: String, + pubkey: String, + total_lamports: u64, + }, + TimeWarped { + unix_timestamp: i64, + slot: u64, + }, + PdaList { + instruction: String, + pdas: Vec, + }, + AccountInspected { + pubkey: String, + owner: String, + lamports: u64, + data_len: usize, + decoded: Option, + }, + FindingAdded { + id: String, + }, + NoteAdded, + StatusUpdated, + SessionSaved { + json: String, + }, + SessionLoaded { + program: String, + steps: Vec, + }, + ScenarioList { + items: Vec, + }, + ScenarioCreated { + name: String, + }, + ScenarioSwitched { + from: String, + to: String, + }, + ScenarioForked { + from: String, + to: String, + at_step: usize, + }, + ScenarioDeleted { + name: String, + }, + Error { + message: String, + }, +} + +pub fn canvas_patch_from_solana( + result: &SolanaCommandResult, + active_scenario: &str, +) -> Option { + match result { + SolanaCommandResult::StepAdded { instruction, step_index, .. } => { + Some(CanvasPatch::AddNode { + scenario: active_scenario.to_string(), + function: instruction.clone(), + access: AccessLevel::Public, + step_index: *step_index, + }) + } + SolanaCommandResult::StepRemoved { .. } => Some(CanvasPatch::RemoveLastNode { + scenario: active_scenario.to_string(), + }), + SolanaCommandResult::Cleared => Some(CanvasPatch::ClearAll { + scenario: active_scenario.to_string(), + }), + SolanaCommandResult::ScenarioCreated { name } => Some(CanvasPatch::ScenarioEvent( + ScenarioEvent::Created { name: name.clone() }, + )), + SolanaCommandResult::ScenarioSwitched { from, to } => { + if from == to { + None + } else { + Some(CanvasPatch::ScenarioEvent(ScenarioEvent::Switched { + from: from.clone(), + to: to.clone(), + })) + } + } + SolanaCommandResult::ScenarioDeleted { name } => Some(CanvasPatch::ScenarioEvent( + ScenarioEvent::Deleted { name: name.clone() }, + )), + SolanaCommandResult::ScenarioForked { from, to, at_step } => Some( + CanvasPatch::ScenarioEvent(ScenarioEvent::Forked { + from: from.clone(), + to: to.clone(), + at_step: *at_step, + }), + ), + SolanaCommandResult::SessionLoaded { .. } => Some(CanvasPatch::ScenarioEvent( + ScenarioEvent::Reloaded { + active: active_scenario.to_string(), + }, + )), + _ => None, + } +} diff --git a/crates/ilold-solana-core/src/exploration/mod.rs b/crates/ilold-solana-core/src/exploration/mod.rs index bf995c3..5221f54 100644 --- a/crates/ilold-solana-core/src/exploration/mod.rs +++ b/crates/ilold-solana-core/src/exploration/mod.rs @@ -1,3 +1,8 @@ pub mod add_step; +pub mod commands; pub use add_step::add_solana_step; +pub use commands::{ + canvas_patch_from_solana, AccountSummary, InstructionEntry, PdaEntry, SolanaCommand, + SolanaCommandResult, UserEntry, +}; From 665226c1fd85fe9e844370d0ad813c46510ad2e4 Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 18:52:36 +0200 Subject: [PATCH 032/115] feat(solana-core): execute read-only ops (funcs, state, session, users, pda, inspect) - helpers consume ProgramDef + VmHost + ExplorationSession + users registry without mutating the VM - state aggregates account diffs across steps and decodes via account_types - pda lists declared PDAs symbolically (seeds + program) since args are unknown until call time - inspect resolves a pubkey, decodes data when it matches an account discriminator --- .../src/exploration/commands.rs | 4 +- .../src/exploration/execute.rs | 218 ++++++++++++++++++ .../ilold-solana-core/src/exploration/mod.rs | 4 + 3 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 crates/ilold-solana-core/src/exploration/execute.rs diff --git a/crates/ilold-solana-core/src/exploration/commands.rs b/crates/ilold-solana-core/src/exploration/commands.rs index e6e5377..77e7561 100644 --- a/crates/ilold-solana-core/src/exploration/commands.rs +++ b/crates/ilold-solana-core/src/exploration/commands.rs @@ -95,8 +95,8 @@ pub struct UserEntry { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PdaEntry { pub account_name: String, - pub address: String, - pub bump: u8, + pub seeds: Vec, + pub program: String, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/ilold-solana-core/src/exploration/execute.rs b/crates/ilold-solana-core/src/exploration/execute.rs new file mode 100644 index 0000000..f5353d8 --- /dev/null +++ b/crates/ilold-solana-core/src/exploration/execute.rs @@ -0,0 +1,218 @@ +use std::collections::HashMap; + +use anchor_lang_idl::types::IdlTypeDefTy; +use ilold_session_core::exploration::session::ExplorationSession; +use solana_address::Address; +use solana_keypair::Keypair; +use solana_signer::Signer; + +use crate::decode::borsh::decode_defined_fields; +use crate::execute::VmHost; +use crate::model::{AccountTypeDef, ProgramDef, SeedSpec}; + +use super::commands::{ + AccountSummary, InstructionEntry, PdaEntry, SolanaCommandResult, UserEntry, +}; + +pub fn execute_funcs(program: &ProgramDef) -> SolanaCommandResult { + let items = program + .instructions + .iter() + .map(|ix| InstructionEntry { + name: ix.name.clone(), + args_count: ix.args.len(), + accounts_count: ix.accounts.len(), + has_pdas: ix.accounts.iter().any(|a| a.pda.is_some()), + signers: ix + .accounts + .iter() + .filter(|a| a.signer) + .map(|a| a.name.clone()) + .collect(), + }) + .collect(); + SolanaCommandResult::InstructionList { items } +} + +pub fn execute_users(users: &HashMap, vm: &VmHost) -> SolanaCommandResult { + let mut entries: Vec = users + .iter() + .map(|(name, kp)| { + let pk = kp.pubkey(); + UserEntry { + name: name.clone(), + pubkey: pk.to_string(), + lamports: vm.balance(&pk), + } + }) + .collect(); + entries.sort_by(|a, b| a.name.cmp(&b.name)); + SolanaCommandResult::UserList { users: entries } +} + +pub fn execute_state( + session: &ExplorationSession, + program: &ProgramDef, + vm: &VmHost, +) -> SolanaCommandResult { + let mut seen: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + + for (idx, step) in session.steps.iter().enumerate() { + let trace = match step.runtime_trace.as_ref() { + Some(t) => t, + None => continue, + }; + let diffs = trace.get("account_diffs").and_then(|v| v.as_array()); + let diffs = match diffs { + Some(d) => d, + None => continue, + }; + for diff in diffs { + let address = diff.get("address").and_then(|v| v.as_str()).unwrap_or(""); + if address.is_empty() { + continue; + } + let label = diff + .get("name") + .and_then(|v| v.as_str()) + .map(|n| format!("{n}#{idx}")) + .unwrap_or_else(|| format!("acc#{idx}")); + let pk: Address = match address.parse() { + Ok(p) => p, + Err(_) => continue, + }; + let acc = match vm.svm().get_account(&pk) { + Some(a) => a, + None => continue, + }; + let decoded = decode_account_bytes(&acc.data, &program.account_types, &program.types); + seen.insert( + address.to_string(), + AccountSummary { + label, + pubkey: address.to_string(), + owner: acc.owner.to_string(), + lamports: acc.lamports, + decoded, + }, + ); + } + } + + SolanaCommandResult::StateView { + accounts: seen.into_values().collect(), + } +} + +pub fn execute_session( + session: &ExplorationSession, + program: &ProgramDef, + active_scenario: &str, +) -> SolanaCommandResult { + SolanaCommandResult::SessionView { + program: program.name.clone(), + scenario: active_scenario.to_string(), + steps: session.steps.iter().map(|s| s.function.clone()).collect(), + findings_count: session.journal.findings.len(), + } +} + +pub fn execute_pda(program: &ProgramDef, instruction: &str) -> SolanaCommandResult { + let ix = match program.instructions.iter().find(|i| i.name == instruction) { + Some(i) => i, + None => { + return SolanaCommandResult::Error { + message: format!("instruction '{instruction}' not found"), + }; + } + }; + + let pdas: Vec = ix + .accounts + .iter() + .filter_map(|spec| { + let pda_spec = spec.pda.as_ref()?; + let seeds = pda_spec.seeds.iter().map(describe_seed).collect(); + let prog = match &pda_spec.program { + None => program.program_id.to_string(), + Some(SeedSpec::Const { value }) => Address::try_from(value.as_slice()) + .map(|a| a.to_string()) + .unwrap_or_else(|_| format!("const:{:02x?}", value)), + Some(SeedSpec::Account { path }) => format!("account:{path}"), + Some(SeedSpec::Arg { path, .. }) => format!("arg:{path}"), + }; + Some(PdaEntry { + account_name: spec.name.clone(), + seeds, + program: prog, + }) + }) + .collect(); + + SolanaCommandResult::PdaList { + instruction: instruction.to_string(), + pdas, + } +} + +pub fn execute_inspect( + program: &ProgramDef, + vm: &VmHost, + pubkey: &str, +) -> SolanaCommandResult { + let pk: Address = match pubkey.parse() { + Ok(p) => p, + Err(_) => { + return SolanaCommandResult::Error { + message: format!("invalid pubkey '{pubkey}'"), + }; + } + }; + let acc = match vm.svm().get_account(&pk) { + Some(a) => a, + None => { + return SolanaCommandResult::Error { + message: format!("account '{pubkey}' not found in VM"), + }; + } + }; + let decoded = decode_account_bytes(&acc.data, &program.account_types, &program.types); + SolanaCommandResult::AccountInspected { + pubkey: pubkey.to_string(), + owner: acc.owner.to_string(), + lamports: acc.lamports, + data_len: acc.data.len(), + decoded, + } +} + +fn describe_seed(seed: &SeedSpec) -> String { + match seed { + SeedSpec::Const { value } => match std::str::from_utf8(value) { + Ok(s) if s.chars().all(|c| c.is_ascii_graphic() || c == ' ') => format!("const:'{s}'"), + _ => format!("const:{:02x?}", value), + }, + SeedSpec::Account { path } => format!("account:{path}"), + SeedSpec::Arg { path, .. } => format!("arg:{path}"), + } +} + +fn decode_account_bytes( + data: &[u8], + account_types: &[AccountTypeDef], + types: &[anchor_lang_idl::types::IdlTypeDef], +) -> Option { + if data.len() < 8 { + return None; + } + let (disc, rest) = data.split_at(8); + let acc_def = account_types.iter().find(|a| a.discriminator == disc)?; + let mut cursor = rest; + match &acc_def.layout.ty { + IdlTypeDefTy::Struct { fields } => { + decode_defined_fields(&mut cursor, fields.as_ref(), types).ok() + } + _ => None, + } +} diff --git a/crates/ilold-solana-core/src/exploration/mod.rs b/crates/ilold-solana-core/src/exploration/mod.rs index 5221f54..7322c31 100644 --- a/crates/ilold-solana-core/src/exploration/mod.rs +++ b/crates/ilold-solana-core/src/exploration/mod.rs @@ -1,8 +1,12 @@ pub mod add_step; pub mod commands; +pub mod execute; pub use add_step::add_solana_step; pub use commands::{ canvas_patch_from_solana, AccountSummary, InstructionEntry, PdaEntry, SolanaCommand, SolanaCommandResult, UserEntry, }; +pub use execute::{ + execute_funcs, execute_inspect, execute_pda, execute_session, execute_state, execute_users, +}; From 6e7b83d780e122474e17a35cc58275c8f77928b0 Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 18:52:37 +0200 Subject: [PATCH 033/115] test(solana-core): read-only command helpers - funcs over lever fixture asserts both instructions and the multi-signer set on initialize - pda over relations fixture proves symbolic listing returns seeds and program - error path for unknown instruction - session view on a fresh ExplorationSession --- .../tests/execute_readonly.rs | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 crates/ilold-solana-core/tests/execute_readonly.rs diff --git a/crates/ilold-solana-core/tests/execute_readonly.rs b/crates/ilold-solana-core/tests/execute_readonly.rs new file mode 100644 index 0000000..85c8857 --- /dev/null +++ b/crates/ilold-solana-core/tests/execute_readonly.rs @@ -0,0 +1,99 @@ +use ilold_session_core::exploration::session::ExplorationSession; +use ilold_solana_core::exploration::{ + execute_funcs, execute_pda, execute_session, SolanaCommandResult, +}; +use ilold_solana_core::idl::parse_idl; +use ilold_solana_core::model::ProgramDef; + +const LEVER_JSON: &str = include_str!("fixtures/lever.json"); +const RELATIONS_JSON: &str = include_str!("fixtures/relations.json"); + +fn lever_program() -> ProgramDef { + let idl = parse_idl(LEVER_JSON).expect("parse lever"); + ProgramDef::from_idl(idl).expect("build lever ProgramDef") +} + +fn relations_program() -> ProgramDef { + let idl = parse_idl(RELATIONS_JSON).expect("parse relations"); + ProgramDef::from_idl(idl).expect("build relations ProgramDef") +} + +#[test] +fn funcs_lists_lever_instructions() { + let program = lever_program(); + let result = execute_funcs(&program); + let items = match result { + SolanaCommandResult::InstructionList { items } => items, + other => panic!("expected InstructionList, got {other:?}"), + }; + assert_eq!(items.len(), 2); + let names: Vec<_> = items.iter().map(|i| i.name.as_str()).collect(); + assert!(names.contains(&"initialize")); + assert!(names.contains(&"switch_power")); + + let init = items.iter().find(|i| i.name == "initialize").unwrap(); + assert!(init.signers.iter().any(|s| s == "user")); + assert!(init.signers.iter().any(|s| s == "power")); + assert_eq!(init.has_pdas, false); +} + +#[test] +fn pda_lists_relations_pdas_symbolically() { + let program = relations_program(); + let with_pdas: Vec<_> = program + .instructions + .iter() + .find(|ix| ix.accounts.iter().any(|a| a.pda.is_some())) + .into_iter() + .collect(); + let probe = with_pdas + .first() + .expect("relations IDL should declare at least one ix with PDAs"); + + let result = execute_pda(&program, &probe.name); + let pdas = match result { + SolanaCommandResult::PdaList { pdas, .. } => pdas, + other => panic!("expected PdaList, got {other:?}"), + }; + assert!(!pdas.is_empty(), "expected at least one PDA in {}", probe.name); + let entry = &pdas[0]; + assert!(!entry.account_name.is_empty()); + assert!(!entry.seeds.is_empty(), "PDAs must have seeds"); + assert!( + !entry.program.is_empty(), + "PDA program must resolve to a string" + ); +} + +#[test] +fn pda_unknown_instruction_returns_error() { + let program = lever_program(); + let result = execute_pda(&program, "does_not_exist"); + match result { + SolanaCommandResult::Error { message } => { + assert!(message.contains("does_not_exist")); + } + other => panic!("expected Error, got {other:?}"), + } +} + +#[test] +fn session_view_reports_empty_on_fresh_session() { + let program = lever_program(); + let session = ExplorationSession::new(&program.name, "ilold"); + let result = execute_session(&session, &program, "main"); + match result { + SolanaCommandResult::SessionView { + program: prog, + scenario, + steps, + findings_count, + } => { + assert_eq!(prog, program.name); + assert_eq!(scenario, "main"); + assert!(steps.is_empty()); + assert_eq!(findings_count, 0); + } + other => panic!("expected SessionView, got {other:?}"), + } +} From 97b2aeb4bf3addf60cd287a6271a8a41c5740af6 Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 18:56:40 +0200 Subject: [PATCH 034/115] feat(solana-core): builder multi-signer + execute call/back/clear - build_transaction now accepts extra_signers and dedupes against the payer - add_solana_step forwards them to build_transaction unchanged - execute_call resolves account values against the users registry, validates that signer accounts have a matching keypair, and returns a StepAdded with logs and diff counts - execute_back undoes the last step or reports no steps to undo; execute_clear empties the session --- .../ilold-solana-core/src/execute/builder.rs | 10 +- .../src/exploration/add_step.rs | 4 +- .../src/exploration/execute.rs | 143 ++++++++++++++++++ .../ilold-solana-core/src/exploration/mod.rs | 3 +- crates/ilold-solana-core/tests/e2e_lever.rs | 1 + 5 files changed, 158 insertions(+), 3 deletions(-) diff --git a/crates/ilold-solana-core/src/execute/builder.rs b/crates/ilold-solana-core/src/execute/builder.rs index c3a6b11..14508dd 100644 --- a/crates/ilold-solana-core/src/execute/builder.rs +++ b/crates/ilold-solana-core/src/execute/builder.rs @@ -76,9 +76,17 @@ pub fn build_instruction( pub fn build_transaction( ix: Instruction, payer: &Keypair, + extra_signers: &[&Keypair], blockhash: Hash, ) -> Result { let msg = Message::new_with_blockhash(&[ix], Some(&payer.pubkey()), &blockhash); - VersionedTransaction::try_new(VersionedMessage::Legacy(msg), &[payer]) + let payer_pk = payer.pubkey(); + let mut signers: Vec<&Keypair> = vec![payer]; + for kp in extra_signers { + if kp.pubkey() != payer_pk && !signers.iter().any(|s| s.pubkey() == kp.pubkey()) { + signers.push(*kp); + } + } + VersionedTransaction::try_new(VersionedMessage::Legacy(msg), &signers) .map_err(|e| SolanaError::EncodeFailed(format!("transaction sign: {e:?}"))) } diff --git a/crates/ilold-solana-core/src/exploration/add_step.rs b/crates/ilold-solana-core/src/exploration/add_step.rs index 2877f57..bf1d32a 100644 --- a/crates/ilold-solana-core/src/exploration/add_step.rs +++ b/crates/ilold-solana-core/src/exploration/add_step.rs @@ -9,6 +9,7 @@ use ilold_session_core::runtime_trace::{AccountDiff, RuntimeTrace}; use serde_json::Value; use solana_account::{Account, ReadableAccount}; use solana_address::Address; +use solana_keypair::Keypair; use crate::error::SolanaError; use crate::execute::{build_instruction, build_transaction, VmHost}; @@ -22,6 +23,7 @@ pub fn add_solana_step<'a>( vm: &mut VmHost, args: Value, accounts: HashMap, + extra_signers: &[&Keypair], timestamp: &str, ) -> Result<&'a ExplorationStep, SolanaError> { let step_index = session.steps.len(); @@ -37,7 +39,7 @@ pub fn add_solana_step<'a>( .collect(); let blockhash = vm.svm().latest_blockhash(); - let tx = build_transaction(instruction.clone(), vm.payer(), blockhash)?; + let tx = build_transaction(instruction.clone(), vm.payer(), extra_signers, blockhash)?; let result = vm.svm_mut().send_transaction(tx); let (runtime_trace, mutations) = match result { diff --git a/crates/ilold-solana-core/src/exploration/execute.rs b/crates/ilold-solana-core/src/exploration/execute.rs index f5353d8..a31837c 100644 --- a/crates/ilold-solana-core/src/exploration/execute.rs +++ b/crates/ilold-solana-core/src/exploration/execute.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use anchor_lang_idl::types::IdlTypeDefTy; use ilold_session_core::exploration::session::ExplorationSession; +use serde_json::Value; use solana_address::Address; use solana_keypair::Keypair; use solana_signer::Signer; @@ -10,6 +11,7 @@ use crate::decode::borsh::decode_defined_fields; use crate::execute::VmHost; use crate::model::{AccountTypeDef, ProgramDef, SeedSpec}; +use super::add_step::add_solana_step; use super::commands::{ AccountSummary, InstructionEntry, PdaEntry, SolanaCommandResult, UserEntry, }; @@ -187,6 +189,147 @@ pub fn execute_inspect( } } +#[allow(clippy::too_many_arguments)] +pub fn execute_call( + program: &ProgramDef, + ix_name: &str, + args: Value, + accounts_input: HashMap, + signer_names: Vec, + users: &HashMap, + session: &mut ExplorationSession, + vm: &mut VmHost, + timestamp: &str, +) -> SolanaCommandResult { + let ix = match program.instructions.iter().find(|i| i.name == ix_name) { + Some(i) => i.clone(), + None => { + return SolanaCommandResult::Error { + message: format!("instruction '{ix_name}' not found"), + }; + } + }; + + let mut accounts: HashMap = HashMap::new(); + for (key, raw) in accounts_input { + if let Some(kp) = users.get(&raw) { + accounts.insert(key, kp.pubkey()); + continue; + } + match raw.parse::

() { + Ok(addr) => { + accounts.insert(key, addr); + } + Err(_) => { + return SolanaCommandResult::Error { + message: format!( + "account '{key}': '{raw}' is neither a known user nor a valid pubkey" + ), + }; + } + } + } + + let mut extra_signers: Vec<&Keypair> = Vec::new(); + for name in &signer_names { + match users.get(name) { + Some(kp) => extra_signers.push(kp), + None => { + return SolanaCommandResult::Error { + message: format!("signer '{name}' not found in users registry"), + }; + } + } + } + + let payer_pk = vm.payer_pubkey(); + for spec in &ix.accounts { + if !spec.signer { + continue; + } + let resolved_pk = accounts + .get(&spec.path) + .or_else(|| accounts.get(&spec.name)) + .copied(); + let pk = match resolved_pk { + Some(p) => p, + None => continue, + }; + let in_signers = extra_signers.iter().any(|kp| kp.pubkey() == pk); + if pk != payer_pk && !in_signers { + return SolanaCommandResult::Error { + message: format!( + "account '{}' is marked signer but no matching keypair was provided", + spec.name + ), + }; + } + } + + if let Err(e) = add_solana_step( + session, + program, + &ix, + vm, + args, + accounts, + &extra_signers, + timestamp, + ) { + return SolanaCommandResult::Error { + message: format!("{e:?}"), + }; + } + + let step_index = session.steps.len() - 1; + let step = session.steps.last().expect("step pushed"); + let trace = step.runtime_trace.clone().unwrap_or(Value::Null); + let logs_excerpt: Vec = trace + .get("logs") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .take(10) + .collect() + }) + .unwrap_or_default(); + let account_diffs_count = trace + .get("account_diffs") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + let compute_units = trace + .get("compute_units") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + + SolanaCommandResult::StepAdded { + step_index, + instruction: step.function.clone(), + logs_excerpt, + account_diffs_count, + compute_units, + } +} + +pub fn execute_back(session: &mut ExplorationSession) -> SolanaCommandResult { + if session.remove_last_step() { + SolanaCommandResult::StepRemoved { + remaining: session.steps.len(), + } + } else { + SolanaCommandResult::Error { + message: "no steps to undo".into(), + } + } +} + +pub fn execute_clear(session: &mut ExplorationSession) -> SolanaCommandResult { + session.clear(); + SolanaCommandResult::Cleared +} + fn describe_seed(seed: &SeedSpec) -> String { match seed { SeedSpec::Const { value } => match std::str::from_utf8(value) { diff --git a/crates/ilold-solana-core/src/exploration/mod.rs b/crates/ilold-solana-core/src/exploration/mod.rs index 7322c31..20e524d 100644 --- a/crates/ilold-solana-core/src/exploration/mod.rs +++ b/crates/ilold-solana-core/src/exploration/mod.rs @@ -8,5 +8,6 @@ pub use commands::{ SolanaCommandResult, UserEntry, }; pub use execute::{ - execute_funcs, execute_inspect, execute_pda, execute_session, execute_state, execute_users, + execute_back, execute_call, execute_clear, execute_funcs, execute_inspect, execute_pda, + execute_session, execute_state, execute_users, }; diff --git a/crates/ilold-solana-core/tests/e2e_lever.rs b/crates/ilold-solana-core/tests/e2e_lever.rs index 3656c41..5df80fd 100644 --- a/crates/ilold-solana-core/tests/e2e_lever.rs +++ b/crates/ilold-solana-core/tests/e2e_lever.rs @@ -70,6 +70,7 @@ fn add_solana_step_runs_switch_power_against_real_program() { &mut vm, serde_json::json!({"name": "claude"}), accounts, + &[], "2026-05-06T00:00:00Z", ) .expect("add_solana_step"); From 206e270ea831ddafec779a433ab532050160218f Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 18:56:57 +0200 Subject: [PATCH 035/115] test(solana-core): execute_call multi-signer + back + clear - initialize covers the full Anchor init flow with admin and power keypairs as multi-signer payers; switch_power runs after and asserts the lever log line - back undoes both steps and then reports no steps to undo; clear no-ops on empty session - error paths cover unknown account user values and missing signer keypairs --- .../ilold-solana-core/tests/execute_call.rs | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 crates/ilold-solana-core/tests/execute_call.rs diff --git a/crates/ilold-solana-core/tests/execute_call.rs b/crates/ilold-solana-core/tests/execute_call.rs new file mode 100644 index 0000000..8f4f309 --- /dev/null +++ b/crates/ilold-solana-core/tests/execute_call.rs @@ -0,0 +1,174 @@ +use std::collections::HashMap; + +use ilold_session_core::exploration::session::ExplorationSession; +use ilold_solana_core::execute::VmHost; +use ilold_solana_core::exploration::{ + execute_back, execute_call, execute_clear, SolanaCommandResult, +}; +use ilold_solana_core::idl::parse_idl; +use ilold_solana_core::model::ProgramDef; +use solana_keypair::Keypair; +use solana_signer::Signer; + +const LEVER_JSON: &str = include_str!("fixtures/lever.json"); + +fn read_lever_so() -> Vec { + let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/programs/lever.so"); + std::fs::read(&path).expect("lever.so missing") +} + +#[test] +fn execute_call_initialize_then_switch_power() { + let idl = parse_idl(LEVER_JSON).unwrap(); + let program = ProgramDef::from_idl(idl).unwrap(); + let mut vm = VmHost::boot(vec![(program.program_id, read_lever_so())]).unwrap(); + + let admin = Keypair::new(); + let power = Keypair::new(); + vm.svm_mut().airdrop(&admin.pubkey(), 10_000_000_000).unwrap(); + vm.svm_mut().airdrop(&power.pubkey(), 10_000_000_000).unwrap(); + + let mut users = HashMap::new(); + users.insert("admin".into(), admin); + users.insert("power".into(), power); + + let mut session = ExplorationSession::new(&program.name, "ilold"); + + let mut accs = HashMap::new(); + accs.insert("power".into(), "power".to_string()); + accs.insert("user".into(), "admin".to_string()); + accs.insert( + "system_program".into(), + "11111111111111111111111111111111".to_string(), + ); + + let init = execute_call( + &program, + "initialize", + serde_json::json!({}), + accs, + vec!["admin".into(), "power".into()], + &users, + &mut session, + &mut vm, + "2026-05-06T00:00:00Z", + ); + let step_index = match init { + SolanaCommandResult::StepAdded { step_index, instruction, account_diffs_count, .. } => { + assert_eq!(instruction, "initialize"); + assert!(account_diffs_count >= 1, "init should mutate at least power account"); + step_index + } + other => panic!("expected StepAdded for initialize, got {other:?}"), + }; + assert_eq!(step_index, 0); + + let mut accs2 = HashMap::new(); + accs2.insert("power".into(), "power".to_string()); + + let switch = execute_call( + &program, + "switch_power", + serde_json::json!({"name": "claude"}), + accs2, + vec![], + &users, + &mut session, + &mut vm, + "2026-05-06T00:00:01Z", + ); + match switch { + SolanaCommandResult::StepAdded { logs_excerpt, .. } => { + let joined = logs_excerpt.join("\n"); + assert!( + joined.contains("pulling the power switch"), + "expected lever log line, got:\n{joined}" + ); + } + other => panic!("expected StepAdded for switch_power, got {other:?}"), + } + + assert!(matches!( + execute_back(&mut session), + SolanaCommandResult::StepRemoved { remaining: 1 } + )); + assert!(matches!( + execute_back(&mut session), + SolanaCommandResult::StepRemoved { remaining: 0 } + )); + assert!(matches!( + execute_back(&mut session), + SolanaCommandResult::Error { .. } + )); + + assert!(matches!(execute_clear(&mut session), SolanaCommandResult::Cleared)); +} + +#[test] +fn execute_call_rejects_unknown_user_in_account() { + let idl = parse_idl(LEVER_JSON).unwrap(); + let program = ProgramDef::from_idl(idl).unwrap(); + let mut vm = VmHost::boot(vec![(program.program_id, read_lever_so())]).unwrap(); + let users: HashMap = HashMap::new(); + let mut session = ExplorationSession::new(&program.name, "ilold"); + + let mut accs = HashMap::new(); + accs.insert("power".into(), "ghost_user".to_string()); + + let result = execute_call( + &program, + "switch_power", + serde_json::json!({"name": "x"}), + accs, + vec![], + &users, + &mut session, + &mut vm, + "2026-05-06T00:00:00Z", + ); + match result { + SolanaCommandResult::Error { message } => { + assert!(message.contains("ghost_user"), "got: {message}"); + } + other => panic!("expected Error, got {other:?}"), + } +} + +#[test] +fn execute_call_rejects_missing_signer_keypair() { + let idl = parse_idl(LEVER_JSON).unwrap(); + let program = ProgramDef::from_idl(idl).unwrap(); + let mut vm = VmHost::boot(vec![(program.program_id, read_lever_so())]).unwrap(); + let users: HashMap = HashMap::new(); + let mut session = ExplorationSession::new(&program.name, "ilold"); + + let mut accs = HashMap::new(); + accs.insert("power".into(), Keypair::new().pubkey().to_string()); + accs.insert("user".into(), Keypair::new().pubkey().to_string()); + accs.insert( + "system_program".into(), + "11111111111111111111111111111111".to_string(), + ); + + let result = execute_call( + &program, + "initialize", + serde_json::json!({}), + accs, + vec![], + &users, + &mut session, + &mut vm, + "2026-05-06T00:00:00Z", + ); + match result { + SolanaCommandResult::Error { message } => { + assert!( + message.contains("signer"), + "expected signer error, got: {message}" + ); + } + other => panic!("expected Error, got {other:?}"), + } +} From 617fb47f532c32a40615527ded43000e34fba309 Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 19:03:29 +0200 Subject: [PATCH 036/115] feat(solana-core): execute users-new, airdrop, time-warp, finding, note, status - users_new generates a Keypair and airdrops the requested lamports, rejecting duplicates - airdrop tops up an existing user; time_warp advances Clock unix_timestamp and slot - finding writes through Journal::add_finding with the active sequence as affected_sequence - note records a NoteAdded with sequence-derived anchor - status validates the instruction exists in ProgramDef before recording StatusChanged --- .../src/exploration/execute.rs | 149 ++++++++++++++++++ .../ilold-solana-core/src/exploration/mod.rs | 5 +- 2 files changed, 152 insertions(+), 2 deletions(-) diff --git a/crates/ilold-solana-core/src/exploration/execute.rs b/crates/ilold-solana-core/src/exploration/execute.rs index a31837c..a5014e6 100644 --- a/crates/ilold-solana-core/src/exploration/execute.rs +++ b/crates/ilold-solana-core/src/exploration/execute.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use anchor_lang_idl::types::IdlTypeDefTy; use ilold_session_core::exploration::session::ExplorationSession; +use ilold_session_core::journal::types::{Finding, JournalEntry, ReviewStatus, Severity}; use serde_json::Value; use solana_address::Address; use solana_keypair::Keypair; @@ -16,6 +17,8 @@ use super::commands::{ AccountSummary, InstructionEntry, PdaEntry, SolanaCommandResult, UserEntry, }; +const DEFAULT_USER_LAMPORTS: u64 = 10_000_000_000; + pub fn execute_funcs(program: &ProgramDef) -> SolanaCommandResult { let items = program .instructions @@ -330,6 +333,152 @@ pub fn execute_clear(session: &mut ExplorationSession) -> SolanaCommandResult { SolanaCommandResult::Cleared } +pub fn execute_users_new( + name: String, + lamports: u64, + users: &mut HashMap, + vm: &mut VmHost, +) -> SolanaCommandResult { + if users.contains_key(&name) { + return SolanaCommandResult::Error { + message: format!("user '{name}' already exists"), + }; + } + let kp = Keypair::new(); + let pk = kp.pubkey(); + let funded = if lamports == 0 { + DEFAULT_USER_LAMPORTS + } else { + lamports + }; + if let Err(e) = vm.airdrop(pk, funded) { + return SolanaCommandResult::Error { + message: format!("airdrop failed: {e:?}"), + }; + } + users.insert(name.clone(), kp); + SolanaCommandResult::UserCreated { + name, + pubkey: pk.to_string(), + lamports: funded, + } +} + +pub fn execute_airdrop( + user: &str, + lamports: u64, + users: &HashMap, + vm: &mut VmHost, +) -> SolanaCommandResult { + let kp = match users.get(user) { + Some(k) => k, + None => { + return SolanaCommandResult::Error { + message: format!("user '{user}' not found"), + }; + } + }; + let pk = kp.pubkey(); + if let Err(e) = vm.airdrop(pk, lamports) { + return SolanaCommandResult::Error { + message: format!("airdrop failed: {e:?}"), + }; + } + SolanaCommandResult::Airdropped { + name: user.to_string(), + pubkey: pk.to_string(), + total_lamports: vm.balance(&pk), + } +} + +pub fn execute_time_warp(delta_seconds: i64, vm: &mut VmHost) -> SolanaCommandResult { + let clock = vm.clock(); + let new_ts = clock.unix_timestamp.saturating_add(delta_seconds); + let slot_advance = delta_seconds.max(0) as u64; + let new_slot = clock.slot.saturating_add(slot_advance); + vm.warp_clock(new_slot, new_ts); + SolanaCommandResult::TimeWarped { + unix_timestamp: new_ts, + slot: new_slot, + } +} + +pub fn execute_finding( + session: &mut ExplorationSession, + severity: Severity, + title: String, + description: String, + timestamp: &str, +) -> SolanaCommandResult { + let affected_sequence = if session.steps.is_empty() { + None + } else { + Some( + session + .current_sequence() + .into_iter() + .map(|s| s.to_string()) + .collect(), + ) + }; + let finding = Finding { + id: String::new(), + severity, + title, + affected_function: session + .steps + .last() + .map(|s| s.function.clone()) + .unwrap_or_default(), + affected_sequence, + description, + notes: vec![], + created_at: String::new(), + }; + session.journal.add_finding(finding, timestamp); + let id = session + .journal + .findings + .last() + .map(|f| f.id.clone()) + .unwrap_or_default(); + SolanaCommandResult::FindingAdded { id } +} + +pub fn execute_note( + session: &mut ExplorationSession, + text: &str, + timestamp: &str, +) -> SolanaCommandResult { + let anchor = session.current_sequence().join(" → "); + session.journal.record(JournalEntry::NoteAdded { + anchor, + content: text.into(), + timestamp: timestamp.into(), + }); + SolanaCommandResult::NoteAdded +} + +pub fn execute_status( + session: &mut ExplorationSession, + program: &ProgramDef, + ix_name: &str, + status: ReviewStatus, + timestamp: &str, +) -> SolanaCommandResult { + if !program.instructions.iter().any(|i| i.name == ix_name) { + return SolanaCommandResult::Error { + message: format!("instruction '{ix_name}' not found in program '{}'", program.name), + }; + } + session.journal.record(JournalEntry::StatusChanged { + function: ix_name.into(), + status, + timestamp: timestamp.into(), + }); + SolanaCommandResult::StatusUpdated +} + fn describe_seed(seed: &SeedSpec) -> String { match seed { SeedSpec::Const { value } => match std::str::from_utf8(value) { diff --git a/crates/ilold-solana-core/src/exploration/mod.rs b/crates/ilold-solana-core/src/exploration/mod.rs index 20e524d..5b38e2a 100644 --- a/crates/ilold-solana-core/src/exploration/mod.rs +++ b/crates/ilold-solana-core/src/exploration/mod.rs @@ -8,6 +8,7 @@ pub use commands::{ SolanaCommandResult, UserEntry, }; pub use execute::{ - execute_back, execute_call, execute_clear, execute_funcs, execute_inspect, execute_pda, - execute_session, execute_state, execute_users, + execute_airdrop, execute_back, execute_call, execute_clear, execute_finding, execute_funcs, + execute_inspect, execute_note, execute_pda, execute_session, execute_state, execute_status, + execute_time_warp, execute_users, execute_users_new, }; From 601150082d6bfa11ac3ebba8ca8b8ba5ecd55645 Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 19:03:30 +0200 Subject: [PATCH 037/115] test(solana-core): users, airdrop, time-warp, journal commands - users_new + duplicate detection + listing with balances - airdrop unknown user error and known user balance accumulation - time_warp advances Clock past +86400 seconds preserving slot delta - finding/note/status round-trip through ExplorationSession journal --- .../tests/execute_state_ops.rs | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 crates/ilold-solana-core/tests/execute_state_ops.rs diff --git a/crates/ilold-solana-core/tests/execute_state_ops.rs b/crates/ilold-solana-core/tests/execute_state_ops.rs new file mode 100644 index 0000000..6806975 --- /dev/null +++ b/crates/ilold-solana-core/tests/execute_state_ops.rs @@ -0,0 +1,149 @@ +use std::collections::HashMap; + +use ilold_session_core::exploration::session::ExplorationSession; +use ilold_session_core::journal::types::{ReviewStatus, Severity}; +use ilold_solana_core::execute::VmHost; +use ilold_solana_core::exploration::{ + execute_airdrop, execute_finding, execute_note, execute_status, execute_time_warp, + execute_users, execute_users_new, SolanaCommandResult, +}; +use ilold_solana_core::idl::parse_idl; +use ilold_solana_core::model::ProgramDef; +use solana_keypair::Keypair; + +const LEVER_JSON: &str = include_str!("fixtures/lever.json"); + +fn empty_vm() -> VmHost { + VmHost::boot(Vec::new()).expect("boot empty vm") +} + +fn lever_program() -> ProgramDef { + let idl = parse_idl(LEVER_JSON).unwrap(); + ProgramDef::from_idl(idl).unwrap() +} + +#[test] +fn users_new_generates_keypair_and_airdrops() { + let mut vm = empty_vm(); + let mut users = HashMap::::new(); + + let result = execute_users_new("alice".into(), 5_000_000_000, &mut users, &mut vm); + match result { + SolanaCommandResult::UserCreated { name, pubkey, lamports } => { + assert_eq!(name, "alice"); + assert!(!pubkey.is_empty()); + assert_eq!(lamports, 5_000_000_000); + } + other => panic!("expected UserCreated, got {other:?}"), + } + assert!(users.contains_key("alice")); + + let dup = execute_users_new("alice".into(), 0, &mut users, &mut vm); + assert!(matches!(dup, SolanaCommandResult::Error { .. })); +} + +#[test] +fn users_list_shows_balances() { + let mut vm = empty_vm(); + let mut users = HashMap::::new(); + execute_users_new("bob".into(), 2_000_000_000, &mut users, &mut vm); + execute_users_new("admin".into(), 8_000_000_000, &mut users, &mut vm); + + match execute_users(&users, &vm) { + SolanaCommandResult::UserList { users: list } => { + assert_eq!(list.len(), 2); + let admin = list.iter().find(|u| u.name == "admin").unwrap(); + assert_eq!(admin.lamports, 8_000_000_000); + let bob = list.iter().find(|u| u.name == "bob").unwrap(); + assert_eq!(bob.lamports, 2_000_000_000); + } + other => panic!("expected UserList, got {other:?}"), + } +} + +#[test] +fn airdrop_unknown_user_errors() { + let mut vm = empty_vm(); + let users = HashMap::::new(); + let result = execute_airdrop("ghost", 1_000, &users, &mut vm); + assert!(matches!(result, SolanaCommandResult::Error { .. })); +} + +#[test] +fn airdrop_known_user_increases_balance() { + let mut vm = empty_vm(); + let mut users = HashMap::::new(); + execute_users_new("bob".into(), 1_000_000_000, &mut users, &mut vm); + + match execute_airdrop("bob", 500_000_000, &users, &mut vm) { + SolanaCommandResult::Airdropped { name, total_lamports, .. } => { + assert_eq!(name, "bob"); + assert_eq!(total_lamports, 1_500_000_000); + } + other => panic!("expected Airdropped, got {other:?}"), + } +} + +#[test] +fn time_warp_advances_clock() { + let mut vm = empty_vm(); + let before = vm.clock(); + let result = execute_time_warp(86_400, &mut vm); + match result { + SolanaCommandResult::TimeWarped { unix_timestamp, slot } => { + assert_eq!(unix_timestamp, before.unix_timestamp + 86_400); + assert_eq!(slot, before.slot + 86_400); + } + other => panic!("expected TimeWarped, got {other:?}"), + } + assert_eq!(vm.clock().unix_timestamp, before.unix_timestamp + 86_400); +} + +#[test] +fn finding_and_note_and_status_record_journal() { + let program = lever_program(); + let mut session = ExplorationSession::new(&program.name, "ilold"); + + match execute_finding( + &mut session, + Severity::High, + "missing signer check".into(), + "switch_power should require admin".into(), + "2026-05-06T00:00:00Z", + ) { + SolanaCommandResult::FindingAdded { id } => assert!(!id.is_empty()), + other => panic!("expected FindingAdded, got {other:?}"), + } + assert_eq!(session.journal.findings.len(), 1); + + match execute_note(&mut session, "looking at switch_power", "2026-05-06T00:00:01Z") { + SolanaCommandResult::NoteAdded => {} + other => panic!("expected NoteAdded, got {other:?}"), + } + + match execute_status( + &mut session, + &program, + "switch_power", + ReviewStatus::Reviewed, + "2026-05-06T00:00:02Z", + ) { + SolanaCommandResult::StatusUpdated => {} + other => panic!("expected StatusUpdated, got {other:?}"), + } + assert_eq!( + session.journal.function_status.get("switch_power"), + Some(&ReviewStatus::Reviewed) + ); + + match execute_status( + &mut session, + &program, + "ghost_ix", + ReviewStatus::Reviewed, + "2026-05-06T00:00:03Z", + ) { + SolanaCommandResult::Error { message } => assert!(message.contains("ghost_ix")), + other => panic!("expected Error, got {other:?}"), + } +} From 8a8585d2818568db567c779414ae9a55a1f983a6 Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 19:18:36 +0200 Subject: [PATCH 038/115] feat(web): SolanaState owns vms-per-scenario, users registry and program artifacts - SolanaState carries program_artifacts, vms locked map and users locked map keyed by scenario name - AppState::from_solana boots the main VmHost from artifacts and seeds an empty users map - serve_solana and start_solana_server load .so bytes by matching file stem to program name and propagate them --- crates/ilold-web/Cargo.toml | 3 +++ crates/ilold-web/src/lib.rs | 44 ++++++++++++++++++++++++++++++++--- crates/ilold-web/src/state.rs | 29 +++++++++++++++++++---- 3 files changed, 69 insertions(+), 7 deletions(-) diff --git a/crates/ilold-web/Cargo.toml b/crates/ilold-web/Cargo.toml index 4684421..4650386 100644 --- a/crates/ilold-web/Cargo.toml +++ b/crates/ilold-web/Cargo.toml @@ -6,7 +6,10 @@ description = "Web server and interactive viewer for Ilold" [dependencies] ilold-core = { path = "../ilold-core" } +ilold-session-core = { path = "../ilold-session-core" } ilold-solana-core = { path = "../ilold-solana-core" } +solana-address = { version = "2.0", features = ["serde", "decode"] } +solana-keypair = "3.1" axum = { workspace = true } tokio = { workspace = true } tower-http = { workspace = true } diff --git a/crates/ilold-web/src/lib.rs b/crates/ilold-web/src/lib.rs index 10c48b3..3895f07 100644 --- a/crates/ilold-web/src/lib.rs +++ b/crates/ilold-web/src/lib.rs @@ -87,11 +87,21 @@ pub async fn start_server( pub async fn serve_solana(detected: DetectedProject, port: u16) -> anyhow::Result<()> { println!("Analyzing {} IDL(s)...", detected.idl_paths.len()); let project = build_solana_project(&detected)?; + let artifacts = load_program_artifacts(&detected, &project); let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")).await?; let actual_port = listener.local_addr()?.port(); - let state = Arc::new(AppState::from_solana(project, actual_port, detected.root.clone())); + let state = Arc::new(AppState::from_solana( + project, + artifacts, + actual_port, + detected.root.clone(), + )?); if let Some(s) = state.solana() { - println!("Ready: {} program(s) analyzed\n", s.project.programs.len()); + println!( + "Ready: {} program(s) analyzed, {} .so loaded\n", + s.project.programs.len(), + s.program_artifacts.len() + ); } let app = build_router(state); println!("Server running at http://localhost:{actual_port}"); @@ -104,9 +114,15 @@ pub async fn start_solana_server( port: u16, ) -> anyhow::Result<(Arc, u16)> { let project = build_solana_project(&detected)?; + let artifacts = load_program_artifacts(&detected, &project); let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{port}")).await?; let actual_port = listener.local_addr()?.port(); - let state = Arc::new(AppState::from_solana(project, actual_port, detected.root.clone())); + let state = Arc::new(AppState::from_solana( + project, + artifacts, + actual_port, + detected.root.clone(), + )?); let app = build_router(state.clone()); tokio::spawn(async move { @@ -126,3 +142,25 @@ fn build_solana_project(detected: &DetectedProject) -> anyhow::Result Vec<(solana_address::Address, Vec)> { + let mut out = Vec::new(); + for program in &project.programs { + for so_path in &detected.so_paths { + let stem = so_path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or(""); + if stem == program.name { + if let Ok(bytes) = std::fs::read(so_path) { + out.push((program.program_id, bytes)); + } + break; + } + } + } + out +} diff --git a/crates/ilold-web/src/state.rs b/crates/ilold-web/src/state.rs index 26bd808..c417b4b 100644 --- a/crates/ilold-web/src/state.rs +++ b/crates/ilold-web/src/state.rs @@ -21,7 +21,10 @@ use ilold_core::pathtree::walker::build_path_tree; use ilold_core::sequence::analysis::{analyze_project, analyze_sequences, SequenceAnalysis}; use ilold_core::sequence::builder::build_sequence_tree; use ilold_core::sequence::types::SequenceTree; +use ilold_solana_core::execute::VmHost; use ilold_solana_core::model::SolanaProject; +use solana_address::Address; +use solana_keypair::Keypair; use serde::{Deserialize, Serialize}; @@ -211,6 +214,9 @@ pub struct SolidityState { pub struct SolanaState { pub project: SolanaProject, + pub program_artifacts: Vec<(Address, Vec)>, + pub vms: RwLock>, + pub users: RwLock>>, } pub enum Backend { @@ -292,23 +298,38 @@ pub enum AnnotationStatus { impl AppState { pub fn from_solana( project: SolanaProject, + program_artifacts: Vec<(Address, Vec)>, port: u16, project_root: PathBuf, - ) -> Self { + ) -> anyhow::Result { let (session_tx, _) = broadcast::channel(64); let default_program = project .programs .first() .map(|p| p.name.clone()) .unwrap_or_else(|| "unknown".to_string()); - Self { - backend: Backend::Solana(SolanaState { project }), + + let mut vms = HashMap::new(); + let main_vm = VmHost::boot(program_artifacts.clone()) + .map_err(|e| anyhow::anyhow!("boot main VM: {e:?}"))?; + vms.insert(DEFAULT_SCENARIO.to_string(), main_vm); + + let mut users: HashMap> = HashMap::new(); + users.insert(DEFAULT_SCENARIO.to_string(), HashMap::new()); + + Ok(Self { + backend: Backend::Solana(SolanaState { + project, + program_artifacts, + vms: RwLock::new(vms), + users: RwLock::new(users), + }), annotations: RwLock::new(Vec::new()), scenarios: RwLock::new(ScenarioStore::new_for_contract(default_program)), session_tx, port, project_root, - } + }) } pub fn from_paths(paths: &[PathBuf], max_seq_depth: usize, port: u16, project_root: PathBuf) -> anyhow::Result { From 1e0f008944360cf8115a53c21f32eb5208085d77 Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 19:25:03 +0200 Subject: [PATCH 039/115] feat(web): handle_command branches by backend with Solana REPL pipeline - CommandRequest.command becomes serde_json::Value to host both backends without forking the wire format - Solidity branch keeps the existing flow with an explicit SessionCommand parse step and the same final canvas patches - Solana branch dispatches read-only and mutating ops, locking vms and users by active scenario, calling the ilold-solana-core executors and rebroadcasting CanvasPatch via canvas_patch_from_solana - scenario new boots a fresh VmHost from program_artifacts; fork snapshots and restores the VM and clones the users registry; delete drops both alongside the session --- crates/ilold-web/src/api/session.rs | 360 ++++++++++++++++++++++++++-- 1 file changed, 341 insertions(+), 19 deletions(-) diff --git a/crates/ilold-web/src/api/session.rs b/crates/ilold-web/src/api/session.rs index e2579c7..fa63783 100644 --- a/crates/ilold-web/src/api/session.rs +++ b/crates/ilold-web/src/api/session.rs @@ -4,6 +4,7 @@ use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::Json; use serde::{Deserialize, Serialize}; +use serde_json::Value; use ilold_core::classify::entry_points::AccessLevel; use ilold_core::exploration::commands::{ @@ -17,13 +18,21 @@ use ilold_core::exploration::timeline::{build_variable_timeline, VariableTimelin use ilold_core::narrative::trace::FlowTree; use ilold_core::narrative::types::{FunctionNarrative, SequenceNarrative}; use ilold_core::slicing::{build_slice_result, SliceDirection, SliceResult}; +use ilold_session_core::exploration::scenario::ScenarioAction as SharedScenarioAction; +use ilold_solana_core::exploration::{ + canvas_patch_from_solana, execute_airdrop, execute_back, execute_call, execute_clear, + execute_finding, execute_funcs, execute_inspect, execute_note, execute_pda, execute_session, + execute_state, execute_status, execute_time_warp, execute_users, execute_users_new, + SolanaCommand, SolanaCommandResult, +}; +use solana_keypair::Keypair; -use crate::state::{require_solidity_msg, AppState, ScenarioStore}; +use crate::state::{require_solidity_msg, AppState, Backend, ScenarioStore}; #[derive(Deserialize)] pub struct CommandRequest { pub contract: Option, - pub command: SessionCommand, + pub command: Value, } fn build_analysis_data<'a>( @@ -219,10 +228,19 @@ fn fork_scenario( pub async fn handle_command( State(state): State>, Json(req): Json, -) -> Result, (StatusCode, String)> { +) -> Result, (StatusCode, String)> { + if matches!(state.backend, Backend::Solana(_)) { + return handle_solana_command(state, req).await; + } let contract_name = resolve_contract(&state, req.contract.as_deref())?; let data = build_analysis_data(&state, &contract_name)?; let timestamp = timestamp_now(); + let solidity_command: SessionCommand = serde_json::from_value(req.command).map_err(|e| { + ( + StatusCode::BAD_REQUEST, + format!("invalid Solidity command: {e}"), + ) + })?; let mut scenarios_guard = state.scenarios.write().unwrap(); // SaveSession/LoadSession bypass the contract-switch reset: Save doesn't @@ -230,7 +248,7 @@ pub async fn handle_command( // contract inside the JSON. Without the bypass, loading a file from a // different contract would clear the store before the load runs. let is_persistence = matches!( - req.command, + solidity_command, SessionCommand::SaveSession | SessionCommand::LoadSession { .. } ); // Contract switch: only reset if the active session has actual steps. @@ -253,18 +271,14 @@ pub async fn handle_command( // other commands are delegated to the active session. Dispatch happens // here (before `active_session_mut`) to avoid partial-move errors on // `req.command`. - match req.command { + let result: CommandResult = match solidity_command { SessionCommand::Scenario { sub } => { - // Capture the active scenario BEFORE executing the scenario - // command. Switch/Delete mutate `active` or remove scenarios; the - // lifecycle patch embeds the pre-call name for consistent - // routing on the frontend. let active_before = scenarios_guard.active().to_string(); let result = execute_scenario(&mut scenarios_guard, sub, ×tamp, &contract_name); if let Some(patch) = canvas_patch_from(&result, &active_before) { state.session_tx.send(patch).ok(); } - Ok(Json(result)) + result } SessionCommand::SaveSession => { let active_before = scenarios_guard.active().to_string(); @@ -275,7 +289,7 @@ pub async fn handle_command( if let Some(patch) = canvas_patch_from(&result, &active_before) { state.session_tx.send(patch).ok(); } - Ok(Json(result)) + result } SessionCommand::LoadSession { json } => { let result = match ScenarioStore::load_from_json(&json) { @@ -292,26 +306,334 @@ pub async fn handle_command( } Err(message) => CommandResult::Error { message }, }; - // Use the post-load active so the WS Reloaded event names the - // scenario the frontend should switch to. let active_after = scenarios_guard.active().to_string(); if let Some(patch) = canvas_patch_from(&result, &active_after) { state.session_tx.send(patch).ok(); } - Ok(Json(result)) + result } other => { - // Capture the active scenario name BEFORE delegating — the - // session mutation below doesn't change `active`, but grabbing - // it here keeps the pattern symmetric with the scenario branch - // and avoids a second borrow after `active_session_mut`. let active_name = scenarios_guard.active().to_string(); let session = scenarios_guard.active_session_mut(); let result = execute_command(other, session, &data, ×tamp); if let Some(patch) = canvas_patch_from(&result, &active_name) { state.session_tx.send(patch).ok(); } - Ok(Json(result)) + result + } + }; + Ok(Json(serde_json::to_value(result).unwrap_or(Value::Null))) +} + +async fn handle_solana_command( + state: Arc, + req: CommandRequest, +) -> Result, (StatusCode, String)> { + let solana = state + .solana() + .ok_or((StatusCode::INTERNAL_SERVER_ERROR, "solana state missing".into()))?; + + let command: SolanaCommand = serde_json::from_value(req.command) + .map_err(|e| (StatusCode::BAD_REQUEST, format!("invalid Solana command: {e}")))?; + + let timestamp = timestamp_now(); + + let program = match req.contract.as_deref() { + Some(name) => solana + .project + .find_program(name) + .ok_or(( + StatusCode::NOT_FOUND, + format!("program '{name}' not found"), + ))? + .clone(), + None => solana + .project + .programs + .first() + .ok_or((StatusCode::INTERNAL_SERVER_ERROR, "no programs available".into()))? + .clone(), + }; + + if let SolanaCommand::Scenario { sub } = command { + let mut scenarios = state.scenarios.write().unwrap(); + let active_before = scenarios.active().to_string(); + let result = solana_scenario_action( + &mut scenarios, + solana, + sub, + &active_before, + ×tamp, + &program.name, + ); + if let Some(patch) = canvas_patch_from_solana(&result, &active_before) { + state.session_tx.send(patch).ok(); + } + return Ok(Json(serde_json::to_value(result).unwrap_or(Value::Null))); + } + + if matches!(command, SolanaCommand::SaveSession) { + let scenarios = state.scenarios.read().unwrap(); + let result = match scenarios.save_to_json() { + Ok(json) => SolanaCommandResult::SessionSaved { json }, + Err(message) => SolanaCommandResult::Error { message }, + }; + return Ok(Json(serde_json::to_value(result).unwrap_or(Value::Null))); + } + if let SolanaCommand::LoadSession { json } = command { + let mut scenarios = state.scenarios.write().unwrap(); + let result = match ScenarioStore::load_from_json(&json) { + Ok(loaded) => { + let prog = loaded.contract.clone(); + let step_names: Vec = loaded + .active_session() + .steps + .iter() + .map(|s| s.function.clone()) + .collect(); + *scenarios = loaded; + SolanaCommandResult::SessionLoaded { + program: prog, + steps: step_names, + } + } + Err(message) => SolanaCommandResult::Error { message }, + }; + let active_after = scenarios.active().to_string(); + if let Some(patch) = canvas_patch_from_solana(&result, &active_after) { + state.session_tx.send(patch).ok(); + } + return Ok(Json(serde_json::to_value(result).unwrap_or(Value::Null))); + } + + let mut scenarios = state.scenarios.write().unwrap(); + let mut vms = solana.vms.write().unwrap(); + let mut users = solana.users.write().unwrap(); + let active_scenario = scenarios.active().to_string(); + + let vm = vms.get_mut(&active_scenario).ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("VM for scenario '{active_scenario}' missing"), + ))?; + let scenario_users = users.get_mut(&active_scenario).ok_or(( + StatusCode::INTERNAL_SERVER_ERROR, + format!("users registry for scenario '{active_scenario}' missing"), + ))?; + let session = scenarios.active_session_mut(); + + let result = match command { + SolanaCommand::Funcs => execute_funcs(&program), + SolanaCommand::State => execute_state(session, &program, vm), + SolanaCommand::Session => execute_session(session, &program, &active_scenario), + SolanaCommand::Users => execute_users(scenario_users, vm), + SolanaCommand::UsersNew { name, lamports } => { + execute_users_new(name, lamports, scenario_users, vm) + } + SolanaCommand::Airdrop { user, lamports } => { + execute_airdrop(&user, lamports, scenario_users, vm) + } + SolanaCommand::TimeWarp { delta_seconds } => execute_time_warp(delta_seconds, vm), + SolanaCommand::Pda { instruction } => execute_pda(&program, &instruction), + SolanaCommand::Inspect { pubkey } => execute_inspect(&program, vm, &pubkey), + SolanaCommand::Call { + ix, + args, + accounts, + signers, + } => execute_call( + &program, + &ix, + args, + accounts, + signers, + scenario_users, + session, + vm, + ×tamp, + ), + SolanaCommand::Back => execute_back(session), + SolanaCommand::Clear => execute_clear(session), + SolanaCommand::Finding { + severity, + title, + description, + } => execute_finding(session, severity, title, description, ×tamp), + SolanaCommand::Note { text } => execute_note(session, &text, ×tamp), + SolanaCommand::Status { ix, status } => { + execute_status(session, &program, &ix, status, ×tamp) + } + SolanaCommand::SaveSession + | SolanaCommand::LoadSession { .. } + | SolanaCommand::Scenario { .. } => unreachable!("handled above"), + }; + + if let Some(patch) = canvas_patch_from_solana(&result, &active_scenario) { + state.session_tx.send(patch).ok(); + } + Ok(Json(serde_json::to_value(result).unwrap_or(Value::Null))) +} + +fn solana_scenario_action( + scenarios: &mut ScenarioStore, + solana: &crate::state::SolanaState, + sub: SharedScenarioAction, + active_before: &str, + timestamp: &str, + program_name: &str, +) -> SolanaCommandResult { + match sub { + SharedScenarioAction::New { name } => { + if let Err(e) = ilold_session_core::exploration::scenario::validate_scenario_name(&name) + { + return SolanaCommandResult::Error { message: e }; + } + if scenarios.contains(&name) { + return SolanaCommandResult::Error { + message: format!("scenario '{name}' already exists"), + }; + } + let session = ExplorationSession::new(program_name, "ilold"); + let fresh_vm = match ilold_solana_core::execute::VmHost::boot( + solana.program_artifacts.clone(), + ) { + Ok(v) => v, + Err(e) => { + return SolanaCommandResult::Error { + message: format!("boot VM for '{name}': {e:?}"), + }; + } + }; + scenarios.insert(name.clone(), session); + solana + .vms + .write() + .unwrap() + .insert(name.clone(), fresh_vm); + solana + .users + .write() + .unwrap() + .insert(name.clone(), std::collections::HashMap::new()); + SolanaCommandResult::ScenarioCreated { name } + } + SharedScenarioAction::List => { + let active = scenarios.active().to_string(); + let items = scenarios + .names() + .iter() + .map(|n| ilold_session_core::exploration::scenario::ScenarioInfo { + name: n.clone(), + active: n == &active, + step_count: scenarios.get(n).map(|s| s.steps.len()).unwrap_or(0), + }) + .collect(); + SolanaCommandResult::ScenarioList { items } + } + SharedScenarioAction::Switch { name } => { + let from = scenarios.active().to_string(); + if name == from { + return SolanaCommandResult::ScenarioSwitched { from, to: name }; + } + match scenarios.set_active(name.clone()) { + Ok(()) => SolanaCommandResult::ScenarioSwitched { from, to: name }, + Err(e) => SolanaCommandResult::Error { message: e }, + } + } + SharedScenarioAction::Fork { name, at_step } => { + if let Err(e) = ilold_session_core::exploration::scenario::validate_scenario_name(&name) + { + return SolanaCommandResult::Error { message: e }; + } + if scenarios.contains(&name) { + return SolanaCommandResult::Error { + message: format!("scenario '{name}' already exists"), + }; + } + let from = active_before.to_string(); + let mut cloned = scenarios.active_session().clone(); + let len = cloned.steps.len(); + let effective = match at_step { + None => len, + Some(n) if n > len => { + let noun = if len == 1 { "step" } else { "steps" }; + return SolanaCommandResult::Error { + message: format!( + "cannot fork at step {n}: only {len} {noun} in active scenario" + ), + }; + } + Some(n) => { + cloned.steps.truncate(n); + n + } + }; + cloned.forked_from = Some(ForkOrigin { + scenario: from.clone(), + at_step: effective, + }); + cloned.journal.record(JournalEntry::BranchCreated { + from_function: from.clone(), + branch_function: name.clone(), + timestamp: timestamp.to_string(), + }); + + let mut vms = solana.vms.write().unwrap(); + let snap = match vms.get(&from) { + Some(vm) => vm.snapshot(), + None => { + return SolanaCommandResult::Error { + message: format!("VM for scenario '{from}' missing"), + }; + } + }; + let new_vm = match ilold_solana_core::execute::VmHost::restore(snap) { + Ok(v) => v, + Err(e) => { + return SolanaCommandResult::Error { + message: format!("restore VM for '{name}': {e:?}"), + }; + } + }; + + let mut users = solana.users.write().unwrap(); + let cloned_users: std::collections::HashMap = users + .get(&from) + .map(|m| { + m.iter() + .map(|(k, kp)| (k.clone(), kp.insecure_clone())) + .collect() + }) + .unwrap_or_default(); + + scenarios.insert(name.clone(), cloned); + vms.insert(name.clone(), new_vm); + users.insert(name.clone(), cloned_users); + SolanaCommandResult::ScenarioForked { + from, + to: name, + at_step: effective, + } + } + SharedScenarioAction::Delete { name } => { + if name == scenarios.active() { + return SolanaCommandResult::Error { + message: "cannot delete active scenario — switch first".into(), + }; + } + if scenarios.len() == 1 { + return SolanaCommandResult::Error { + message: "cannot delete the only remaining scenario".into(), + }; + } + if !scenarios.contains(&name) { + return SolanaCommandResult::Error { + message: format!("scenario '{name}' does not exist"), + }; + } + scenarios.remove(&name); + solana.vms.write().unwrap().remove(&name); + solana.users.write().unwrap().remove(&name); + SolanaCommandResult::ScenarioDeleted { name } } } } From 9281336a98603786008105ff4818ae6a2bf2f3b7 Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 19:28:14 +0200 Subject: [PATCH 040/115] test(web): SolanaCommand pipeline over /api/cmd - funcs lists lever instructions; users-new + users round-trip - time-warp returns the warped Clock; fork clones the active scenario VM and users registry - call initialize + call switch_power exercise multi-signer over HTTP and verify the lever log line lands in the response logs_excerpt --- .../tests/solana_command_pipeline.rs | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 crates/ilold-web/tests/solana_command_pipeline.rs diff --git a/crates/ilold-web/tests/solana_command_pipeline.rs b/crates/ilold-web/tests/solana_command_pipeline.rs new file mode 100644 index 0000000..898a501 --- /dev/null +++ b/crates/ilold-web/tests/solana_command_pipeline.rs @@ -0,0 +1,196 @@ +use std::path::PathBuf; + +use ilold_solana_core::ingest::detect; + +fn cpi_fixture() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("tests/fixtures/solana/cpi") +} + +async fn start_solana() -> (reqwest::Client, u16) { + let detected = detect(&cpi_fixture()).expect("detect cpi fixture"); + let (_state, port) = ilold_web::start_solana_server(detected, 0) + .await + .expect("start solana server"); + (reqwest::Client::new(), port) +} + +async fn cmd( + client: &reqwest::Client, + port: u16, + program: &str, + command: serde_json::Value, +) -> serde_json::Value { + let res = client + .post(format!("http://127.0.0.1:{port}/api/cmd")) + .json(&serde_json::json!({ "contract": program, "command": command })) + .send() + .await + .expect("POST /api/cmd failed"); + assert!( + res.status().is_success(), + "POST /api/cmd returned {}", + res.status() + ); + res.json().await.expect("response was not JSON") +} + +#[tokio::test] +async fn solana_funcs_lists_lever_instructions() { + let (client, port) = start_solana().await; + let result = cmd(&client, port, "lever", serde_json::json!("Funcs")).await; + let items = result + .get("InstructionList") + .and_then(|v| v.get("items")) + .and_then(|v| v.as_array()) + .expect("InstructionList.items"); + assert_eq!(items.len(), 2); +} + +#[tokio::test] +async fn solana_users_new_then_list() { + let (client, port) = start_solana().await; + let _ = cmd( + &client, + port, + "lever", + serde_json::json!({"UsersNew": {"name": "admin", "lamports": 5_000_000_000u64}}), + ) + .await; + let listed = cmd(&client, port, "lever", serde_json::json!("Users")).await; + let users = listed + .get("UserList") + .and_then(|v| v.get("users")) + .and_then(|v| v.as_array()) + .expect("UserList.users"); + assert_eq!(users.len(), 1); + assert_eq!(users[0].get("name").and_then(|v| v.as_str()), Some("admin")); +} + +#[tokio::test] +async fn solana_time_warp_returns_new_clock() { + let (client, port) = start_solana().await; + let result = cmd( + &client, + port, + "lever", + serde_json::json!({"TimeWarp": {"delta_seconds": 86400}}), + ) + .await; + let warped = result.get("TimeWarped").expect("TimeWarped variant"); + assert!(warped.get("unix_timestamp").is_some()); + assert!(warped.get("slot").is_some()); +} + +#[tokio::test] +async fn solana_scenario_fork_clones_vm_and_users() { + let (client, port) = start_solana().await; + + let _ = cmd( + &client, + port, + "lever", + serde_json::json!({"UsersNew": {"name": "admin", "lamports": 1_000_000_000u64}}), + ) + .await; + + let forked = cmd( + &client, + port, + "lever", + serde_json::json!({"Scenario": {"sub": {"Fork": {"name": "branch1", "at_step": null}}}}), + ) + .await; + assert!(forked.get("ScenarioForked").is_some(), "got: {forked}"); + + let _ = cmd( + &client, + port, + "lever", + serde_json::json!({"Scenario": {"sub": {"Switch": {"name": "branch1"}}}}), + ) + .await; + + let listed = cmd(&client, port, "lever", serde_json::json!("Users")).await; + let users = listed + .get("UserList") + .and_then(|v| v.get("users")) + .and_then(|v| v.as_array()) + .expect("UserList.users"); + assert_eq!(users.len(), 1, "fork should have cloned the admin user"); + assert_eq!(users[0].get("name").and_then(|v| v.as_str()), Some("admin")); +} + +#[tokio::test] +async fn solana_call_switch_power_via_http() { + let (client, port) = start_solana().await; + + let _ = cmd( + &client, + port, + "lever", + serde_json::json!({"UsersNew": {"name": "admin", "lamports": 5_000_000_000u64}}), + ) + .await; + let _ = cmd( + &client, + port, + "lever", + serde_json::json!({"UsersNew": {"name": "power", "lamports": 5_000_000_000u64}}), + ) + .await; + + let init = cmd( + &client, + port, + "lever", + serde_json::json!({ + "Call": { + "ix": "initialize", + "args": {}, + "accounts": { + "power": "power", + "user": "admin", + "system_program": "11111111111111111111111111111111" + }, + "signers": ["admin", "power"] + } + }), + ) + .await; + assert!(init.get("StepAdded").is_some(), "got: {init}"); + + let switch = cmd( + &client, + port, + "lever", + serde_json::json!({ + "Call": { + "ix": "switch_power", + "args": {"name": "claude"}, + "accounts": {"power": "power"}, + "signers": [] + } + }), + ) + .await; + let added = switch.get("StepAdded").expect("StepAdded for switch_power"); + let logs = added + .get("logs_excerpt") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str()) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + assert!( + logs.contains("pulling the power switch"), + "expected lever log line, got: {logs}" + ); +} From db70456dad34088bdd043f08a1b6fd0de7c131fa Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 19:40:32 +0200 Subject: [PATCH 041/115] feat(cli): explore detects backend kind, branches contracts and use - BackendKind enum y detect_backend_kind seleccionan el flujo apropiado entre Solidity, Solana y attach - run_solana arranca start_solana_server y comparte el camino REPL con run_with_state - repl_loop y handle_input reciben backend; SwitchContract hace lookup en projects.programs cuando es Solana - contracts y use admiten Solana: print_programs lista programas y find_program valida el cambio activo --- crates/ilold-cli/src/explore.rs | 288 ++++++++++++++++++++++++++------ 1 file changed, 235 insertions(+), 53 deletions(-) diff --git a/crates/ilold-cli/src/explore.rs b/crates/ilold-cli/src/explore.rs index 0aa94e9..af5d459 100644 --- a/crates/ilold-cli/src/explore.rs +++ b/crates/ilold-cli/src/explore.rs @@ -15,6 +15,26 @@ use ilold_core::exploration::commands::CommandResult; use crate::colors::*; use crate::fmt; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BackendKind { + Solidity, + Solana, + Attached, +} + +fn detect_backend_kind(state: &Option>) -> BackendKind { + match state { + None => BackendKind::Attached, + Some(s) => { + if s.solana().is_some() { + BackendKind::Solana + } else { + BackendKind::Solidity + } + } + } +} + pub async fn run(paths: Vec, port: u16, max_seq_depth: usize, attach: Option) -> Result<()> { // --attach mode: connect to a running server instead of starting one locally if let Some(url) = attach { @@ -68,42 +88,100 @@ pub async fn run(paths: Vec, port: u16, max_seq_depth: usize, attach: O let base_url = url; let handle = tokio::runtime::Handle::current(); let repl_thread = std::thread::spawn(move || { - repl_loop(handle, contract_name, function_names, contract_names, None, base_url, Some(functions_by_contract)); + repl_loop( + handle, + contract_name, + function_names, + contract_names, + None, + base_url, + Some(functions_by_contract), + BackendKind::Attached, + ); }); repl_thread.join().map_err(|_| anyhow::anyhow!("REPL thread panicked"))?; return Ok(()); } - // Local mode: start server and connect println!("Analyzing {} file(s)...", paths.len()); let (state, actual_port) = ilold_web::start_server(paths, port, max_seq_depth).await?; + run_with_state(state, actual_port).await +} - let s = state.unwrap_solidity(); - let contract_name = s.project.find_contract(None) - .map(|c| c.name.clone()) - .unwrap_or_else(|_| "unknown".into()); - - let function_names: Vec = s.project.contracts.iter() - .find(|c| c.name == contract_name) - .map(|c| { - s.project - .accessible_functions(c) - .iter() - .map(|af| af.function.name.clone()) - .collect() - }) - .unwrap_or_default(); +pub async fn run_solana( + detected: ilold_solana_core::ingest::DetectedProject, + port: u16, +) -> Result<()> { + println!("Analyzing {} IDL(s)...", detected.idl_paths.len()); + let (state, actual_port) = ilold_web::start_solana_server(detected, port).await?; + run_with_state(state, actual_port).await +} - let contract_names: Vec = s.project.contracts.iter() - .map(|c| c.name.clone()) - .filter(|n| !n.is_empty()) - .collect(); +async fn run_with_state( + state: std::sync::Arc, + actual_port: u16, +) -> Result<()> { + let backend = detect_backend_kind(&Some(state.clone())); + + let (contract_name, function_names, contract_names, header_label) = match backend { + BackendKind::Solana => { + let s = state.solana().expect("solana backend"); + let program = s.project.programs.first(); + let name = program + .map(|p| p.name.clone()) + .unwrap_or_else(|| "unknown".into()); + let ix_names: Vec = program + .map(|p| p.instructions.iter().map(|i| i.name.clone()).collect()) + .unwrap_or_default(); + let program_names: Vec = s + .project + .programs + .iter() + .map(|p| p.name.clone()) + .collect(); + let label = format!("ilold explore — {} (solana)", name); + (name, ix_names, program_names, label) + } + _ => { + let s = state.unwrap_solidity(); + let name = s + .project + .find_contract(None) + .map(|c| c.name.clone()) + .unwrap_or_else(|_| "unknown".into()); + let function_names: Vec = s + .project + .contracts + .iter() + .find(|c| c.name == name) + .map(|c| { + s.project + .accessible_functions(c) + .iter() + .map(|af| af.function.name.clone()) + .collect() + }) + .unwrap_or_default(); + let contract_names: Vec = s + .project + .contracts + .iter() + .map(|c| c.name.clone()) + .filter(|n| !n.is_empty()) + .collect(); + let label = format!("ilold explore — {}", name); + (name, function_names, contract_names, label) + } + }; let func_count = function_names.len(); - + let func_label = match backend { + BackendKind::Solana => "instructions", + _ => "functions", + }; let banner = fmt::header_box(&[ - &format!("ilold explore — {}", contract_name), - &format!("{} functions | Type ? for help", func_count), + &header_label, + &format!("{} {} | Type ? for help", func_count, func_label), &format!("Web UI: http://localhost:{}", actual_port), ]); println!("{}\n", banner); @@ -112,13 +190,23 @@ pub async fn run(paths: Vec, port: u16, max_seq_depth: usize, attach: O let state_for_thread = state.clone(); let base_url = format!("http://127.0.0.1:{}", actual_port); let repl_thread = std::thread::spawn(move || { - repl_loop(handle, contract_name, function_names, contract_names, Some(state_for_thread), base_url, None); + repl_loop( + handle, + contract_name, + function_names, + contract_names, + Some(state_for_thread), + base_url, + None, + backend, + ); }); repl_thread.join().map_err(|_| anyhow::anyhow!("REPL thread panicked"))?; Ok(()) } +#[allow(clippy::too_many_arguments)] fn repl_loop( handle: tokio::runtime::Handle, mut contract: String, @@ -127,6 +215,7 @@ fn repl_loop( state: Option>, base_url: String, functions_by_contract: Option>>, + backend: BackendKind, ) { let history_path = dirs::home_dir() .map(|h| h.join(".ilold").join("history")) @@ -189,7 +278,7 @@ fn repl_loop( match handle_input( line, &handle, &client, &base_url, &contract, - &mut steps, &mut scenario_name, &completer, &state, + &mut steps, &mut scenario_name, &completer, &state, backend, ) { InputResult::Continue => {} InputResult::Quit => break, @@ -202,15 +291,38 @@ fn repl_loop( steps.clear(); scenario_name = "main".into(); if let Some(state) = state.as_ref() { - let s = state.unwrap_solidity(); - if let Some(c) = s.project.contracts.iter().find(|c| c.name == new_name) { - functions = s.project - .accessible_functions(c) - .iter() - .map(|af| af.function.name.clone()) - .collect(); - if let Ok(mut comp) = completer.lock() { - comp.functions = functions.clone(); + match backend { + BackendKind::Solana => { + if let Some(s) = state.solana() { + if let Some(p) = + s.project.programs.iter().find(|p| p.name == new_name) + { + functions = p + .instructions + .iter() + .map(|i| i.name.clone()) + .collect(); + if let Ok(mut comp) = completer.lock() { + comp.functions = functions.clone(); + } + } + } + } + _ => { + let s = state.unwrap_solidity(); + if let Some(c) = + s.project.contracts.iter().find(|c| c.name == new_name) + { + functions = s + .project + .accessible_functions(c) + .iter() + .map(|af| af.function.name.clone()) + .collect(); + if let Ok(mut comp) = completer.lock() { + comp.functions = functions.clone(); + } + } } } } else if let Some(fbc) = functions_by_contract.as_ref() { @@ -255,6 +367,7 @@ impl Completer for CompleterWrapper { } } +#[allow(clippy::too_many_arguments)] fn handle_input( line: &str, handle: &tokio::runtime::Handle, @@ -265,6 +378,7 @@ fn handle_input( scenario_name: &mut String, completer: &std::sync::Arc>, state: &Option>, + backend: BackendKind, ) -> InputResult { // Allow shortcuts like `st0`, `st1`, `step2` without requiring a space. let normalized = split_numeric_suffix(line); @@ -385,9 +499,12 @@ fn handle_input( InputResult::Continue } - "ct" | "contracts" => { + "ct" | "contracts" | "programs" | "progs" => { if let Some(state) = state { - print_contracts(state, contract); + match backend { + BackendKind::Solana => print_programs(state, contract), + _ => print_contracts(state, contract), + } } else { // --attach mode: fetch contract list from server match handle.block_on(async { @@ -416,24 +533,60 @@ fn handle_input( return InputResult::Continue; } if let Some(state) = state { - // Local mode - let s = state.unwrap_solidity(); - match s.project.find_contract(Some(arg)) { - Ok(c) => { - let name = c.name.clone(); - if name == contract { - println!(" Already using {}", c_accent(&name)); - return InputResult::Continue; - } - println!(" {} Now using: {}", c_ok("✓"), c_accent(&name)); - if !steps.is_empty() { - println!(" {}", c_muted(&format!("Cleared {} step(s) from previous contract", steps.len()))); + match backend { + BackendKind::Solana => { + let s = state.solana().expect("solana backend"); + match s.project.find_program(arg) { + Some(p) => { + let name = p.name.clone(); + if name == contract { + println!(" Already using {}", c_accent(&name)); + return InputResult::Continue; + } + println!(" {} Now using: {}", c_ok("✓"), c_accent(&name)); + if !steps.is_empty() { + println!( + " {}", + c_muted(&format!( + "Cleared {} step(s) from previous program", + steps.len() + )) + ); + } + InputResult::SwitchContract(name) + } + None => { + eprintln!(" {}", c_danger(&format!("program '{arg}' not found"))); + InputResult::Continue + } } - InputResult::SwitchContract(name) } - Err(e) => { - eprintln!(" {}", c_danger(&e)); - InputResult::Continue + _ => { + let s = state.unwrap_solidity(); + match s.project.find_contract(Some(arg)) { + Ok(c) => { + let name = c.name.clone(); + if name == contract { + println!(" Already using {}", c_accent(&name)); + return InputResult::Continue; + } + println!(" {} Now using: {}", c_ok("✓"), c_accent(&name)); + if !steps.is_empty() { + println!( + " {}", + c_muted(&format!( + "Cleared {} step(s) from previous contract", + steps.len() + )) + ); + } + InputResult::SwitchContract(name) + } + Err(e) => { + eprintln!(" {}", c_danger(&e)); + InputResult::Continue + } + } } } } else { @@ -1094,6 +1247,35 @@ fn handle_finding_interactive( } } +fn print_programs(state: &std::sync::Arc, current: &str) { + let s = match state.solana() { + Some(s) => s, + None => return, + }; + println!(); + if s.project.programs.is_empty() { + println!(" {}", c_muted("No programs detected")); + println!(); + return; + } + for p in &s.project.programs { + let marker = if p.name == current { + c_ok(" ← current").to_string() + } else { + String::new() + }; + println!( + " {} {} {} {}{}", + c_accent("[P]"), + p.name, + c_muted(&format!("({} ix)", p.instructions.len())), + c_muted(&p.program_id.to_string()), + marker + ); + } + println!(); +} + fn print_contracts(state: &std::sync::Arc, current: &str) { use ilold_core::model::contract::ContractKind; let s = state.unwrap_solidity(); From f2e9ae1a4ebb25f18f29383d55b9d4bea75ce98c Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 21:07:15 +0200 Subject: [PATCH 042/115] feat(cli): Solana REPL dispatch with formatters and helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handle_solana_input enruta los comandos del TUI a SolanaCommand vía /api/cmd - send_solana_command + dispatch_solana centralizan POST y broadcast del resultado - print_solana_result formatea cada variante: instrucciones, accounts, users, traces, scenarios y errores - apply_solana_result_to_steps mantiene la lista local sincronizada para el prompt - soporta funcs, state, session, back, clear, users, users new, airdrop, time-warp, pda, inspect, call con JSON, scenario new/list/switch/fork/delete, note, status --- crates/ilold-cli/src/explore.rs | 589 ++++++++++++++++++++++++++++++++ 1 file changed, 589 insertions(+) diff --git a/crates/ilold-cli/src/explore.rs b/crates/ilold-cli/src/explore.rs index af5d459..66c80a3 100644 --- a/crates/ilold-cli/src/explore.rs +++ b/crates/ilold-cli/src/explore.rs @@ -11,6 +11,7 @@ use reedline::{ use ilold_core::classify::entry_points::AccessLevel; use ilold_core::exploration::commands::CommandResult; +use ilold_solana_core::exploration::SolanaCommandResult; use crate::colors::*; use crate::fmt; @@ -380,6 +381,18 @@ fn handle_input( state: &Option>, backend: BackendKind, ) -> InputResult { + if backend == BackendKind::Solana { + return handle_solana_input( + line, + handle, + client, + base_url, + contract, + steps, + scenario_name, + completer, + ); + } // Allow shortcuts like `st0`, `st1`, `step2` without requiring a space. let normalized = split_numeric_suffix(line); let parts: Vec<&str> = normalized.splitn(2, ' ').collect(); @@ -1011,6 +1024,582 @@ fn handle_input( } } +#[allow(clippy::too_many_arguments)] +fn handle_solana_input( + line: &str, + handle: &tokio::runtime::Handle, + client: &reqwest::Client, + base_url: &str, + contract: &str, + steps: &mut Vec, + scenario_name: &mut String, + completer: &std::sync::Arc>, +) -> InputResult { + let parts: Vec<&str> = line.splitn(2, ' ').collect(); + let cmd = parts[0].to_lowercase(); + let arg = parts.get(1).map(|s| s.trim()).unwrap_or(""); + + match cmd.as_str() { + "?" | "help" | "h" => { + print_help(); + InputResult::Continue + } + "quit" | "q" | "exit" => InputResult::Quit, + "funcs" | "functions" | "f" => { + dispatch_solana( + handle, + client, + base_url, + contract, + serde_json::json!("Funcs"), + steps, + ) + } + "state" => dispatch_solana( + handle, + client, + base_url, + contract, + serde_json::json!("State"), + steps, + ), + "session" | "s" => { + let r = dispatch_solana( + handle, + client, + base_url, + contract, + serde_json::json!("Session"), + steps, + ); + *scenario_name = sync_active_scenario(handle, client, base_url, contract) + .unwrap_or_else(|| scenario_name.clone()); + r + } + "back" => dispatch_solana( + handle, + client, + base_url, + contract, + serde_json::json!("Back"), + steps, + ), + "clear" => dispatch_solana( + handle, + client, + base_url, + contract, + serde_json::json!("Clear"), + steps, + ), + "users" => { + if arg.starts_with("new") { + let rest = arg.trim_start_matches("new").trim(); + if rest.is_empty() { + println!(" Usage: users new []"); + return InputResult::Continue; + } + let parts: Vec<&str> = rest.split_whitespace().collect(); + let name = parts[0].to_string(); + let lamports: u64 = parts + .get(1) + .and_then(|s| s.parse().ok()) + .unwrap_or(10_000_000_000); + let body = serde_json::json!({"UsersNew": {"name": name, "lamports": lamports}}); + dispatch_solana(handle, client, base_url, contract, body, steps) + } else { + dispatch_solana( + handle, + client, + base_url, + contract, + serde_json::json!("Users"), + steps, + ) + } + } + "airdrop" | "air" => { + let parts: Vec<&str> = arg.split_whitespace().collect(); + if parts.len() != 2 { + println!(" Usage: airdrop "); + return InputResult::Continue; + } + let lamports: u64 = match parts[1].parse() { + Ok(v) => v, + Err(_) => { + println!(" Lamports must be an integer"); + return InputResult::Continue; + } + }; + let body = + serde_json::json!({"Airdrop": {"user": parts[0], "lamports": lamports}}); + dispatch_solana(handle, client, base_url, contract, body, steps) + } + "time-warp" | "tw" => { + let delta: i64 = match arg.parse() { + Ok(v) => v, + Err(_) => { + println!(" Usage: time-warp "); + return InputResult::Continue; + } + }; + let body = serde_json::json!({"TimeWarp": {"delta_seconds": delta}}); + dispatch_solana(handle, client, base_url, contract, body, steps) + } + "pda" => { + if arg.is_empty() { + println!(" Usage: pda "); + return InputResult::Continue; + } + let body = serde_json::json!({"Pda": {"instruction": arg}}); + dispatch_solana(handle, client, base_url, contract, body, steps) + } + "inspect" | "acc" => { + if arg.is_empty() { + println!(" Usage: inspect "); + return InputResult::Continue; + } + let body = serde_json::json!({"Inspect": {"pubkey": arg}}); + dispatch_solana(handle, client, base_url, contract, body, steps) + } + "call" | "c" => { + let parts: Vec<&str> = arg.splitn(2, ' ').collect(); + if parts.is_empty() || parts[0].is_empty() { + println!( + " Usage: call " + ); + return InputResult::Continue; + } + let ix = parts[0]; + let payload = parts.get(1).copied().unwrap_or("{}"); + let parsed: serde_json::Value = match serde_json::from_str(payload) { + Ok(v) => v, + Err(e) => { + println!(" Invalid JSON: {e}"); + return InputResult::Continue; + } + }; + let body = serde_json::json!({ + "Call": { + "ix": ix, + "args": parsed.get("args").cloned().unwrap_or(serde_json::json!({})), + "accounts": parsed.get("accounts").cloned().unwrap_or(serde_json::json!({})), + "signers": parsed.get("signers").cloned().unwrap_or(serde_json::json!([])), + } + }); + dispatch_solana(handle, client, base_url, contract, body, steps) + } + "ct" | "contracts" | "programs" | "progs" => { + // print_programs handled via SwitchContract path; this branch never + // reaches here because we route handle_solana_input before the + // shared dispatch. Inline a Funcs response so users see something. + dispatch_solana( + handle, + client, + base_url, + contract, + serde_json::json!("Funcs"), + steps, + ) + } + "sc" | "scenario" => { + let parts: Vec<&str> = arg.split_whitespace().collect(); + let sub = parts.first().copied().unwrap_or(""); + let name_arg = parts.get(1).copied().unwrap_or(""); + let action: Option = match sub { + "new" if !name_arg.is_empty() => { + Some(serde_json::json!({"New": {"name": name_arg}})) + } + "list" | "ls" | "" => Some(serde_json::json!("List")), + "switch" if !name_arg.is_empty() => { + Some(serde_json::json!({"Switch": {"name": name_arg}})) + } + "fork" if !name_arg.is_empty() => { + let at_step: Option = parts.get(2).and_then(|s| s.parse().ok()); + Some(serde_json::json!({"Fork": {"name": name_arg, "at_step": at_step}})) + } + "delete" | "rm" if !name_arg.is_empty() => { + Some(serde_json::json!({"Delete": {"name": name_arg}})) + } + _ => None, + }; + let action = match action { + Some(a) => a, + None => { + println!( + " Usage: scenario new|list|switch|fork|delete [step]" + ); + return InputResult::Continue; + } + }; + let body = serde_json::json!({"Scenario": {"sub": action}}); + let outcome = dispatch_solana(handle, client, base_url, contract, body, steps); + *scenario_name = sync_active_scenario(handle, client, base_url, contract) + .unwrap_or_else(|| scenario_name.clone()); + if let Ok(mut comp) = completer.lock() { + if let Some(items) = sync_scenarios(handle, client, base_url, contract) { + comp.scenarios = items; + } + } + outcome + } + "note" | "n" => { + if arg.is_empty() { + println!(" Usage: note "); + return InputResult::Continue; + } + let body = serde_json::json!({"Note": {"text": arg}}); + dispatch_solana(handle, client, base_url, contract, body, steps) + } + "status" => { + let parts: Vec<&str> = arg.split_whitespace().collect(); + if parts.len() != 2 { + println!(" Usage: status "); + return InputResult::Continue; + } + let st = match parts[1].to_lowercase().as_str() { + "open" => "Open", + "reviewed" => "Reviewed", + "finding" | "found" => "Finding", + other => { + println!(" Unknown status '{other}'. Use open|reviewed|finding"); + return InputResult::Continue; + } + }; + let body = + serde_json::json!({"Status": {"ix": parts[0], "status": st}}); + dispatch_solana(handle, client, base_url, contract, body, steps) + } + _ => { + println!( + " Unknown command: {}. Type {} for help.", + c_danger(&cmd), + c_accent("?") + ); + InputResult::Continue + } + } +} + +fn dispatch_solana( + handle: &tokio::runtime::Handle, + client: &reqwest::Client, + base_url: &str, + contract: &str, + command: serde_json::Value, + steps: &mut Vec, +) -> InputResult { + let body = serde_json::json!({"contract": contract, "command": command}); + match send_solana_command(handle, client, base_url, &body) { + Ok(result) => { + apply_solana_result_to_steps(&result, steps); + print_solana_result(&result); + InputResult::UpdatePrompt + } + Err(e) => { + eprintln!(" {}", c_danger(&e)); + InputResult::Continue + } + } +} + +fn send_solana_command( + handle: &tokio::runtime::Handle, + client: &reqwest::Client, + base_url: &str, + body: &serde_json::Value, +) -> Result { + handle.block_on(async { + let resp = client + .post(format!("{base_url}/api/cmd")) + .json(body) + .send() + .await + .map_err(|e| format!("Request failed: {e}"))?; + let status = resp.status(); + if !status.is_success() { + let text = resp.text().await.unwrap_or_default(); + return Err(format!("Server error {status}: {text}")); + } + resp.json::() + .await + .map_err(|e| format!("Parse failed: {e}")) + }) +} + +fn apply_solana_result_to_steps(result: &SolanaCommandResult, steps: &mut Vec) { + match result { + SolanaCommandResult::StepAdded { instruction, .. } => steps.push(instruction.clone()), + SolanaCommandResult::StepRemoved { .. } => { + steps.pop(); + } + SolanaCommandResult::Cleared => steps.clear(), + SolanaCommandResult::SessionView { steps: server_steps, .. } => { + *steps = server_steps.clone(); + } + SolanaCommandResult::SessionLoaded { steps: server_steps, .. } => { + *steps = server_steps.clone(); + } + _ => {} + } +} + +fn print_solana_result(result: &SolanaCommandResult) { + println!(); + match result { + SolanaCommandResult::StepAdded { + step_index, + instruction, + logs_excerpt, + account_diffs_count, + compute_units, + } => { + println!( + " {} step {}: {} {}", + c_ok("✓"), + step_index, + c_accent(instruction), + c_muted(&format!( + "({} CU, {} diffs)", + compute_units, account_diffs_count + )) + ); + for log in logs_excerpt { + println!(" {}", c_muted(log)); + } + } + SolanaCommandResult::StepRemoved { remaining } => { + println!(" {} step undone ({} remaining)", c_ok("✓"), remaining); + } + SolanaCommandResult::Cleared => { + println!(" {} session cleared", c_ok("✓")); + } + SolanaCommandResult::InstructionList { items } => { + for ix in items { + let badge = if ix.has_pdas { c_accent("[PDA]") } else { c_muted("[ix]") }; + let signers = if ix.signers.is_empty() { + String::new() + } else { + format!(" signers: {}", ix.signers.join(",")) + }; + println!( + " {} {} {}{}", + badge, + ix.name, + c_muted(&format!( + "(args:{} accounts:{})", + ix.args_count, ix.accounts_count + )), + c_muted(&signers) + ); + } + } + SolanaCommandResult::StateView { accounts } => { + if accounts.is_empty() { + println!(" {}", c_muted("No accounts mutated yet")); + } + for a in accounts { + let decoded = a + .decoded + .as_ref() + .map(|v| serde_json::to_string(v).unwrap_or_default()) + .unwrap_or_else(|| "".into()); + println!( + " {} {} {} {} {}", + c_accent("[A]"), + a.label, + c_muted(&format!("({} lamports)", a.lamports)), + c_muted(&a.pubkey), + c_muted(&decoded) + ); + } + } + SolanaCommandResult::SessionView { + program, + scenario, + steps, + findings_count, + } => { + println!( + " program={} scenario={} steps={} findings={}", + c_accent(program), + c_accent(scenario), + steps.len(), + findings_count + ); + for (i, s) in steps.iter().enumerate() { + println!(" {} {}", c_muted(&format!("{i}.")), s); + } + } + SolanaCommandResult::UserList { users } => { + if users.is_empty() { + println!(" {}", c_muted("No users — create with 'users new '")); + } + for u in users { + println!( + " {} {} {} {}", + c_accent("[U]"), + u.name, + c_muted(&u.pubkey), + c_muted(&format!("{} lamports", u.lamports)) + ); + } + } + SolanaCommandResult::UserCreated { name, pubkey, lamports } => { + println!( + " {} user {} created at {} with {} lamports", + c_ok("✓"), + c_accent(name), + c_muted(pubkey), + lamports + ); + } + SolanaCommandResult::Airdropped { name, pubkey, total_lamports } => { + println!( + " {} {} now {} lamports {}", + c_ok("✓"), + c_accent(name), + total_lamports, + c_muted(pubkey) + ); + } + SolanaCommandResult::TimeWarped { unix_timestamp, slot } => { + println!( + " {} clock now ts={} slot={}", + c_ok("✓"), + unix_timestamp, + slot + ); + } + SolanaCommandResult::PdaList { instruction, pdas } => { + if pdas.is_empty() { + println!(" {} {}", c_muted("no PDAs declared in"), instruction); + } + for p in pdas { + println!( + " {} {} seeds=[{}] program={}", + c_accent("[PDA]"), + p.account_name, + p.seeds.join(", "), + c_muted(&p.program) + ); + } + } + SolanaCommandResult::AccountInspected { + pubkey, + owner, + lamports, + data_len, + decoded, + } => { + println!( + " {} owner={} lamports={} data_len={}", + c_accent(pubkey), + c_muted(owner), + lamports, + data_len + ); + if let Some(d) = decoded { + println!(" {}", serde_json::to_string_pretty(d).unwrap_or_default()); + } + } + SolanaCommandResult::FindingAdded { id } => { + println!(" {} finding {}", c_ok("✓"), c_accent(id)); + } + SolanaCommandResult::NoteAdded => { + println!(" {} note recorded", c_ok("✓")); + } + SolanaCommandResult::StatusUpdated => { + println!(" {} status updated", c_ok("✓")); + } + SolanaCommandResult::SessionSaved { json } => { + println!( + " {} session JSON ({} bytes)", + c_ok("✓"), + json.len() + ); + } + SolanaCommandResult::SessionLoaded { program, steps } => { + println!( + " {} loaded program={} steps={}", + c_ok("✓"), + c_accent(program), + steps.len() + ); + } + SolanaCommandResult::ScenarioList { items } => { + for it in items { + let marker = if it.active { c_ok(" ← active") } else { "".into() }; + println!( + " {} {} {}{}", + c_accent("[S]"), + it.name, + c_muted(&format!("({} steps)", it.step_count)), + marker + ); + } + } + SolanaCommandResult::ScenarioCreated { name } => { + println!(" {} scenario {} created", c_ok("✓"), c_accent(name)); + } + SolanaCommandResult::ScenarioSwitched { from, to } => { + println!(" {} {} → {}", c_ok("→"), from, c_accent(to)); + } + SolanaCommandResult::ScenarioForked { from, to, at_step } => { + println!( + " {} forked {} → {} at step {}", + c_ok("✓"), + from, + c_accent(to), + at_step + ); + } + SolanaCommandResult::ScenarioDeleted { name } => { + println!(" {} scenario {} deleted", c_ok("✓"), name); + } + SolanaCommandResult::Error { message } => { + eprintln!(" {} {}", c_danger("✗"), message); + } + } + println!(); +} + +fn sync_active_scenario( + handle: &tokio::runtime::Handle, + client: &reqwest::Client, + base_url: &str, + contract: &str, +) -> Option { + let body = serde_json::json!({ + "contract": contract, + "command": {"Scenario": {"sub": "List"}} + }); + let res = send_solana_command(handle, client, base_url, &body).ok()?; + if let SolanaCommandResult::ScenarioList { items } = res { + items.into_iter().find(|i| i.active).map(|i| i.name) + } else { + None + } +} + +fn sync_scenarios( + handle: &tokio::runtime::Handle, + client: &reqwest::Client, + base_url: &str, + contract: &str, +) -> Option> { + let body = serde_json::json!({ + "contract": contract, + "command": {"Scenario": {"sub": "List"}} + }); + let res = send_solana_command(handle, client, base_url, &body).ok()?; + if let SolanaCommandResult::ScenarioList { items } = res { + Some(items.into_iter().map(|i| i.name).collect()) + } else { + None + } +} + /// Fetch current session steps from the server (for --attach prompt sync). fn sync_steps( handle: &tokio::runtime::Handle, From 0afd94f41459e7249029f148987fb403d81ee2e0 Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 21:10:21 +0200 Subject: [PATCH 043/115] feat(cli): route explore command to run_solana - drop the Solana bail in main.rs and delegate to explore::run_solana when detect identifies an Anchor.toml workspace - existing Solidity flow stays intact --- crates/ilold-cli/src/main.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/ilold-cli/src/main.rs b/crates/ilold-cli/src/main.rs index 72fdce0..4fede5d 100644 --- a/crates/ilold-cli/src/main.rs +++ b/crates/ilold-cli/src/main.rs @@ -88,9 +88,7 @@ async fn main() -> Result<()> { let detected = detect(&path)?; match detected.kind { ProjectKind::Solidity => explore_solidity(&path, port, max_seq_depth, attach).await, - ProjectKind::Solana => anyhow::bail!( - "Explore mode for Solana projects requires the REPL command set planned for a later phase. Use `ilold serve` for now." - ), + ProjectKind::Solana => explore::run_solana(detected, port).await, } } } From 484d1b4dc69ac9647c9a0f1faf5d958b5a7a9f02 Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 21:21:37 +0200 Subject: [PATCH 044/115] feat(cli): help text and attach mode parity for Solana - print_help_for branches groups by backend; print_solana_help covers the Solana-specific REPL surface, native ops and journal - run_solana_attach detects kind solana from /api/project/map and starts repl_loop with BackendKind::Solana --- crates/ilold-cli/src/explore.rs | 119 ++++++++++++++++++++++++++++++-- 1 file changed, 115 insertions(+), 4 deletions(-) diff --git a/crates/ilold-cli/src/explore.rs b/crates/ilold-cli/src/explore.rs index 66c80a3..dec7566 100644 --- a/crates/ilold-cli/src/explore.rs +++ b/crates/ilold-cli/src/explore.rs @@ -37,9 +37,23 @@ fn detect_backend_kind(state: &Option } pub async fn run(paths: Vec, port: u16, max_seq_depth: usize, attach: Option) -> Result<()> { - // --attach mode: connect to a running server instead of starting one locally if let Some(url) = attach { let client = reqwest::Client::new(); + let map_resp = client + .get(format!("{url}/api/project/map")) + .send() + .await + .map_err(|e| anyhow::anyhow!("Cannot reach server at {url}: {e}"))?; + if !map_resp.status().is_success() { + anyhow::bail!("Server at {url} returned {}", map_resp.status()); + } + let project_map: serde_json::Value = map_resp.json().await?; + let kind = project_map["kind"].as_str().unwrap_or("solidity"); + + if kind == "solana" { + return run_solana_attach(url, client, project_map).await; + } + let resp = client.get(format!("{url}/api/project")) .send().await .map_err(|e| anyhow::anyhow!("Cannot reach server at {url}: {e}"))?; @@ -109,6 +123,60 @@ pub async fn run(paths: Vec, port: u16, max_seq_depth: usize, attach: O run_with_state(state, actual_port).await } +async fn run_solana_attach( + url: String, + _client: reqwest::Client, + project_map: serde_json::Value, +) -> Result<()> { + let programs_arr = project_map["programs"].as_array(); + let program_name = programs_arr + .and_then(|arr| arr.first()) + .and_then(|p| p["name"].as_str()) + .unwrap_or("unknown") + .to_string(); + let program_names: Vec = programs_arr + .map(|arr| { + arr.iter() + .filter_map(|p| p["name"].as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + let function_names: Vec = programs_arr + .and_then(|arr| arr.iter().find(|p| p["name"].as_str() == Some(&program_name))) + .and_then(|p| p["instructions"].as_array()) + .map(|ixs| { + ixs.iter() + .filter_map(|i| i["name"].as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + let banner = fmt::header_box(&[ + &format!("ilold explore — {} (solana, attached)", program_name), + &format!("{} instructions | Type ? for help", function_names.len()), + &format!("Server: {}", url), + ]); + println!("{}\n", banner); + + let handle = tokio::runtime::Handle::current(); + let repl_thread = std::thread::spawn(move || { + repl_loop( + handle, + program_name, + function_names, + program_names, + None, + url, + None, + BackendKind::Solana, + ); + }); + repl_thread + .join() + .map_err(|_| anyhow::anyhow!("REPL thread panicked"))?; + Ok(()) +} + pub async fn run_solana( detected: ilold_solana_core::ingest::DetectedProject, port: u16, @@ -1041,7 +1109,7 @@ fn handle_solana_input( match cmd.as_str() { "?" | "help" | "h" => { - print_help(); + print_solana_help(); InputResult::Continue } "quit" | "q" | "exit" => InputResult::Quit, @@ -2438,7 +2506,49 @@ fn print_sequence_narrative(val: &serde_json::Value) { } fn print_help() { - let groups: &[(&str, &[(&str, &str, &str)])] = &[ + print_help_for(BackendKind::Solidity); +} + +fn print_solana_help() { + print_help_for(BackendKind::Solana); +} + +fn print_help_for(backend: BackendKind) { + let groups: &[(&str, &[(&str, &str, &str)])] = match backend { + BackendKind::Solana => &[ + ("Session", &[ + ("c", "call ", "Send instruction (json: {args, accounts, signers})"), + ("b", "back", "Remove last step"), + ("cl", "clear", "Reset session"), + ("", "state", "Decoded view of mutated accounts"), + ("s", "session", "Steps + scenario summary"), + ]), + ("Solana", &[ + ("", "users", "List keypairs"), + ("", "users new [lamports]", "Create + airdrop"), + ("", "airdrop ", "Top up balance"), + ("tw", "time-warp ", "Advance Clock"), + ("", "pda ", "List declared PDAs of an instruction"), + ("", "inspect ", "Decode account by discriminator"), + ]), + ("Programs", &[ + ("ct", "programs", "List programs in workspace"), + ("", "use ", "Switch active program"), + ("", "funcs", "List instructions of active program"), + ]), + ("Findings", &[ + ("fi", "finding ", "Record a finding"), + ("n", "note ", "Add note"), + ("", "status ", "Change review status"), + ("sc", "scenario ", "new|list|switch|fork|delete"), + ]), + ("Workspace", &[ + ("", "save ", "Save session JSON"), + ("", "load ", "Load session JSON"), + ("q", "quit/exit", "Exit"), + ]), + ], + _ => &[ ("Session", &[ ("c", "call ", "Add function to sequence"), ("b", "back", "Remove last step"), @@ -2479,7 +2589,8 @@ fn print_help() { ("", "browser", "Open web UI"), ("q", "quit/exit", "Exit"), ]), - ]; + ], + }; println!(); println!(" {} {}", c_bright("ilold explore"), c_muted("— append ? to any command for inline help (e.g. sl?)")); From e110dcd60ea370020ad498b7e6584fb49cdb3760 Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 21:30:32 +0200 Subject: [PATCH 045/115] fix(cli): contracts and programs in Solana TUI use /api/project/map - drop the multi-line prose comment and the dummy branch that forwarded a Funcs payload - print_remote_programs lists name, program_id and ix_count from the public endpoint, unifying local and --attach behaviour --- crates/ilold-cli/src/explore.rs | 64 +++++++++++++++++++++++++++------ 1 file changed, 53 insertions(+), 11 deletions(-) diff --git a/crates/ilold-cli/src/explore.rs b/crates/ilold-cli/src/explore.rs index dec7566..1e01f0d 100644 --- a/crates/ilold-cli/src/explore.rs +++ b/crates/ilold-cli/src/explore.rs @@ -1258,17 +1258,20 @@ fn handle_solana_input( dispatch_solana(handle, client, base_url, contract, body, steps) } "ct" | "contracts" | "programs" | "progs" => { - // print_programs handled via SwitchContract path; this branch never - // reaches here because we route handle_solana_input before the - // shared dispatch. Inline a Funcs response so users see something. - dispatch_solana( - handle, - client, - base_url, - contract, - serde_json::json!("Funcs"), - steps, - ) + match handle.block_on(async { + client + .get(format!("{base_url}/api/project/map")) + .send() + .await? + .json::() + .await + }) { + Ok(map) => print_remote_programs(&map, contract), + Err(e) => { + eprintln!(" {}", c_danger(&format!("Failed to fetch programs: {e}"))) + } + } + InputResult::Continue } "sc" | "scenario" => { let parts: Vec<&str> = arg.split_whitespace().collect(); @@ -1904,6 +1907,45 @@ fn handle_finding_interactive( } } +fn print_remote_programs(map: &serde_json::Value, current: &str) { + let arr = match map.get("programs").and_then(|v| v.as_array()) { + Some(a) => a, + None => { + println!(" {}", c_muted("No programs in /api/project/map")); + return; + } + }; + println!(); + if arr.is_empty() { + println!(" {}", c_muted("No programs detected")); + println!(); + return; + } + for p in arr { + let name = p.get("name").and_then(|v| v.as_str()).unwrap_or("?"); + let pid = p.get("program_id").and_then(|v| v.as_str()).unwrap_or(""); + let ix_count = p + .get("instructions") + .and_then(|v| v.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + let marker = if name == current { + c_ok(" ← current").to_string() + } else { + String::new() + }; + println!( + " {} {} {} {}{}", + c_accent("[P]"), + name, + c_muted(&format!("({} ix)", ix_count)), + c_muted(pid), + marker + ); + } + println!(); +} + fn print_programs(state: &std::sync::Arc, current: &str) { let s = match state.solana() { Some(s) => s, From c484dd7a31e5fd1dacd2e0f9faaa8d7083fdbd33 Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 21:53:13 +0200 Subject: [PATCH 046/115] feat(frontend): home and palette detect kind solana - ProjectMap gains kind, programs, MapProgram and MapInstruction shaped after the backend response - the home renders program cards with instructions when kind is solana while keeping the Solidity flow untouched - the command palette lists programs with the diamond icon and IDL-derived details --- crates/ilold-web/frontend/src/lib/api/rest.ts | 16 +++ .../frontend/src/routes/+page.svelte | 99 ++++++++++++++++--- 2 files changed, 99 insertions(+), 16 deletions(-) diff --git a/crates/ilold-web/frontend/src/lib/api/rest.ts b/crates/ilold-web/frontend/src/lib/api/rest.ts index c8291e0..d508c86 100644 --- a/crates/ilold-web/frontend/src/lib/api/rest.ts +++ b/crates/ilold-web/frontend/src/lib/api/rest.ts @@ -76,10 +76,26 @@ export interface SearchSuggestions { } export interface ProjectMap { + kind: string; contracts: MapContract[]; + programs: MapProgram[]; relationships: MapRelationship[]; } +export interface MapProgram { + name: string; + program_id: string; + instructions: MapInstruction[]; + account_types: { name: string }[]; +} + +export interface MapInstruction { + name: string; + args_count: number; + accounts_count: number; + has_pdas: boolean; +} + export interface MapContract { name: string; kind: string; diff --git a/crates/ilold-web/frontend/src/routes/+page.svelte b/crates/ilold-web/frontend/src/routes/+page.svelte index 7a9b6fe..82a7828 100644 --- a/crates/ilold-web/frontend/src/routes/+page.svelte +++ b/crates/ilold-web/frontend/src/routes/+page.svelte @@ -1,7 +1,7 @@ + + + + + + + + diff --git a/crates/ilold-web/frontend/src/lib/components/contract/nodes/InstructionNode.svelte b/crates/ilold-web/frontend/src/lib/components/contract/nodes/InstructionNode.svelte new file mode 100644 index 0000000..9529e98 --- /dev/null +++ b/crates/ilold-web/frontend/src/lib/components/contract/nodes/InstructionNode.svelte @@ -0,0 +1,38 @@ + + +
+ {data.label} +
+ {data.argsCount}a · {data.accountsCount}acc + {#if data.hasPdas} + PDA + {/if} + {#if hasSigners} + 🔑 + {/if} +
+
+ + + + + + diff --git a/crates/ilold-web/frontend/src/lib/components/contract/nodes/TraceNode.svelte b/crates/ilold-web/frontend/src/lib/components/contract/nodes/TraceNode.svelte new file mode 100644 index 0000000..25079c6 --- /dev/null +++ b/crates/ilold-web/frontend/src/lib/components/contract/nodes/TraceNode.svelte @@ -0,0 +1,42 @@ + + +
+
+ #{data.stepIndex} + {data.instruction} +
+
+ {data.computeUnits} CU + {data.diffsCount} diffs + {#if hasError} + err + {/if} +
+
+ + + + + + diff --git a/crates/ilold-web/frontend/src/lib/stores/graph.svelte.ts b/crates/ilold-web/frontend/src/lib/stores/graph.svelte.ts index 111dff6..87bae8c 100644 --- a/crates/ilold-web/frontend/src/lib/stores/graph.svelte.ts +++ b/crates/ilold-web/frontend/src/lib/stores/graph.svelte.ts @@ -51,7 +51,49 @@ export interface SequenceNodeData { _dimmed?: boolean; } -export type GraphNodeData = FunctionNodeData | BlockNodeData | SequenceNodeData; +export interface InstructionNodeData { + [key: string]: unknown; + _type: 'instruction'; + label: string; + programName: string; + programId: string; + argsCount: number; + accountsCount: number; + hasPdas: boolean; + signers: string[]; + _dimmed?: boolean; +} + +export interface AccountNodeData { + [key: string]: unknown; + _type: 'account'; + label: string; + programName: string; + fields?: { name: string; type: string }[]; + _dimmed?: boolean; +} + +export interface TraceNodeData { + [key: string]: unknown; + _type: 'trace'; + label: string; + stepIndex: number; + instruction: string; + computeUnits: number; + diffsCount: number; + logsExcerpt: string[]; + scenario: string; + error?: string | null; + _dimmed?: boolean; +} + +export type GraphNodeData = + | FunctionNodeData + | BlockNodeData + | SequenceNodeData + | InstructionNodeData + | AccountNodeData + | TraceNodeData; // ── Reactive state ────────────────────────────────────────── // SvelteFlow uses $bindable nodes/edges — the wrapper component diff --git a/crates/ilold-web/src/api/project.rs b/crates/ilold-web/src/api/project.rs index 197a09d..bcdc560 100644 --- a/crates/ilold-web/src/api/project.rs +++ b/crates/ilold-web/src/api/project.rs @@ -1,8 +1,9 @@ use std::sync::Arc; -use axum::extract::State; +use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::Json; +use ilold_solana_core::model::ProgramDef; use serde::Serialize; use crate::state::{require_solidity_msg, AppState, Backend}; @@ -114,6 +115,21 @@ pub struct MapRelationship { pub kind: String, } +pub async fn get_program_detail( + State(state): State>, + Path(name): Path, +) -> Result, (StatusCode, String)> { + let solana = state + .solana() + .ok_or((StatusCode::BAD_REQUEST, "endpoint is Solana-only".into()))?; + solana + .project + .find_program(&name) + .cloned() + .map(Json) + .ok_or((StatusCode::NOT_FOUND, format!("program '{name}' not found"))) +} + pub async fn get_project_map( State(state): State>, ) -> Json { diff --git a/crates/ilold-web/src/lib.rs b/crates/ilold-web/src/lib.rs index 3895f07..10f4a4e 100644 --- a/crates/ilold-web/src/lib.rs +++ b/crates/ilold-web/src/lib.rs @@ -17,6 +17,7 @@ fn build_router(state: Arc) -> Router { Router::new() .route("/api/project", get(api::project::get_project)) .route("/api/project/map", get(api::project::get_project_map)) + .route("/api/program/{name}", get(api::project::get_program_detail)) .route("/api/contract/{name}", get(api::contract::get_contract)) .route("/api/contract/{name}/callgraph", get(api::contract::get_callgraph)) .route("/api/contract/{name}/{func}/cfg", get(api::contract::get_cfg)) From 039038e76d9db5632f89a960ce9e25ae11084fb2 Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 22:29:57 +0200 Subject: [PATCH 049/115] feat(frontend): Solana canvas with InstructionSidebar and program composer - composeProgramGraph emits instruction and account nodes plus instruction-to-account edges using ProgramDef detail - InstructionSidebar mirrors FunctionSidebar with PDA and signer filters; supports add/remove via callbacks - contract detail page mounts GraphCanvasFlow + InstructionSidebar when kind is solana, dropping the placeholder list - AccountNode border falls back to the default success token to avoid an undefined --color-success-dark variable --- .../frontend/src/lib/canvas/program.ts | 96 ++++++++++++ .../contract/InstructionSidebar.svelte | 139 ++++++++++++++++++ .../contract/nodes/AccountNode.svelte | 2 +- .../src/routes/contract/[name]/+page.svelte | 79 +++++----- 4 files changed, 281 insertions(+), 35 deletions(-) create mode 100644 crates/ilold-web/frontend/src/lib/canvas/program.ts create mode 100644 crates/ilold-web/frontend/src/lib/components/contract/InstructionSidebar.svelte diff --git a/crates/ilold-web/frontend/src/lib/canvas/program.ts b/crates/ilold-web/frontend/src/lib/canvas/program.ts new file mode 100644 index 0000000..f3951a9 --- /dev/null +++ b/crates/ilold-web/frontend/src/lib/canvas/program.ts @@ -0,0 +1,96 @@ +import type { Node, Edge } from '@xyflow/svelte'; +import type { ProgramDetail } from '$lib/api/rest'; +import type { + AccountNodeData, + GraphNodeData, + InstructionNodeData, +} from '$lib/stores/graph.svelte'; + +const INSTRUCTION_X_STEP = 220; +const ACCOUNT_X_STEP = 180; +const INSTRUCTION_Y = 320; +const ACCOUNT_Y = 0; + +export function composeProgramGraph(program: ProgramDetail): { + nodes: Node[]; + edges: Edge[]; +} { + const accountIds = new Map(); + const accountNodes: Node[] = program.account_types.map( + (a, i) => { + const id = `account:${a.name}`; + accountIds.set(a.name, id); + const data: AccountNodeData = { + _type: 'account', + label: a.name, + programName: program.name, + fields: extractFieldList(a), + }; + return { + id, + type: 'account', + position: { x: i * ACCOUNT_X_STEP, y: ACCOUNT_Y }, + data, + }; + }, + ); + + const instructionNodes: Node[] = program.instructions.map( + (ix, i) => { + const id = `ix:${ix.name}`; + const signers = (ix.accounts ?? []) + .filter((a: any) => a.signer) + .map((a: any) => a.name); + const hasPdas = (ix.accounts ?? []).some((a: any) => a.pda != null); + const data: InstructionNodeData = { + _type: 'instruction', + label: ix.name, + programName: program.name, + programId: program.program_id, + argsCount: (ix.args ?? []).length, + accountsCount: (ix.accounts ?? []).length, + hasPdas, + signers, + }; + return { + id, + type: 'instruction', + position: { x: i * INSTRUCTION_X_STEP, y: INSTRUCTION_Y }, + data, + }; + }, + ); + + const edges: Edge[] = []; + for (const ix of program.instructions) { + const ixId = `ix:${ix.name}`; + const seen = new Set(); + for (const acc of ix.accounts ?? []) { + const accId = accountIds.get(acc.name); + if (!accId) continue; + const key = `${ixId}->${accId}`; + if (seen.has(key)) continue; + seen.add(key); + edges.push({ + id: `e:${key}`, + source: ixId, + sourceHandle: 't', + target: accId, + targetHandle: 'b', + animated: false, + }); + } + } + + return { nodes: [...accountNodes, ...instructionNodes], edges }; +} + +function extractFieldList(a: any): { name: string; type: string }[] { + const ty = a?.layout?.ty; + const fields = ty?.kind === 'Struct' ? ty.fields ?? ty?.Struct?.fields : null; + if (!Array.isArray(fields)) return []; + return fields.map((f: any) => ({ + name: f?.name ?? '?', + type: typeof f?.ty === 'string' ? f.ty : JSON.stringify(f?.ty ?? '?'), + })); +} diff --git a/crates/ilold-web/frontend/src/lib/components/contract/InstructionSidebar.svelte b/crates/ilold-web/frontend/src/lib/components/contract/InstructionSidebar.svelte new file mode 100644 index 0000000..7edd890 --- /dev/null +++ b/crates/ilold-web/frontend/src/lib/components/contract/InstructionSidebar.svelte @@ -0,0 +1,139 @@ + + + + + diff --git a/crates/ilold-web/frontend/src/lib/components/contract/nodes/AccountNode.svelte b/crates/ilold-web/frontend/src/lib/components/contract/nodes/AccountNode.svelte index cb8e190..2c61a38 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/nodes/AccountNode.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/nodes/AccountNode.svelte @@ -8,7 +8,7 @@ + {#if solanaRunIx} + (solanaRunIx = null)} + /> + {/if} {:else} Date: Wed, 6 May 2026 22:41:45 +0200 Subject: [PATCH 051/115] feat(frontend): Cmd+K palette publishes Solana instructions and execute actions - in Solana mode the palette lists each instruction as a jump-to-canvas entry plus an Execute action that opens the run panel - account types are reachable as their own palette entries - Solidity flow keeps the existing publisher untouched --- .../src/routes/contract/[name]/+page.svelte | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte index ea8adbf..80777a5 100644 --- a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte +++ b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte @@ -1356,6 +1356,54 @@ // is global, so on route unmount we clear the list to avoid leaking // stale handlers back to the next page. $effect(() => { + if (kind === 'solana' && solanaProgram) { + const prog = solanaProgram; + const cmds: Command[] = []; + cmds.push({ + id: 'canvas:center', + label: 'Center canvas', + category: 'Action', + icon: '⊙', + keywords: ['fit', 'zoom', 'reset view'], + run: () => { flowApi?.fitView({ padding: 0.1 }); }, + }); + for (const ix of prog.instructions ?? []) { + cmds.push({ + id: `solana-ix:${ix.name}`, + label: ix.name, + category: 'Function', + icon: 'ƒ', + detail: `${(ix.args ?? []).length} args · ${(ix.accounts ?? []).length} accounts`, + keywords: ['instruction', 'jump', 'canvas'], + run: () => handleSolanaIxAdd(ix.name), + }); + cmds.push({ + id: `solana-run:${ix.name}`, + label: `Execute ${ix.name}`, + category: 'Action', + icon: '▶', + keywords: ['call', 'execute', 'instruction', 'run'], + run: () => handleSolanaRun(ix.name), + }); + } + for (const a of prog.account_types ?? []) { + cmds.push({ + id: `solana-acc:${a.name}`, + label: a.name, + category: 'Contract', + icon: '◇', + detail: 'account type', + keywords: ['account', 'type'], + run: () => { + const node = findNode(`account:${a.name}`); + if (node && flowApi) flowApi.fitView({ nodes: [{ id: node.id }], padding: 0.5, duration: 400 }); + }, + }); + } + setPaletteCommands(cmds); + return; + } + if (!contract) { setPaletteCommands([]); return; From 38c255310338236ee11ac9976313ac427c6eb384 Mon Sep 17 00:00:00 2001 From: scab24 Date: Wed, 6 May 2026 22:45:17 +0200 Subject: [PATCH 052/115] chore(workspace): refresh Cargo.lock for ilold-web deps - ilold-web gains ilold-session-core, solana-address and solana-keypair as direct deps; Cargo.lock now records the resolved versions --- Cargo.lock | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 595ab50..7d0b5dd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2443,11 +2443,14 @@ dependencies = [ "axum", "futures-util", "ilold-core", + "ilold-session-core", "ilold-solana-core", "portable-pty", "reqwest", "serde", "serde_json", + "solana-address 2.6.0", + "solana-keypair", "tokio", "tokio-tungstenite 0.24.0", "tower-http", From 42ff7d3c02bef18672aef05c12c143827f42465c Mon Sep 17 00:00:00 2001 From: scab24 Date: Thu, 7 May 2026 11:15:55 +0200 Subject: [PATCH 053/115] feat(frontend): TopBar, StatusBar and session sidebar parity for Solana - TopBar accepts a kind prop and renders Program / Trace mode buttons when kind is solana, preserving the Solidity flow untouched - mode union extended with program and trace; the contract page passes kind solana and dispatches Solana Back / Clear via postSolanaCommand on the existing session-back and session-clear callbacks - SolanaSessionSidebar mirrors the Solidity SessionSidebar with three tabs: Steps lists TraceNodes from the graph store, Users lists keypairs and supports new + airdrop, Inspector reuses NodeInspector with the onsolanarun hook - EmbeddedTerminal mounts inside the sidebar so the WS PTY is available next to the canvas - StatusBar renders counts and shortcuts in Solana mode without changes --- .../src/lib/components/contract/TopBar.svelte | 65 +-- .../session/SolanaSessionSidebar.svelte | 370 ++++++++++++++++++ .../src/routes/contract/[name]/+page.svelte | 87 ++-- 3 files changed, 470 insertions(+), 52 deletions(-) create mode 100644 crates/ilold-web/frontend/src/lib/components/session/SolanaSessionSidebar.svelte diff --git a/crates/ilold-web/frontend/src/lib/components/contract/TopBar.svelte b/crates/ilold-web/frontend/src/lib/components/contract/TopBar.svelte index a0eb42a..f8da74a 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/TopBar.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/TopBar.svelte @@ -9,6 +9,7 @@ contractName, mode, seqDirection, + kind = 'solidity', onmodechange, onsearch, oncenter, @@ -17,15 +18,14 @@ onsessionclear, }: { contractName: string; - mode: 'cfg' | 'sequences' | 'session'; + mode: 'cfg' | 'sequences' | 'session' | 'program' | 'trace'; seqDirection: 'TB' | 'LR'; - onmodechange: (mode: 'cfg' | 'sequences' | 'session') => void; + kind?: 'solidity' | 'solana'; + onmodechange: (mode: 'cfg' | 'sequences' | 'session' | 'program' | 'trace') => void; onsearch: () => void; oncenter: () => void; onseqdirection: (dir: 'TB' | 'LR') => void; - /** Remove the last step of the active scenario (REPL `b`). */ onsessionback: () => void; - /** Clear every step of the active scenario (REPL `cl`). */ onsessionclear: () => void; } = $props(); @@ -56,26 +56,43 @@ -
- - - -
+ {#if kind === 'solana'} +
+ + +
+ {:else} +
+ + + +
+ {/if} {#if mode === 'sequences'} diff --git a/crates/ilold-web/frontend/src/lib/components/session/SolanaSessionSidebar.svelte b/crates/ilold-web/frontend/src/lib/components/session/SolanaSessionSidebar.svelte new file mode 100644 index 0000000..27c10a7 --- /dev/null +++ b/crates/ilold-web/frontend/src/lib/components/session/SolanaSessionSidebar.svelte @@ -0,0 +1,370 @@ + + +
+
+ + + +
+ +
+ {#if activeTab === 'steps'} + {#if traceSteps.length === 0} +
+
No steps yet
+
Click an instruction in the sidebar and press Execute, or run call <ix> <json> in the terminal.
+
+ {/if} + {#each traceSteps as step (step.stepIndex)} +
+
+ #{step.stepIndex} + {step.instruction} + {#if step.error} + err + {/if} +
+
+ {step.computeUnits} CU + {step.diffsCount} diffs + {step.scenario} +
+ {#if step.logsExcerpt && step.logsExcerpt.length > 0} +
{step.logsExcerpt.slice(0, 5).join('\n')}
+ {/if} +
+ {/each} + {:else if activeTab === 'users'} +
+
+ + + +
+ {#if userError} +
{userError}
+ {/if} +
+ + {#if users.length === 0} +
+
No users yet
+
Create one above or run users new <name> in the terminal. Names autocomplete in the run panel.
+
+ {/if} + {#each users as u (u.name)} +
+
+ {u.name} + {u.lamports.toLocaleString()} lamports +
+
{u.pubkey}
+ +
+ {/each} + {:else} + null} + onpathselect={() => {}} + onexpandcfg={() => {}} + {onsolanarun} + /> + {/if} +
+ + +
+ + diff --git a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte index 80777a5..4358dfa 100644 --- a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte +++ b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte @@ -18,6 +18,7 @@ import InstructionSidebar from '$lib/components/contract/InstructionSidebar.svelte'; import SolanaRunPanel from '$lib/components/contract/SolanaRunPanel.svelte'; import NodeInspector from '$lib/components/contract/NodeInspector.svelte'; + import SolanaSessionSidebar from '$lib/components/session/SolanaSessionSidebar.svelte'; import { composeProgramGraph } from '$lib/canvas/program'; import TopBar from '$lib/components/contract/TopBar.svelte'; import StatusBar from '$lib/components/contract/StatusBar.svelte'; @@ -58,7 +59,7 @@ // Default: Seq mode is the auditor-friendly view; Session mode flips the // sidebar click into "add step" and hides exploration nodes on the canvas // so only the scenarios tree is visible. - let mode: 'cfg' | 'sequences' | 'session' = $state('sequences'); + let mode: 'cfg' | 'sequences' | 'session' | 'program' | 'trace' = $state('sequences'); let seqTree: any = $state(null); let seqAnalysis: SequenceAnalysis | null = $state(null); let seqExpanded: Map = $state(new Map()); @@ -1524,21 +1525,39 @@ {#if error}
{error}
{:else if kind === 'solana' && solanaProgram} -
- ilold - / - {solanaProgram.name} - solana program - {solanaProgram.program_id} -
- -
-
+ { mode = m; }} + onsearch={togglePalette} + oncenter={() => flowApi?.fitView({ padding: 0.1 })} + onseqdirection={() => {}} + onsessionback={async () => { + try { + await postSolanaCommand('Back', solanaProgram.name); + } catch (e) { + alert(`session back failed:\n\n${e instanceof Error ? e.message : String(e)}`); + } + }} + onsessionclear={async () => { + try { + await postSolanaCommand('Clear', solanaProgram.name); + solanaTraceCount = 0; + const composed = composeProgramGraph(solanaProgram); + setNodes(composed.nodes); + setEdges(composed.edges); + } catch (e) { + alert(`session clear failed:\n\n${e instanceof Error ? e.message : String(e)}`); + } + }} + />
handleSolanaIxAdd(ix)} onremove={(ix) => handleSolanaIxRemove(ix)} /> @@ -1552,23 +1571,35 @@ onselectionchange={(nodes) => { selectionCount = nodes.length; }} onready={(api) => { flowApi = api; }} /> - + { + const result = await postSolanaCommand( + { UsersNew: { name, lamports } }, + solanaProgram.name, + ); + if (result?.Error) throw new Error(result.Error.message ?? 'create user failed'); + await refreshSolanaUsers(); + }} + onairdrop={async (name, lamports) => { + await postSolanaCommand( + { Airdrop: { user: name, lamports } }, + solanaProgram.name, + ); + await refreshSolanaUsers(); + }} + />
+ {#if solanaRunIx} Date: Thu, 7 May 2026 11:20:50 +0200 Subject: [PATCH 054/115] feat(frontend): ContextMenu branches for Solana node types - ContextMenu accepts an optional onsolanarun callback and renders an Execute action when nodeType is instruction - account and trace nodes get a Remove from canvas entry - contract page mounts ContextMenu in Solana mode and routes Remove to handleSolanaIxRemove for ix: nodes --- .../components/contract/ContextMenu.svelte | 23 ++++++++++++++++++- .../src/routes/contract/[name]/+page.svelte | 23 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/ilold-web/frontend/src/lib/components/contract/ContextMenu.svelte b/crates/ilold-web/frontend/src/lib/components/contract/ContextMenu.svelte index 22d91b1..2b6b41e 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/ContextMenu.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/ContextMenu.svelte @@ -26,10 +26,12 @@ /** Open the file in the user's IDE (VS Code / Cursor / fork) via the * `vscode://` URL scheme. */ onopenide: (funcName: string) => void; + /** Solana-only: trigger the Run panel for an instruction node. */ + onsolanarun?: (instructionName: string) => void; onclose: () => void; } - let { menu, expandedFuncs, seqExpanded, mode, onexpandcfg, onremovefunc, onremovenode, onforkscenario, onremovefromhere, onviewsource, onopenide, onclose }: Props = $props(); + let { menu, expandedFuncs, seqExpanded, mode, onexpandcfg, onremovefunc, onremovenode, onforkscenario, onremovefromhere, onviewsource, onopenide, onsolanarun, onclose }: Props = $props(); // View source / Open in code are read-only operations meaningful for // any canvas node that identifies a function. Shown in every mode. @@ -95,6 +97,25 @@ > ✕ Remove node + {:else if menu.nodeType === 'instruction'} + {#if onsolanarun} + + {/if} + + {:else if menu.nodeType === 'account' || menu.nodeType === 'trace'} + {:else if showCanvasActions && menu.nodeType === 'block'} - {#if sidebarOpen} - Instructions - {filtered.length} / {rows.length} - {/if} - - {#if sidebarOpen} -
- -
- - -
-
-
- {#each filtered as row (row.name)} - {@const onCanvas = canvasInstructions.has(row.name)} -
- - {#if onCanvas && mode !== 'session'} - - {/if} -
- {/each} -
- {#if program.account_types.length > 0} -
-
Account types
- {#each program.account_types as a (a.name)} -
{a.name}
- {/each} -
- {/if} - {/if} - - - diff --git a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte index 297332d..0a42f48 100644 --- a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte +++ b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte @@ -15,7 +15,6 @@ import { postCommand, postSolanaCommand } from '$lib/api/session'; import Legend from '$lib/components/contract/Legend.svelte'; import FunctionSidebar from '$lib/components/contract/FunctionSidebar.svelte'; - import InstructionSidebar from '$lib/components/contract/InstructionSidebar.svelte'; import SolanaRunPanel from '$lib/components/contract/SolanaRunPanel.svelte'; import NodeInspector from '$lib/components/contract/NodeInspector.svelte'; import SolanaSessionSidebar from '$lib/components/session/SolanaSessionSidebar.svelte'; @@ -1554,10 +1553,11 @@ }} />
- handleSolanaIxAdd(ix)} onremove={(ix) => handleSolanaIxRemove(ix)} /> From 46d854fe0f74e17bf2353f9ab58b92842d6e716f Mon Sep 17 00:00:00 2001 From: scab24 Date: Thu, 7 May 2026 12:21:38 +0200 Subject: [PATCH 056/115] refactor(frontend): unify SessionSidebar with kind prop, drop SolanaSessionSidebar - single SessionSidebar covers Solidity Timeline / State / Inspector and Solana steps + state with users embedded - Solana branch in Timeline tab lists TraceNodes from the graph store sorted by stepIndex; Solana branch in State tab embeds the user form with create + airdrop and a list with balances - contract page mounts SessionSidebar in both branches with kind passthrough; the parallel SolanaSessionSidebar file is removed --- .../components/session/SessionSidebar.svelte | 297 +++++++++++++- .../session/SolanaSessionSidebar.svelte | 370 ------------------ .../src/routes/contract/[name]/+page.svelte | 9 +- 3 files changed, 294 insertions(+), 382 deletions(-) delete mode 100644 crates/ilold-web/frontend/src/lib/components/session/SolanaSessionSidebar.svelte diff --git a/crates/ilold-web/frontend/src/lib/components/session/SessionSidebar.svelte b/crates/ilold-web/frontend/src/lib/components/session/SessionSidebar.svelte index 0fdd0ad..baa2267 100644 --- a/crates/ilold-web/frontend/src/lib/components/session/SessionSidebar.svelte +++ b/crates/ilold-web/frontend/src/lib/components/session/SessionSidebar.svelte @@ -7,12 +7,10 @@ import { getScenarios, getActiveScenario } from '$lib/stores/session.svelte'; import { promptScenarioName } from '$lib/scenarios/name'; import { dispatchScenarioAction } from '$lib/scenarios/dispatch'; + import { getNodes } from '$lib/stores/graph.svelte'; + import type { ProgramDetail } from '$lib/api/rest'; + import type { TraceNodeData } from '$lib/stores/graph.svelte'; - // Inspector props are optional so existing callers don't break, but - // +page.svelte passes them all as part of F4 to replace the floating - // NodeDetailPanel. When `selectedNode` is null the Inspector shows an - // empty state in-place instead of the panel disappearing — the tab - // itself stays available. let { contract, selectedNode = null, @@ -26,6 +24,12 @@ lookupBlock = () => null, onpathselect = () => {}, onexpandcfg = () => {}, + kind = 'solidity', + program = null, + solanaUsers = [], + onsolanarun = () => {}, + onnewuser = async () => {}, + onairdrop = async () => {}, }: { contract: string; selectedNode?: any; @@ -33,14 +37,59 @@ funcPaths?: Record; expandedFuncs?: Set; seqExpanded?: Map; - mode?: 'cfg' | 'sequences' | 'session'; + mode?: 'cfg' | 'sequences' | 'session' | 'program' | 'trace'; seqAnalysis?: any; contractDetail?: { name: string; functions?: any[] } | null; lookupBlock?: (blockId: string) => { statements: string[]; node_type: string } | null; onpathselect?: (funcName: string, path: any) => void; onexpandcfg?: (funcName: string, nodeId?: string) => void; + kind?: 'solidity' | 'solana'; + program?: ProgramDetail | null; + solanaUsers?: { name: string; pubkey: string; lamports: number }[]; + onsolanarun?: (instruction: string) => void; + onnewuser?: (name: string, lamports: number) => Promise; + onairdrop?: (name: string, lamports: number) => Promise; } = $props(); + let newUserName = $state(''); + let newUserLamports = $state(10_000_000_000); + let pendingUser = $state(false); + let userError = $state(null); + + const traceSteps = $derived.by(() => { + if (kind !== 'solana') return []; + return getNodes() + .filter((n) => (n.data as any)?._type === 'trace') + .map((n) => n.data as TraceNodeData) + .sort((a, b) => a.stepIndex - b.stepIndex); + }); + + async function handleCreateUser(e: SubmitEvent) { + e.preventDefault(); + userError = null; + if (!newUserName.trim()) { + userError = 'name required'; + return; + } + pendingUser = true; + try { + await onnewuser(newUserName.trim(), newUserLamports); + newUserName = ''; + } catch (e) { + userError = e instanceof Error ? e.message : String(e); + } finally { + pendingUser = false; + } + } + + async function topUp(name: string) { + try { + await onairdrop(name, 1_000_000_000); + } catch (e) { + alert(`airdrop failed:\n\n${e instanceof Error ? e.message : String(e)}`); + } + } + let open = $state(true); let activeTab: 'timeline' | 'state' | 'inspector' = $state('timeline'); @@ -237,9 +286,79 @@
{#if activeTab === 'timeline'} - + {#if kind === 'solana'} + {#if traceSteps.length === 0} +
+
No steps yet
+
Click an instruction in the sidebar and press Execute, or run call <ix> <json> in the terminal.
+
+ {/if} + {#each traceSteps as step (step.stepIndex)} +
+
+ #{step.stepIndex} + {step.instruction} + {#if step.error}err{/if} +
+
+ {step.computeUnits} CU + {step.diffsCount} diffs + {step.scenario} +
+ {#if step.logsExcerpt && step.logsExcerpt.length > 0} +
{step.logsExcerpt.slice(0, 5).join('\n')}
+ {/if} +
+ {/each} + {:else} + + {/if} {:else if activeTab === 'state'} - + {#if kind === 'solana' && program} +
+
+ + + +
+ {#if userError} +
{userError}
+ {/if} +
+ + {#if solanaUsers.length === 0} +
+
No users yet
+
Create one above or run users new <name> in the terminal.
+
+ {/if} + {#each solanaUsers as u (u.name)} +
+
+ {u.name} + {u.lamports.toLocaleString()} lamports +
+
{u.pubkey}
+ +
+ {/each} + {:else} + + {/if} {:else if contractDetail} {/if} @@ -261,3 +381,164 @@
+ + diff --git a/crates/ilold-web/frontend/src/lib/components/session/SolanaSessionSidebar.svelte b/crates/ilold-web/frontend/src/lib/components/session/SolanaSessionSidebar.svelte deleted file mode 100644 index 27c10a7..0000000 --- a/crates/ilold-web/frontend/src/lib/components/session/SolanaSessionSidebar.svelte +++ /dev/null @@ -1,370 +0,0 @@ - - -
-
- - - -
- -
- {#if activeTab === 'steps'} - {#if traceSteps.length === 0} -
-
No steps yet
-
Click an instruction in the sidebar and press Execute, or run call <ix> <json> in the terminal.
-
- {/if} - {#each traceSteps as step (step.stepIndex)} -
-
- #{step.stepIndex} - {step.instruction} - {#if step.error} - err - {/if} -
-
- {step.computeUnits} CU - {step.diffsCount} diffs - {step.scenario} -
- {#if step.logsExcerpt && step.logsExcerpt.length > 0} -
{step.logsExcerpt.slice(0, 5).join('\n')}
- {/if} -
- {/each} - {:else if activeTab === 'users'} -
-
- - - -
- {#if userError} -
{userError}
- {/if} -
- - {#if users.length === 0} -
-
No users yet
-
Create one above or run users new <name> in the terminal. Names autocomplete in the run panel.
-
- {/if} - {#each users as u (u.name)} -
-
- {u.name} - {u.lamports.toLocaleString()} lamports -
-
{u.pubkey}
- -
- {/each} - {:else} - null} - onpathselect={() => {}} - onexpandcfg={() => {}} - {onsolanarun} - /> - {/if} -
- - -
- - diff --git a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte index 0a42f48..f5e6d9e 100644 --- a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte +++ b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte @@ -16,8 +16,6 @@ import Legend from '$lib/components/contract/Legend.svelte'; import FunctionSidebar from '$lib/components/contract/FunctionSidebar.svelte'; import SolanaRunPanel from '$lib/components/contract/SolanaRunPanel.svelte'; - import NodeInspector from '$lib/components/contract/NodeInspector.svelte'; - import SolanaSessionSidebar from '$lib/components/session/SolanaSessionSidebar.svelte'; import { composeProgramGraph } from '$lib/canvas/program'; import TopBar from '$lib/components/contract/TopBar.svelte'; import StatusBar from '$lib/components/contract/StatusBar.svelte'; @@ -1571,10 +1569,13 @@ onselectionchange={(nodes) => { selectionCount = nodes.length; }} onready={(api) => { flowApi = api; }} /> - { const result = await postSolanaCommand( From 5a6eb2faf5e00df2330860a12356279448c72dd1 Mon Sep 17 00:00:00 2001 From: scab24 Date: Thu, 7 May 2026 12:24:09 +0200 Subject: [PATCH 057/115] refactor(frontend): embed Solana run form in NodeInspector, drop modal - SolanaRunForm replaces SolanaRunPanel as a plain inline form - NodeInspector instruction branch renders the form when program + onsolanasubmit are provided, falling back to the legacy Execute button otherwise - contract page hands the form submit straight to handleSolanaSubmit; selecting an instruction in the canvas jumps the inspector to it without a modal --- .../components/contract/NodeInspector.svelte | 31 +- .../components/contract/SolanaRunForm.svelte | 232 ++++++++++++++ .../components/contract/SolanaRunPanel.svelte | 290 ------------------ .../components/session/SessionSidebar.svelte | 8 + .../src/routes/contract/[name]/+page.svelte | 44 +-- 5 files changed, 287 insertions(+), 318 deletions(-) create mode 100644 crates/ilold-web/frontend/src/lib/components/contract/SolanaRunForm.svelte delete mode 100644 crates/ilold-web/frontend/src/lib/components/contract/SolanaRunPanel.svelte diff --git a/crates/ilold-web/frontend/src/lib/components/contract/NodeInspector.svelte b/crates/ilold-web/frontend/src/lib/components/contract/NodeInspector.svelte index a37ac73..41a96b7 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/NodeInspector.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/NodeInspector.svelte @@ -1,11 +1,7 @@ + +
+ + {#if (ix.args ?? []).length === 0} +
no args declared
+ {/if} + {#each ix.args ?? [] as arg} + + {/each} + + + {#each ix.accounts ?? [] as acc} + + {/each} + + {#if users.length > 0} +
Users: {users.map((u) => u.name).join(', ')}
+ {/if} + {#if errorMsg} +
{errorMsg}
+ {/if} + + +
in {program.name}
+
+ + diff --git a/crates/ilold-web/frontend/src/lib/components/contract/SolanaRunPanel.svelte b/crates/ilold-web/frontend/src/lib/components/contract/SolanaRunPanel.svelte deleted file mode 100644 index 6658c94..0000000 --- a/crates/ilold-web/frontend/src/lib/components/contract/SolanaRunPanel.svelte +++ /dev/null @@ -1,290 +0,0 @@ - - - - - diff --git a/crates/ilold-web/frontend/src/lib/components/session/SessionSidebar.svelte b/crates/ilold-web/frontend/src/lib/components/session/SessionSidebar.svelte index baa2267..c510ef3 100644 --- a/crates/ilold-web/frontend/src/lib/components/session/SessionSidebar.svelte +++ b/crates/ilold-web/frontend/src/lib/components/session/SessionSidebar.svelte @@ -30,6 +30,7 @@ onsolanarun = () => {}, onnewuser = async () => {}, onairdrop = async () => {}, + onsolanasubmit, }: { contract: string; selectedNode?: any; @@ -49,6 +50,10 @@ onsolanarun?: (instruction: string) => void; onnewuser?: (name: string, lamports: number) => Promise; onairdrop?: (name: string, lamports: number) => Promise; + onsolanasubmit?: ( + ix: any, + payload: { args: Record; accounts: Record; signers: string[] }, + ) => Promise; } = $props(); let newUserName = $state(''); @@ -373,6 +378,9 @@ {onpathselect} {onsolanarun} {onexpandcfg} + {program} + {solanaUsers} + {onsolanasubmit} /> {/if}
diff --git a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte index f5e6d9e..763040d 100644 --- a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte +++ b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte @@ -15,7 +15,6 @@ import { postCommand, postSolanaCommand } from '$lib/api/session'; import Legend from '$lib/components/contract/Legend.svelte'; import FunctionSidebar from '$lib/components/contract/FunctionSidebar.svelte'; - import SolanaRunPanel from '$lib/components/contract/SolanaRunPanel.svelte'; import { composeProgramGraph } from '$lib/canvas/program'; import TopBar from '$lib/components/contract/TopBar.svelte'; import StatusBar from '$lib/components/contract/StatusBar.svelte'; @@ -41,8 +40,7 @@ let solanaProgram: ProgramDetail | null = $state(null); let kind: 'solidity' | 'solana' = $state('solidity'); let solanaCanvasIxs: Set = $state(new Set()); - let solanaRunIx: any = $state(null); - let solanaUsers: { name: string; pubkey: string }[] = $state([]); + let solanaUsers: { name: string; pubkey: string; lamports: number }[] = $state([]); let solanaTraceCount = $state(0); let error: string | null = $state(null); let selectedNode: any = $state(null); @@ -571,7 +569,15 @@ if (!solanaProgram) return; const ix = solanaProgram.instructions.find((i) => i.name === name); if (!ix) return; - solanaRunIx = ix; + selectedNode = { ...findNode(`ix:${ix.name}`)?.data, id: `ix:${ix.name}` } as any; + flowApi?.fitView({ nodes: [{ id: `ix:${ix.name}` }], padding: 0.5, duration: 400 }); + } + + async function handleSolanaSubmitFromInspector( + ix: any, + payload: { args: Record; accounts: Record; signers: string[] }, + ) { + await handleSolanaSubmit(payload, ix); } async function refreshSolanaUsers() { @@ -584,13 +590,18 @@ } catch {} } - async function handleSolanaSubmit(payload: { - args: Record; - accounts: Record; - signers: string[]; - }) { - if (!solanaProgram || !solanaRunIx) return; - const ixName = solanaRunIx.name; + async function handleSolanaSubmit( + payload: { + args: Record; + accounts: Record; + signers: string[]; + }, + targetIx?: any, + ) { + if (!solanaProgram) return; + const ix = targetIx; + if (!ix) return; + const ixName = ix.name; const result = await postSolanaCommand( { Call: { @@ -635,7 +646,6 @@ }); solanaTraceCount += 1; } - solanaRunIx = null; await refreshSolanaUsers(); } @@ -1577,6 +1587,7 @@ contractDetail={{ name: solanaProgram.name, functions: [] }} solanaUsers={solanaUsers} onsolanarun={handleSolanaRun} + onsolanasubmit={handleSolanaSubmitFromInspector} onnewuser={async (name, lamports) => { const result = await postSolanaCommand( { UsersNew: { name, lamports } }, @@ -1601,15 +1612,6 @@ activeScenario={getActiveScenario()} {selectionCount} /> - {#if solanaRunIx} - (solanaRunIx = null)} - /> - {/if} Date: Thu, 7 May 2026 12:31:23 +0200 Subject: [PATCH 058/115] refactor(frontend): single template tree parametrized by kind - contract page mounts TopBar + FunctionSidebar + GraphCanvasFlow + SessionSidebar + StatusBar + ContextMenu in one structure with kind-aware props - handleSidebarAdd, handleSessionBack and handleSessionClear branch by kind so the toolbar works the same for Solidity and Solana - ContextMenu remove dispatches handleSolanaIxRemove for ix: nodes and removeSeqNode for the rest --- .../src/routes/contract/[name]/+page.svelte | 200 +++++++----------- 1 file changed, 76 insertions(+), 124 deletions(-) diff --git a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte index 763040d..fc1f948 100644 --- a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte +++ b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte @@ -756,6 +756,10 @@ } async function handleSidebarAdd(funcName: string) { + if (kind === 'solana') { + handleSolanaIxAdd(funcName); + return; + } if (mode === 'session') { try { await postCommand({ Call: { func: funcName } }, contract?.name); @@ -967,8 +971,15 @@ ); } - // Toolbar ↶: remove the last step of the active scenario. async function handleSessionBack() { + if (kind === 'solana' && solanaProgram) { + try { + await postSolanaCommand('Back', solanaProgram.name); + } catch (e) { + notifyFailure('session back', e); + } + return; + } try { await postCommand('Back', contract?.name); } catch (e) { @@ -976,8 +987,19 @@ } } - // Toolbar 🗑: wipe every step of the active scenario (with confirm). async function handleSessionClear() { + if (kind === 'solana' && solanaProgram) { + try { + await postSolanaCommand('Clear', solanaProgram.name); + solanaTraceCount = 0; + const composed = composeProgramGraph(solanaProgram); + setNodes(composed.nodes); + setEdges(composed.edges); + } catch (e) { + notifyFailure('session clear', e); + } + return; + } const active = getActiveScenario(); const steps = getScenarios().get(active) ?? []; if (steps.length === 0) return; @@ -1531,180 +1553,110 @@
{#if error}
{error}
- {:else if kind === 'solana' && solanaProgram} + {:else if contract || (kind === 'solana' && solanaProgram)} { mode = m; }} + {kind} + onmodechange={(m) => { + if (kind === 'solana') { mode = m; } else { switchMode(m); } + }} onsearch={togglePalette} oncenter={() => flowApi?.fitView({ padding: 0.1 })} - onseqdirection={() => {}} - onsessionback={async () => { - try { - await postSolanaCommand('Back', solanaProgram.name); - } catch (e) { - alert(`session back failed:\n\n${e instanceof Error ? e.message : String(e)}`); - } - }} - onsessionclear={async () => { - try { - await postSolanaCommand('Clear', solanaProgram.name); - solanaTraceCount = 0; - const composed = composeProgramGraph(solanaProgram); - setNodes(composed.nodes); - setEdges(composed.edges); - } catch (e) { - alert(`session clear failed:\n\n${e instanceof Error ? e.message : String(e)}`); - } - }} + onseqdirection={(dir) => { seqDirection = dir; reorientAllSeqSubtrees(); }} + onsessionback={handleSessionBack} + onsessionclear={handleSessionClear} />
handleSolanaIxAdd(ix)} - onremove={(ix) => handleSolanaIxRemove(ix)} + onadd={handleSidebarAdd} + onremove={kind === 'solana' ? handleSolanaIxRemove : removeFuncFromCanvas} /> + handleNodeClick(node, event)} onbackgroundtap={handleBackgroundTap} oncontextmenu={handleContextMenu} onnodesdelete={handleNodesDelete} - canDeleteNodes={true} + canDeleteNodes={mode !== 'session'} onselectionchange={(nodes) => { selectionCount = nodes.length; }} onready={(api) => { flowApi = api; }} /> + { + const node = findNode(blockId); + if (!node || node.data._type !== 'block') return null; + return { statements: (node.data as any).statements ?? [], node_type: (node.data as any).node_type }; + }} + onpathselect={(funcName, path) => { selectedPath = path; highlightPath(funcName, path); }} + onexpandcfg={(funcName, nodeId) => toggleFuncExpand(funcName, nodeId)} solanaUsers={solanaUsers} onsolanarun={handleSolanaRun} onsolanasubmit={handleSolanaSubmitFromInspector} onnewuser={async (name, lamports) => { - const result = await postSolanaCommand( - { UsersNew: { name, lamports } }, - solanaProgram.name, - ); + if (!solanaProgram) return; + const result = await postSolanaCommand({ UsersNew: { name, lamports } }, solanaProgram.name); if (result?.Error) throw new Error(result.Error.message ?? 'create user failed'); await refreshSolanaUsers(); }} onairdrop={async (name, lamports) => { - await postSolanaCommand( - { Airdrop: { user: name, lamports } }, - solanaProgram.name, - ); + if (!solanaProgram) return; + await postSolanaCommand({ Airdrop: { user: name, lamports } }, solanaProgram.name); await refreshSolanaUsers(); }} />
+ + { contextMenu = null; }} - onremovefunc={() => { contextMenu = null; }} + {mode} + onexpandcfg={(func, nodeId) => { toggleFuncExpand(func, nodeId); contextMenu = null; }} + onremovefunc={(func) => { removeFuncFromCanvas(func); contextMenu = null; selectedNode = null; }} onremovenode={(nodeId) => { if (nodeId.startsWith('ix:')) { handleSolanaIxRemove(nodeId.slice(3)); } else { - removeNodesById([nodeId]); + removeSeqNode(nodeId); } contextMenu = null; selectedNode = null; }} - onforkscenario={() => { contextMenu = null; }} - onremovefromhere={() => { contextMenu = null; }} - onviewsource={() => { contextMenu = null; }} - onopenide={() => { contextMenu = null; }} - onsolanarun={(name) => { handleSolanaRun(name); contextMenu = null; }} - onclose={() => (contextMenu = null)} - /> - {:else} - flowApi?.fitView({ padding: 0.1 })} - onseqdirection={(dir) => { seqDirection = dir; reorientAllSeqSubtrees(); }} - onsessionback={handleSessionBack} - onsessionclear={handleSessionClear} - /> -
- {#if contract} - - {/if} - - handleNodeClick(node, event)} - onbackgroundtap={handleBackgroundTap} - oncontextmenu={handleContextMenu} - onnodesdelete={handleNodesDelete} - canDeleteNodes={mode !== 'session'} - onselectionchange={(nodes) => { selectionCount = nodes.length; }} - onready={(api) => { flowApi = api; }} - /> - - {#if contract} - { - const node = findNode(blockId); - if (!node || node.data._type !== 'block') return null; - return { statements: (node.data as any).statements ?? [], node_type: (node.data as any).node_type }; - }} - onpathselect={(funcName, path) => { selectedPath = path; highlightPath(funcName, path); }} - onexpandcfg={(funcName, nodeId) => toggleFuncExpand(funcName, nodeId)} - /> - {/if} -
- - - - { toggleFuncExpand(func, nodeId); contextMenu = null; }} - onremovefunc={(func) => { removeFuncFromCanvas(func); contextMenu = null; selectedNode = null; }} - onremovenode={(nodeId) => { removeSeqNode(nodeId); contextMenu = null; selectedNode = null; }} onforkscenario={handleForkScenario} onremovefromhere={handleRemoveFromHere} onviewsource={handleViewSource} onopenide={handleOpenInIde} + onsolanarun={(name) => { handleSolanaRun(name); contextMenu = null; }} onclose={() => contextMenu = null} /> From 59503ab777d6f372869ca73852af77aa79cfe650 Mon Sep 17 00:00:00 2001 From: scab24 Date: Thu, 7 May 2026 12:45:45 +0200 Subject: [PATCH 059/115] refactor(frontend): Solana canvas matches Solidity behavior with cfg/sequences/session modes - canvas starts empty in cfg mode; click instruction in sidebar adds it; click the InstructionNode expands its account nodes with edges - sequences mode paints all instructions plus dashed transition edges between instructions that share at least one account, mirroring Solidity sequenceAnalysis.transitions - session mode keeps TraceNodes appearing after each Call execution - TopBar reuses cfg/sequences/session segments for both backends; switchMode clears the graph and repaints sequences when switching into it --- .../contract/FunctionSidebar.svelte | 2 +- .../src/lib/components/contract/TopBar.svelte | 61 +++--- .../components/session/SessionSidebar.svelte | 2 +- .../src/routes/contract/[name]/+page.svelte | 174 ++++++++++++++++-- 4 files changed, 181 insertions(+), 58 deletions(-) diff --git a/crates/ilold-web/frontend/src/lib/components/contract/FunctionSidebar.svelte b/crates/ilold-web/frontend/src/lib/components/contract/FunctionSidebar.svelte index 037c904..b1bdb7d 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/FunctionSidebar.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/FunctionSidebar.svelte @@ -24,7 +24,7 @@ contract?: ContractDetail | null; program?: ProgramDetail | null; canvasFuncs: Set; - mode: 'cfg' | 'sequences' | 'session' | 'program' | 'trace'; + mode: 'cfg' | 'sequences' | 'session'; kind?: 'solidity' | 'solana'; onadd: (entry: string) => void; onremove: (entry: string) => void; diff --git a/crates/ilold-web/frontend/src/lib/components/contract/TopBar.svelte b/crates/ilold-web/frontend/src/lib/components/contract/TopBar.svelte index f8da74a..c0c65aa 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/TopBar.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/TopBar.svelte @@ -18,10 +18,10 @@ onsessionclear, }: { contractName: string; - mode: 'cfg' | 'sequences' | 'session' | 'program' | 'trace'; + mode: 'cfg' | 'sequences' | 'session'; seqDirection: 'TB' | 'LR'; kind?: 'solidity' | 'solana'; - onmodechange: (mode: 'cfg' | 'sequences' | 'session' | 'program' | 'trace') => void; + onmodechange: (mode: 'cfg' | 'sequences' | 'session') => void; onsearch: () => void; oncenter: () => void; onseqdirection: (dir: 'TB' | 'LR') => void; @@ -56,43 +56,26 @@ - {#if kind === 'solana'} -
- - -
- {:else} -
- - - -
- {/if} +
+ + + +
{#if mode === 'sequences'} diff --git a/crates/ilold-web/frontend/src/lib/components/session/SessionSidebar.svelte b/crates/ilold-web/frontend/src/lib/components/session/SessionSidebar.svelte index c510ef3..88cb5fd 100644 --- a/crates/ilold-web/frontend/src/lib/components/session/SessionSidebar.svelte +++ b/crates/ilold-web/frontend/src/lib/components/session/SessionSidebar.svelte @@ -38,7 +38,7 @@ funcPaths?: Record; expandedFuncs?: Set; seqExpanded?: Map; - mode?: 'cfg' | 'sequences' | 'session' | 'program' | 'trace'; + mode?: 'cfg' | 'sequences' | 'session'; seqAnalysis?: any; contractDetail?: { name: string; functions?: any[] } | null; lookupBlock?: (blockId: string) => { statements: string[]; node_type: string } | null; diff --git a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte index fc1f948..8cf49a5 100644 --- a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte +++ b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte @@ -40,6 +40,7 @@ let solanaProgram: ProgramDetail | null = $state(null); let kind: 'solidity' | 'solana' = $state('solidity'); let solanaCanvasIxs: Set = $state(new Set()); + let solanaExpandedIxs: Set = $state(new Set()); let solanaUsers: { name: string; pubkey: string; lamports: number }[] = $state([]); let solanaTraceCount = $state(0); let error: string | null = $state(null); @@ -54,7 +55,7 @@ // Default: Seq mode is the auditor-friendly view; Session mode flips the // sidebar click into "add step" and hides exploration nodes on the canvas // so only the scenarios tree is visible. - let mode: 'cfg' | 'sequences' | 'session' | 'program' | 'trace' = $state('sequences'); + let mode: 'cfg' | 'sequences' | 'session' = $state('sequences'); let seqTree: any = $state(null); let seqAnalysis: SequenceAnalysis | null = $state(null); let seqExpanded: Map = $state(new Map()); @@ -550,19 +551,150 @@ if (node) selectedNode = node; }); - function handleSolanaIxAdd(ix: string) { - solanaCanvasIxs = new Set([...solanaCanvasIxs, ix]); - const node = findNode(`ix:${ix}`); - if (node && flowApi) { - flowApi.fitView({ nodes: [{ id: node.id }], padding: 0.5, duration: 400 }); + function handleSolanaIxAdd(ixName: string) { + if (!solanaProgram) return; + if (solanaCanvasIxs.has(ixName)) { + const existing = findNode(`ix:${ixName}`); + if (existing && flowApi) flowApi.fitView({ nodes: [{ id: existing.id }], padding: 0.5, duration: 400 }); + return; } + const ix = solanaProgram.instructions.find((i) => i.name === ixName); + if (!ix) return; + const idx = solanaCanvasIxs.size; + addNode({ + id: `ix:${ixName}`, + type: 'instruction', + position: { x: idx * 220, y: 200 }, + data: { + _type: 'instruction', + label: ixName, + programName: solanaProgram.name, + programId: solanaProgram.program_id, + argsCount: (ix.args ?? []).length, + accountsCount: (ix.accounts ?? []).length, + hasPdas: (ix.accounts ?? []).some((a: any) => a.pda != null), + signers: (ix.accounts ?? []).filter((a: any) => a.signer).map((a: any) => a.name), + }, + } as any); + solanaCanvasIxs = new Set([...solanaCanvasIxs, ixName]); + if (flowApi) flowApi.fitView({ nodes: [{ id: `ix:${ixName}` }], padding: 0.5, duration: 400 }); } - function handleSolanaIxRemove(ix: string) { + function handleSolanaIxRemove(ixName: string) { const next = new Set(solanaCanvasIxs); - next.delete(ix); + next.delete(ixName); solanaCanvasIxs = next; - removeNodesById([`ix:${ix}`]); + const ids: string[] = [`ix:${ixName}`]; + for (const n of getNodes()) { + const data: any = n.data; + if (data?._type === 'account' && data.parentInstruction === ixName) ids.push(n.id); + } + removeNodesById(ids); + solanaExpandedIxs = new Set([...solanaExpandedIxs].filter((n) => n !== ixName)); + } + + function paintSequencesMode() { + if (!solanaProgram) return; + clearGraph(); + solanaCanvasIxs = new Set(); + solanaExpandedIxs = new Set(); + + const ixs = solanaProgram.instructions ?? []; + const accountUsage = new Map>(); + for (const ix of ixs) { + for (const acc of ix.accounts ?? []) { + const key = acc.name; + if (!accountUsage.has(key)) accountUsage.set(key, new Set()); + accountUsage.get(key)!.add(ix.name); + } + } + + const newNodes: any[] = ixs.map((ix, i) => ({ + id: `ix:${ix.name}`, + type: 'instruction', + position: { x: i * 220, y: 200 }, + data: { + _type: 'instruction', + label: ix.name, + programName: solanaProgram!.name, + programId: solanaProgram!.program_id, + argsCount: (ix.args ?? []).length, + accountsCount: (ix.accounts ?? []).length, + hasPdas: (ix.accounts ?? []).some((a: any) => a.pda != null), + signers: (ix.accounts ?? []).filter((a: any) => a.signer).map((a: any) => a.name), + }, + })); + + const newEdges: any[] = []; + const seen = new Set(); + for (const [, sharingSet] of accountUsage) { + const arr = Array.from(sharingSet); + for (let i = 0; i < arr.length; i++) { + for (let j = 0; j < arr.length; j++) { + if (i === j) continue; + const key = `${arr[i]}->${arr[j]}`; + if (seen.has(key)) continue; + seen.add(key); + newEdges.push({ + id: `transition:${key}`, + source: `ix:${arr[i]}`, + target: `ix:${arr[j]}`, + animated: false, + style: 'stroke: var(--color-accent-hover); stroke-dasharray: 4 3; opacity: 0.6;', + }); + } + } + } + + setNodes(newNodes); + setEdges(newEdges); + solanaCanvasIxs = new Set(ixs.map((i) => i.name)); + } + + function handleSolanaIxExpand(ixName: string) { + if (!solanaProgram) return; + if (solanaExpandedIxs.has(ixName)) { + const ids: string[] = []; + for (const n of getNodes()) { + const data: any = n.data; + if (data?._type === 'account' && data.parentInstruction === ixName) ids.push(n.id); + } + if (ids.length > 0) removeNodesById(ids); + solanaExpandedIxs = new Set([...solanaExpandedIxs].filter((n) => n !== ixName)); + return; + } + const ix = solanaProgram.instructions.find((i) => i.name === ixName); + if (!ix) return; + const parent = findNode(`ix:${ixName}`); + const baseX = parent?.position?.x ?? 0; + const baseY = (parent?.position?.y ?? 200) - 160; + const newNodes: any[] = []; + const newEdges: any[] = []; + (ix.accounts ?? []).forEach((acc: any, i: number) => { + const id = `ix:${ixName}:acc:${acc.name}`; + newNodes.push({ + id, + type: 'account', + position: { x: baseX + (i - (ix.accounts.length - 1) / 2) * 150, y: baseY }, + data: { + _type: 'account', + label: acc.name, + programName: solanaProgram!.name, + parentInstruction: ixName, + fields: acc.signer ? [{ name: 'signer', type: 'true' }] : [], + }, + }); + newEdges.push({ + id: `e:${ixName}:${acc.name}`, + source: `ix:${ixName}`, + sourceHandle: 't', + target: id, + targetHandle: 'b', + }); + }); + if (newNodes.length > 0) addNodes(newNodes); + if (newEdges.length > 0) addEdges(newEdges); + solanaExpandedIxs = new Set([...solanaExpandedIxs, ixName]); } function handleSolanaRun(name: string) { @@ -673,11 +805,8 @@ return; } projectMap = []; - const composed = composeProgramGraph(solanaProgram); - setNodes(composed.nodes); - setEdges(composed.edges); - solanaCanvasIxs = new Set(solanaProgram.instructions.map((i) => i.name)); await refreshSolanaUsers(); + if (mode === 'sequences') paintSequencesMode(); return; } projectMap = pm.contracts ?? []; @@ -1082,6 +1211,10 @@ // deliberately hide in this mode. if (mode === 'session') return; const d = node.data; + if (d._type === 'instruction') { + handleSolanaIxExpand(d.label as string); + return; + } if (d._type === 'function' && !d.is_external) { await handleFunctionTap(d.label, node.id); } else if (d._type === 'seq-next') { @@ -1328,7 +1461,16 @@ } function switchMode(newMode: 'cfg' | 'sequences' | 'session') { - // Remove expanded nodes from graph store + if (kind === 'solana') { + clearGraph(); + solanaCanvasIxs = new Set(); + solanaExpandedIxs = new Set(); + solanaTraceCount = 0; + selectedNode = null; + mode = newMode; + if (newMode === 'sequences') paintSequencesMode(); + return; + } const toRemove = new Set(); for (const n of getNodes()) { if (n.data._type === 'block' || n.data._type === 'seq-next') { @@ -1559,9 +1701,7 @@ {mode} {seqDirection} {kind} - onmodechange={(m) => { - if (kind === 'solana') { mode = m; } else { switchMode(m); } - }} + onmodechange={switchMode} onsearch={togglePalette} oncenter={() => flowApi?.fitView({ padding: 0.1 })} onseqdirection={(dir) => { seqDirection = dir; reorientAllSeqSubtrees(); }} From 0e608b7d830e425275909e27f6b80b7a67b1597c Mon Sep 17 00:00:00 2001 From: scab24 Date: Thu, 7 May 2026 12:55:05 +0200 Subject: [PATCH 060/115] fix(frontend): Solana sequences edges direction and CFG expand for all accounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - transition edges in sequences mode now run only forward (lower index to higher) with arrowclosed markers and right→left handles, removing the figure-eight loop - expand instruction is gated to cfg mode so clicking in sequences keeps the overview clean - account expand uses Set for removeNodesById, lays out all account nodes horizontally centered above the parent and includes signer + writable flags in the AccountNode fields --- .../src/routes/contract/[name]/+page.svelte | 45 +++++++++++++------ 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte index 8cf49a5..2f1926a 100644 --- a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte +++ b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte @@ -584,10 +584,10 @@ const next = new Set(solanaCanvasIxs); next.delete(ixName); solanaCanvasIxs = next; - const ids: string[] = [`ix:${ixName}`]; + const ids = new Set([`ix:${ixName}`]); for (const n of getNodes()) { const data: any = n.data; - if (data?._type === 'account' && data.parentInstruction === ixName) ids.push(n.id); + if (data?._type === 'account' && data.parentInstruction === ixName) ids.add(n.id); } removeNodesById(ids); solanaExpandedIxs = new Set([...solanaExpandedIxs].filter((n) => n !== ixName)); @@ -627,20 +627,29 @@ const newEdges: any[] = []; const seen = new Set(); + const ixIndex = new Map(ixs.map((ix, i) => [ix.name, i])); for (const [, sharingSet] of accountUsage) { const arr = Array.from(sharingSet); for (let i = 0; i < arr.length; i++) { for (let j = 0; j < arr.length; j++) { if (i === j) continue; - const key = `${arr[i]}->${arr[j]}`; + const a = arr[i]; + const b = arr[j]; + const ai = ixIndex.get(a) ?? 0; + const bi = ixIndex.get(b) ?? 0; + if (ai >= bi) continue; + const key = `${a}->${b}`; if (seen.has(key)) continue; seen.add(key); newEdges.push({ id: `transition:${key}`, - source: `ix:${arr[i]}`, - target: `ix:${arr[j]}`, + source: `ix:${a}`, + sourceHandle: 'r', + target: `ix:${b}`, + targetHandle: 'l', animated: false, - style: 'stroke: var(--color-accent-hover); stroke-dasharray: 4 3; opacity: 0.6;', + style: 'stroke: var(--color-accent-hover); stroke-dasharray: 4 3; opacity: 0.7;', + markerEnd: { type: 'arrowclosed', color: 'var(--color-accent-hover)' }, }); } } @@ -654,12 +663,12 @@ function handleSolanaIxExpand(ixName: string) { if (!solanaProgram) return; if (solanaExpandedIxs.has(ixName)) { - const ids: string[] = []; + const ids = new Set(); for (const n of getNodes()) { const data: any = n.data; - if (data?._type === 'account' && data.parentInstruction === ixName) ids.push(n.id); + if (data?._type === 'account' && data.parentInstruction === ixName) ids.add(n.id); } - if (ids.length > 0) removeNodesById(ids); + if (ids.size > 0) removeNodesById(ids); solanaExpandedIxs = new Set([...solanaExpandedIxs].filter((n) => n !== ixName)); return; } @@ -667,21 +676,28 @@ if (!ix) return; const parent = findNode(`ix:${ixName}`); const baseX = parent?.position?.x ?? 0; - const baseY = (parent?.position?.y ?? 200) - 160; + const baseY = (parent?.position?.y ?? 200) - 200; + const accounts = ix.accounts ?? []; + const totalWidth = (accounts.length - 1) * 170; const newNodes: any[] = []; const newEdges: any[] = []; - (ix.accounts ?? []).forEach((acc: any, i: number) => { + accounts.forEach((acc: any, i: number) => { const id = `ix:${ixName}:acc:${acc.name}`; newNodes.push({ id, type: 'account', - position: { x: baseX + (i - (ix.accounts.length - 1) / 2) * 150, y: baseY }, + position: { x: baseX - totalWidth / 2 + i * 170, y: baseY }, data: { _type: 'account', label: acc.name, programName: solanaProgram!.name, parentInstruction: ixName, - fields: acc.signer ? [{ name: 'signer', type: 'true' }] : [], + fields: acc.signer || acc.writable + ? [ + ...(acc.signer ? [{ name: 'signer', type: 'true' }] : []), + ...(acc.writable ? [{ name: 'writable', type: 'true' }] : []), + ] + : [], }, }); newEdges.push({ @@ -690,6 +706,7 @@ sourceHandle: 't', target: id, targetHandle: 'b', + markerEnd: { type: 'arrowclosed', color: 'var(--color-accent-hover)' }, }); }); if (newNodes.length > 0) addNodes(newNodes); @@ -1211,7 +1228,7 @@ // deliberately hide in this mode. if (mode === 'session') return; const d = node.data; - if (d._type === 'instruction') { + if (d._type === 'instruction' && mode === 'cfg') { handleSolanaIxExpand(d.label as string); return; } From 231f9882e7dbdab4eec9a5105c2a3e1871af7b05 Mon Sep 17 00:00:00 2001 From: scab24 Date: Thu, 7 May 2026 13:06:08 +0200 Subject: [PATCH 061/115] fix(frontend): Session mode temporal flow with directional arrows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - TraceNodes carry _sessionStep:true so the existing visibility effect hides every non-session node in Session mode, removing the InstructionNode overview noise - consecutive trace steps are linked with arrowclosed edges r→l between trace[N-1] and trace[N], laying out as a horizontal timeline at fixed y - canvas auto-fits after each new step --- .../src/routes/contract/[name]/+page.svelte | 31 ++++++++++++------- 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte index 2f1926a..ead424b 100644 --- a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte +++ b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte @@ -768,32 +768,39 @@ if (result?.StepAdded) { const sa = result.StepAdded; const id = `trace:${sa.step_index}`; - const ixNode = findNode(`ix:${ixName}`); - const baseX = ixNode?.position?.x ?? 0; - const baseY = (ixNode?.position?.y ?? 320) + 180; - const offsetX = solanaTraceCount * 40; + const stepIndex = sa.step_index; + const x = stepIndex * 240; + const y = 200; addNode({ id, type: 'trace', - position: { x: baseX + offsetX, y: baseY }, + position: { x, y }, data: { _type: 'trace', - label: `${sa.instruction} #${sa.step_index}`, - stepIndex: sa.step_index, + label: `${sa.instruction} #${stepIndex}`, + stepIndex, instruction: sa.instruction, computeUnits: sa.compute_units ?? 0, diffsCount: sa.account_diffs_count ?? 0, logsExcerpt: sa.logs_excerpt ?? [], scenario: getActiveScenario() ?? 'main', error: null, + _sessionStep: true, }, } as any); - addEdge({ - id: `e:${`ix:${ixName}`}->${id}`, - source: `ix:${ixName}`, - target: id, - }); + if (stepIndex > 0) { + addEdge({ + id: `e:trace:${stepIndex - 1}->trace:${stepIndex}`, + source: `trace:${stepIndex - 1}`, + sourceHandle: 'r', + target: id, + targetHandle: 'l', + markerEnd: { type: 'arrowclosed', color: 'var(--color-accent)' }, + style: 'stroke: var(--color-accent); stroke-width: 2;', + } as any); + } solanaTraceCount += 1; + if (flowApi) flowApi.fitView({ padding: 0.2, duration: 400 }); } await refreshSolanaUsers(); } From 6763f38208e1f6b6d5e1561f9292ea246145e9e3 Mon Sep 17 00:00:00 2001 From: scab24 Date: Thu, 7 May 2026 13:09:22 +0200 Subject: [PATCH 062/115] test(fixtures): minimal staking Anchor program fixture - pool with admin, total_staked, total_rewards and reward_rate fields - five instructions: initialize_pool, stake, unstake, add_rewards and claim_rewards - realistic shared-account flow useful to demonstrate Solana sequence and session modes in the UI --- tests/fixtures/solana/staking/.gitignore | 1 + tests/fixtures/solana/staking/Anchor.toml | 16 ++ tests/fixtures/solana/staking/Cargo.toml | 14 ++ .../staking/programs/staking/Cargo.toml | 26 ++++ .../staking/programs/staking/Xargo.toml | 2 + .../staking/programs/staking/src/lib.rs | 139 ++++++++++++++++++ 6 files changed, 198 insertions(+) create mode 100644 tests/fixtures/solana/staking/.gitignore create mode 100644 tests/fixtures/solana/staking/Anchor.toml create mode 100644 tests/fixtures/solana/staking/Cargo.toml create mode 100644 tests/fixtures/solana/staking/programs/staking/Cargo.toml create mode 100644 tests/fixtures/solana/staking/programs/staking/Xargo.toml create mode 100644 tests/fixtures/solana/staking/programs/staking/src/lib.rs diff --git a/tests/fixtures/solana/staking/.gitignore b/tests/fixtures/solana/staking/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/tests/fixtures/solana/staking/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/tests/fixtures/solana/staking/Anchor.toml b/tests/fixtures/solana/staking/Anchor.toml new file mode 100644 index 0000000..2499e2c --- /dev/null +++ b/tests/fixtures/solana/staking/Anchor.toml @@ -0,0 +1,16 @@ +[toolchain] +solana_version = "3.1.8" + +[features] +resolution = true +skip-lint = false + +[programs.localnet] +staking = "AQjgX8bsAU8CvvEoP9q36vAbT83dRxdjK4zGEhhn6SFc" + +[provider] +cluster = "Localnet" +wallet = "~/.config/solana/id.json" + +[scripts] +test = "pnpm ts-mocha -p ./tsconfig.json -t 1000000 tests/**/*.ts" diff --git a/tests/fixtures/solana/staking/Cargo.toml b/tests/fixtures/solana/staking/Cargo.toml new file mode 100644 index 0000000..f397704 --- /dev/null +++ b/tests/fixtures/solana/staking/Cargo.toml @@ -0,0 +1,14 @@ +[workspace] +members = [ + "programs/*" +] +resolver = "2" + +[profile.release] +overflow-checks = true +lto = "fat" +codegen-units = 1 +[profile.release.build-override] +opt-level = 3 +incremental = false +codegen-units = 1 diff --git a/tests/fixtures/solana/staking/programs/staking/Cargo.toml b/tests/fixtures/solana/staking/programs/staking/Cargo.toml new file mode 100644 index 0000000..6a03794 --- /dev/null +++ b/tests/fixtures/solana/staking/programs/staking/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "staking" +version = "0.1.0" +description = "Minimal staking program for Ilold Solana fixture" +edition = "2021" + +[lib] +crate-type = ["cdylib", "lib"] +name = "staking" + +[features] +default = [] +cpi = ["no-entrypoint"] +no-entrypoint = [] +no-idl = [] +no-log-ix-name = [] +idl-build = ["anchor-lang/idl-build"] +anchor-debug = [] +custom-heap = [] +custom-panic = [] + +[dependencies] +anchor-lang = "1.0.0" + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } diff --git a/tests/fixtures/solana/staking/programs/staking/Xargo.toml b/tests/fixtures/solana/staking/programs/staking/Xargo.toml new file mode 100644 index 0000000..475fb71 --- /dev/null +++ b/tests/fixtures/solana/staking/programs/staking/Xargo.toml @@ -0,0 +1,2 @@ +[target.bpfel-unknown-unknown.dependencies.std] +features = [] diff --git a/tests/fixtures/solana/staking/programs/staking/src/lib.rs b/tests/fixtures/solana/staking/programs/staking/src/lib.rs new file mode 100644 index 0000000..1622203 --- /dev/null +++ b/tests/fixtures/solana/staking/programs/staking/src/lib.rs @@ -0,0 +1,139 @@ +use anchor_lang::prelude::*; + +declare_id!("AQjgX8bsAU8CvvEoP9q36vAbT83dRxdjK4zGEhhn6SFc"); + +#[program] +pub mod staking { + use super::*; + + pub fn initialize_pool(ctx: Context, reward_rate: u64) -> Result<()> { + let pool = &mut ctx.accounts.pool; + pool.admin = ctx.accounts.admin.key(); + pool.total_staked = 0; + pool.total_rewards = 0; + pool.reward_rate = reward_rate; + msg!("Pool initialized by {} with reward_rate={}", pool.admin, reward_rate); + Ok(()) + } + + pub fn stake(ctx: Context, amount: u64) -> Result<()> { + require!(amount > 0, StakingError::ZeroAmount); + let pool = &mut ctx.accounts.pool; + let user_stake = &mut ctx.accounts.user_stake; + if user_stake.user == Pubkey::default() { + user_stake.user = ctx.accounts.user.key(); + user_stake.amount = 0; + user_stake.claimed_rewards = 0; + } + user_stake.amount = user_stake.amount.checked_add(amount).ok_or(StakingError::Overflow)?; + pool.total_staked = pool.total_staked.checked_add(amount).ok_or(StakingError::Overflow)?; + msg!("{} staked {} (user_total={}, pool_total={})", user_stake.user, amount, user_stake.amount, pool.total_staked); + Ok(()) + } + + pub fn unstake(ctx: Context, amount: u64) -> Result<()> { + let pool = &mut ctx.accounts.pool; + let user_stake = &mut ctx.accounts.user_stake; + require!(user_stake.user == ctx.accounts.user.key(), StakingError::WrongUser); + require!(amount > 0, StakingError::ZeroAmount); + require!(user_stake.amount >= amount, StakingError::InsufficientBalance); + user_stake.amount -= amount; + pool.total_staked -= amount; + msg!("{} unstaked {} (user_remaining={}, pool_total={})", user_stake.user, amount, user_stake.amount, pool.total_staked); + Ok(()) + } + + pub fn add_rewards(ctx: Context, amount: u64) -> Result<()> { + let pool = &mut ctx.accounts.pool; + require!(pool.admin == ctx.accounts.admin.key(), StakingError::WrongAdmin); + pool.total_rewards = pool.total_rewards.checked_add(amount).ok_or(StakingError::Overflow)?; + msg!("Admin added {} rewards (total_rewards={})", amount, pool.total_rewards); + Ok(()) + } + + pub fn claim_rewards(ctx: Context) -> Result<()> { + let pool = &mut ctx.accounts.pool; + let user_stake = &mut ctx.accounts.user_stake; + require!(user_stake.user == ctx.accounts.user.key(), StakingError::WrongUser); + require!(pool.total_staked > 0, StakingError::EmptyPool); + let share = (pool.total_rewards as u128) + .checked_mul(user_stake.amount as u128) + .ok_or(StakingError::Overflow)? + .checked_div(pool.total_staked as u128) + .ok_or(StakingError::Overflow)?; + let pending = (share as u64).saturating_sub(user_stake.claimed_rewards); + user_stake.claimed_rewards = user_stake.claimed_rewards.checked_add(pending).ok_or(StakingError::Overflow)?; + msg!("{} claimed {} rewards (lifetime={})", user_stake.user, pending, user_stake.claimed_rewards); + Ok(()) + } +} + +#[derive(Accounts)] +pub struct InitializePool<'info> { + #[account(init, payer = admin, space = 8 + 32 + 8 + 8 + 8)] + pub pool: Account<'info, Pool>, + #[account(mut)] + pub admin: Signer<'info>, + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +pub struct Stake<'info> { + #[account(mut)] + pub pool: Account<'info, Pool>, + #[account(init_if_needed, payer = user, space = 8 + 32 + 8 + 8)] + pub user_stake: Account<'info, UserStake>, + #[account(mut)] + pub user: Signer<'info>, + pub system_program: Program<'info, System>, +} + +#[derive(Accounts)] +pub struct Unstake<'info> { + #[account(mut)] + pub pool: Account<'info, Pool>, + #[account(mut)] + pub user_stake: Account<'info, UserStake>, + pub user: Signer<'info>, +} + +#[derive(Accounts)] +pub struct AddRewards<'info> { + #[account(mut)] + pub pool: Account<'info, Pool>, + pub admin: Signer<'info>, +} + +#[derive(Accounts)] +pub struct ClaimRewards<'info> { + #[account(mut)] + pub pool: Account<'info, Pool>, + #[account(mut)] + pub user_stake: Account<'info, UserStake>, + pub user: Signer<'info>, +} + +#[account] +pub struct Pool { + pub admin: Pubkey, + pub total_staked: u64, + pub total_rewards: u64, + pub reward_rate: u64, +} + +#[account] +pub struct UserStake { + pub user: Pubkey, + pub amount: u64, + pub claimed_rewards: u64, +} + +#[error_code] +pub enum StakingError { + #[msg("Amount must be greater than zero")] ZeroAmount, + #[msg("Arithmetic overflow")] Overflow, + #[msg("Insufficient staked balance")] InsufficientBalance, + #[msg("Wrong user for this UserStake account")] WrongUser, + #[msg("Wrong admin for this pool")] WrongAdmin, + #[msg("Pool has no stakers")] EmptyPool, +} From 3bfcd8e8eda7ae3a31203f43f2cb286b755ff3a1 Mon Sep 17 00:00:00 2001 From: scab24 Date: Thu, 7 May 2026 13:14:39 +0200 Subject: [PATCH 063/115] test(fixtures): vendor staking .so + idl, allow bin/ fallback for vendored .so - staking program builds with init-if-needed feature for the user_stake account - ingest.find_so falls back to a bin/ directory when target/deploy is empty so committed fixtures work without an anchor build - staking artefacts (idl + so) committed under idls/ and bin/ so anyone can serve the fixture out of the box --- crates/ilold-solana-core/src/ingest.rs | 18 +- tests/fixtures/solana/staking/bin/staking.so | Bin 0 -> 190608 bytes .../fixtures/solana/staking/idls/staking.json | 281 ++++++++++++++++++ .../staking/programs/staking/Cargo.toml | 2 +- 4 files changed, 295 insertions(+), 6 deletions(-) create mode 100755 tests/fixtures/solana/staking/bin/staking.so create mode 100644 tests/fixtures/solana/staking/idls/staking.json diff --git a/crates/ilold-solana-core/src/ingest.rs b/crates/ilold-solana-core/src/ingest.rs index b60745e..a46bf98 100644 --- a/crates/ilold-solana-core/src/ingest.rs +++ b/crates/ilold-solana-core/src/ingest.rs @@ -114,17 +114,25 @@ pub fn find_idls(anchor_root: &Path) -> Vec { } pub fn find_so(anchor_root: &Path) -> Vec { - let dir = anchor_root.join("target").join("deploy"); - let mut found: Vec = match std::fs::read_dir(&dir) { + let preferred = anchor_root.join("target").join("deploy"); + let fallback = anchor_root.join("bin"); + let mut found = collect_so(&preferred); + if found.is_empty() { + found = collect_so(&fallback); + } + found.sort(); + found +} + +fn collect_so(dir: &Path) -> Vec { + match std::fs::read_dir(dir) { Ok(entries) => entries .flatten() .map(|e| e.path()) .filter(|p| p.extension().is_some_and(|e| e == "so")) .collect(), Err(_) => Vec::new(), - }; - found.sort(); - found + } } fn collect_jsons(dir: &Path) -> Vec { diff --git a/tests/fixtures/solana/staking/bin/staking.so b/tests/fixtures/solana/staking/bin/staking.so new file mode 100755 index 0000000000000000000000000000000000000000..30cbed07451257109234ba3731a4564f26b07847 GIT binary patch literal 190608 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 z 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 literal 0 HcmV?d00001 diff --git a/tests/fixtures/solana/staking/idls/staking.json b/tests/fixtures/solana/staking/idls/staking.json new file mode 100644 index 0000000..33d9309 --- /dev/null +++ b/tests/fixtures/solana/staking/idls/staking.json @@ -0,0 +1,281 @@ +{ + "address": "AQjgX8bsAU8CvvEoP9q36vAbT83dRxdjK4zGEhhn6SFc", + "metadata": { + "name": "staking", + "version": "0.1.0", + "spec": "0.1.0", + "description": "Minimal staking program for Ilold Solana fixture" + }, + "instructions": [ + { + "name": "add_rewards", + "discriminator": [ + 88, + 186, + 25, + 227, + 38, + 137, + 81, + 23 + ], + "accounts": [ + { + "name": "pool", + "writable": true + }, + { + "name": "admin", + "signer": true + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + } + ] + }, + { + "name": "claim_rewards", + "discriminator": [ + 4, + 144, + 132, + 71, + 116, + 23, + 151, + 80 + ], + "accounts": [ + { + "name": "pool", + "writable": true + }, + { + "name": "user_stake", + "writable": true + }, + { + "name": "user", + "signer": true + } + ], + "args": [] + }, + { + "name": "initialize_pool", + "discriminator": [ + 95, + 180, + 10, + 172, + 84, + 174, + 232, + 40 + ], + "accounts": [ + { + "name": "pool", + "writable": true, + "signer": true + }, + { + "name": "admin", + "writable": true, + "signer": true + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "reward_rate", + "type": "u64" + } + ] + }, + { + "name": "stake", + "discriminator": [ + 206, + 176, + 202, + 18, + 200, + 209, + 179, + 108 + ], + "accounts": [ + { + "name": "pool", + "writable": true + }, + { + "name": "user_stake", + "writable": true, + "signer": true + }, + { + "name": "user", + "writable": true, + "signer": true + }, + { + "name": "system_program", + "address": "11111111111111111111111111111111" + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + } + ] + }, + { + "name": "unstake", + "discriminator": [ + 90, + 95, + 107, + 42, + 205, + 124, + 50, + 225 + ], + "accounts": [ + { + "name": "pool", + "writable": true + }, + { + "name": "user_stake", + "writable": true + }, + { + "name": "user", + "signer": true + } + ], + "args": [ + { + "name": "amount", + "type": "u64" + } + ] + } + ], + "accounts": [ + { + "name": "Pool", + "discriminator": [ + 241, + 154, + 109, + 4, + 17, + 177, + 109, + 188 + ] + }, + { + "name": "UserStake", + "discriminator": [ + 102, + 53, + 163, + 107, + 9, + 138, + 87, + 153 + ] + } + ], + "errors": [ + { + "code": 6000, + "name": "ZeroAmount", + "msg": "Amount must be greater than zero" + }, + { + "code": 6001, + "name": "Overflow", + "msg": "Arithmetic overflow" + }, + { + "code": 6002, + "name": "InsufficientBalance", + "msg": "Insufficient staked balance" + }, + { + "code": 6003, + "name": "WrongUser", + "msg": "Wrong user for this UserStake account" + }, + { + "code": 6004, + "name": "WrongAdmin", + "msg": "Wrong admin for this pool" + }, + { + "code": 6005, + "name": "EmptyPool", + "msg": "Pool has no stakers" + } + ], + "types": [ + { + "name": "Pool", + "type": { + "kind": "struct", + "fields": [ + { + "name": "admin", + "type": "pubkey" + }, + { + "name": "total_staked", + "type": "u64" + }, + { + "name": "total_rewards", + "type": "u64" + }, + { + "name": "reward_rate", + "type": "u64" + } + ] + } + }, + { + "name": "UserStake", + "type": { + "kind": "struct", + "fields": [ + { + "name": "user", + "type": "pubkey" + }, + { + "name": "amount", + "type": "u64" + }, + { + "name": "claimed_rewards", + "type": "u64" + } + ] + } + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/solana/staking/programs/staking/Cargo.toml b/tests/fixtures/solana/staking/programs/staking/Cargo.toml index 6a03794..fe6017b 100644 --- a/tests/fixtures/solana/staking/programs/staking/Cargo.toml +++ b/tests/fixtures/solana/staking/programs/staking/Cargo.toml @@ -20,7 +20,7 @@ custom-heap = [] custom-panic = [] [dependencies] -anchor-lang = "1.0.0" +anchor-lang = { version = "1.0.0", features = ["init-if-needed"] } [lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(target_os, values("solana"))'] } From 9b7a9283f53aa9b9f88f09030c8a0aaf25f41bff Mon Sep 17 00:00:00 2001 From: scab24 Date: Thu, 7 May 2026 13:19:50 +0200 Subject: [PATCH 064/115] fix(frontend): coerce numeric args using IDL type field - IDL exposes the arg type under `type`, not `ty`; the form was reading undefined and shipping the raw string back to the encoder - coerceArg now converts u8/u16/u32/i8/i16/i32 to Number and leaves u64/u128/i64/i128 as decimal strings (the encoder accepts both shapes) - describeType handles the defined wrapper used for typed account references --- crates/ilold-web/frontend/src/lib/api/rest.ts | 2 +- .../lib/components/contract/SolanaRunForm.svelte | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/crates/ilold-web/frontend/src/lib/api/rest.ts b/crates/ilold-web/frontend/src/lib/api/rest.ts index 2b80ed6..6162291 100644 --- a/crates/ilold-web/frontend/src/lib/api/rest.ts +++ b/crates/ilold-web/frontend/src/lib/api/rest.ts @@ -106,7 +106,7 @@ export interface ProgramDetail { export interface InstructionDef { name: string; discriminator: number[]; - args: { name: string; ty: any }[]; + args: { name: string; type: any }[]; accounts: AccountSpec[]; returns?: any; } diff --git a/crates/ilold-web/frontend/src/lib/components/contract/SolanaRunForm.svelte b/crates/ilold-web/frontend/src/lib/components/contract/SolanaRunForm.svelte index d4dba63..5c10661 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/SolanaRunForm.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/SolanaRunForm.svelte @@ -38,13 +38,19 @@ signerValues = sgn; }); + const SMALL_INTS = new Set(['u8','u16','u32','i8','i16','i32']); + const LARGE_INTS = new Set(['u64','u128','i64','i128']); + function coerceArg(raw: string, ty: any): any { if (typeof ty === 'string') { if (ty === 'bool') return raw === 'true' || raw === '1'; - if (['u8','u16','u32','u64','i8','i16','i32','i64'].includes(ty)) { + if (SMALL_INTS.has(ty)) { const n = Number(raw); return Number.isFinite(n) ? n : raw; } + if (LARGE_INTS.has(ty)) { + return raw; + } } return raw; } @@ -52,6 +58,10 @@ function describeType(ty: any): string { if (typeof ty === 'string') return ty; if (ty == null) return '?'; + if (typeof ty === 'object' && 'defined' in ty) { + const d = (ty as any).defined; + return typeof d === 'string' ? d : (d?.name ?? JSON.stringify(d)); + } return JSON.stringify(ty); } @@ -64,7 +74,7 @@ for (const arg of ix.args ?? []) { const raw = argValues[arg.name] ?? ''; if (raw === '') continue; - args[arg.name] = coerceArg(raw, arg.ty); + args[arg.name] = coerceArg(raw, arg.type); } const accounts: Record = {}; for (const [k, v] of Object.entries(accountValues)) { @@ -99,7 +109,7 @@ class="run-input" type="text" bind:value={argValues[arg.name]} - placeholder={describeType(arg.ty)} + placeholder={describeType(arg.type)} /> {/each} From 7b395271cf53ba5453e53db4f9d4008e4ef6202d Mon Sep 17 00:00:00 2001 From: scab24 Date: Thu, 7 May 2026 13:23:13 +0200 Subject: [PATCH 065/115] fix(frontend): u64/i64 args go as JSON number, only u128+ as decimal string - backend encoder reads u64 via value.as_u64() which only matches JSON number; sending u64 as string mismatched - only u128/i128/u256/i256 are sent as decimal/hex strings since JS Number cannot round-trip past 2^53 --- .../src/lib/components/contract/SolanaRunForm.svelte | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/ilold-web/frontend/src/lib/components/contract/SolanaRunForm.svelte b/crates/ilold-web/frontend/src/lib/components/contract/SolanaRunForm.svelte index 5c10661..859fa23 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/SolanaRunForm.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/SolanaRunForm.svelte @@ -38,17 +38,17 @@ signerValues = sgn; }); - const SMALL_INTS = new Set(['u8','u16','u32','i8','i16','i32']); - const LARGE_INTS = new Set(['u64','u128','i64','i128']); + const NUMBER_INTS = new Set(['u8','u16','u32','u64','i8','i16','i32','i64','f32','f64']); + const STRING_INTS = new Set(['u128','i128','u256','i256']); function coerceArg(raw: string, ty: any): any { if (typeof ty === 'string') { if (ty === 'bool') return raw === 'true' || raw === '1'; - if (SMALL_INTS.has(ty)) { + if (NUMBER_INTS.has(ty)) { const n = Number(raw); return Number.isFinite(n) ? n : raw; } - if (LARGE_INTS.has(ty)) { + if (STRING_INTS.has(ty)) { return raw; } } From 7961904dacf12162a9ca0ecb02ebfc4451def316 Mon Sep 17 00:00:00 2001 From: scab24 Date: Thu, 7 May 2026 16:31:00 +0200 Subject: [PATCH 066/115] fix(frontend): Solana session canvas paints scenario tree with fork edges - the scenario-tree composer now also runs for kind solana, reusing composeScenarioTree on the shared session store - runtime CU/diffs/logs are kept in a per-step map so the trace nodes still carry execution feedback after the tree is repainted - removing the manual TraceNode wiring in handleSolanaSubmit lets the effect own the canvas like the Solidity flow does --- .../src/routes/contract/[name]/+page.svelte | 143 +++++++++++++----- 1 file changed, 109 insertions(+), 34 deletions(-) diff --git a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte index ead424b..ada1125 100644 --- a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte +++ b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte @@ -43,6 +43,13 @@ let solanaExpandedIxs: Set = $state(new Set()); let solanaUsers: { name: string; pubkey: string; lamports: number }[] = $state([]); let solanaTraceCount = $state(0); + type SolanaRuntimeInfo = { + computeUnits: number; + diffsCount: number; + logsExcerpt: string[]; + error?: string | null; + }; + let solanaRuntimeByStep: Map = $state(new Map()); let error: string | null = $state(null); let selectedNode: any = $state(null); let selectedPath: any = $state(null); @@ -408,6 +415,11 @@ const scenarios = getScenarios(); const forkOrigins = getForkOrigins(); const active = getActiveScenario(); + if (kind === 'solana') { + if (!solanaProgram) return; + paintSolanaScenarioTree(scenarios, forkOrigins, active); + return; + } if (!contract || !callgraphRaw) return; const tree = composeScenarioTree(scenarios, forkOrigins); @@ -593,6 +605,93 @@ solanaExpandedIxs = new Set([...solanaExpandedIxs].filter((n) => n !== ixName)); } + function paintSolanaScenarioTree( + scenarios: Map, + forkOrigins: Map, + active: string, + ) { + untrack(() => { + const toRemove = new Set(); + for (const n of getNodes()) { + if (n.id.startsWith('session:') || n.id.startsWith('trace:')) toRemove.add(n.id); + } + if (toRemove.size > 0) removeNodesById(toRemove); + + const tree = composeScenarioTree(scenarios, forkOrigins); + if (tree.nodes.length === 0) { + solanaTraceCount = 0; + return; + } + + const SESSION_BASE_X = 200; + const SESSION_BASE_Y = 200; + const SESSION_STEP_WIDTH = 240; + const SESSION_LANE_HEIGHT = 130; + + const composedNodes = tree.nodes.map((cn) => { + const runtimeKey = `${cn._scenario}:${cn.stepIndex}`; + const runtime = solanaRuntimeByStep.get(runtimeKey); + return { + id: cn.id, + type: 'trace', + position: { + x: SESSION_BASE_X + cn.stepIndex * SESSION_STEP_WIDTH, + y: SESSION_BASE_Y + cn.lane * SESSION_LANE_HEIGHT, + }, + data: { + _type: 'trace', + _sessionStep: true, + _scenario: cn._scenario, + _scenariosPassingThrough: cn._scenariosPassingThrough, + _activeScenario: active, + stepIndex: cn.stepIndex, + label: `${cn.function} #${cn.stepIndex}`, + instruction: cn.function, + scenario: cn._scenario, + computeUnits: runtime?.computeUnits ?? 0, + diffsCount: runtime?.diffsCount ?? 0, + logsExcerpt: runtime?.logsExcerpt ?? [], + error: runtime?.error ?? null, + } as any, + } as Node; + }); + addNodes(composedNodes); + + const nodeScenarios = new Map( + tree.nodes.map((n) => [n.id, n._scenariosPassingThrough]), + ); + + const composedEdges = tree.edges.map((ce) => { + const isFork = ce._forkEdge === true; + const sourceScns = nodeScenarios.get(ce.source) ?? []; + const targetScns = nodeScenarios.get(ce.target) ?? []; + const onActivePath = sourceScns.includes(active) && targetScns.includes(active); + const color = onActivePath + ? (isFork ? 'var(--color-accent)' : 'var(--color-accent-hover)') + : 'var(--color-text-dim)'; + const opacity = onActivePath ? 1 : 0.4; + return { + id: ce.id, + source: ce.source, + target: ce.target, + sourceHandle: 'r', + targetHandle: 'l', + type: 'default', + animated: isFork && onActivePath, + style: `stroke: ${color}; opacity: ${opacity}; ${isFork ? 'stroke-dasharray: 4 4;' : ''}`, + markerEnd: { type: MarkerType.ArrowClosed, width: 12, height: 12, color }, + labelBgStyle: { fill: 'var(--color-surface)', fillOpacity: 0.85 }, + labelBgPadding: [3, 5] as [number, number], + data: { _type: 'session-path', _scenario: ce._scenario, _forkEdge: isFork }, + }; + }); + addEdges(composedEdges); + + solanaTraceCount = tree.nodes.length; + if (flowApi) flowApi.fitView({ padding: 0.2, duration: 300 }); + }); + } + function paintSequencesMode() { if (!solanaProgram) return; clearGraph(); @@ -767,40 +866,16 @@ } if (result?.StepAdded) { const sa = result.StepAdded; - const id = `trace:${sa.step_index}`; - const stepIndex = sa.step_index; - const x = stepIndex * 240; - const y = 200; - addNode({ - id, - type: 'trace', - position: { x, y }, - data: { - _type: 'trace', - label: `${sa.instruction} #${stepIndex}`, - stepIndex, - instruction: sa.instruction, - computeUnits: sa.compute_units ?? 0, - diffsCount: sa.account_diffs_count ?? 0, - logsExcerpt: sa.logs_excerpt ?? [], - scenario: getActiveScenario() ?? 'main', - error: null, - _sessionStep: true, - }, - } as any); - if (stepIndex > 0) { - addEdge({ - id: `e:trace:${stepIndex - 1}->trace:${stepIndex}`, - source: `trace:${stepIndex - 1}`, - sourceHandle: 'r', - target: id, - targetHandle: 'l', - markerEnd: { type: 'arrowclosed', color: 'var(--color-accent)' }, - style: 'stroke: var(--color-accent); stroke-width: 2;', - } as any); - } - solanaTraceCount += 1; - if (flowApi) flowApi.fitView({ padding: 0.2, duration: 400 }); + const scenario = getActiveScenario() ?? 'main'; + const runtimeKey = `${scenario}:${sa.step_index}`; + const next = new Map(solanaRuntimeByStep); + next.set(runtimeKey, { + computeUnits: sa.compute_units ?? 0, + diffsCount: sa.account_diffs_count ?? 0, + logsExcerpt: sa.logs_excerpt ?? [], + error: null, + }); + solanaRuntimeByStep = next; } await refreshSolanaUsers(); } From c4297e7cc1fdf2ea25732ce198a41a01bab5b826 Mon Sep 17 00:00:00 2001 From: scab24 Date: Thu, 7 May 2026 16:39:42 +0200 Subject: [PATCH 067/115] fix(web): get_all_scenarios returns also for Solana, no Solidity gate - the endpoint was failing with 400 "endpoint is Solidity-only" on Solana, which broke frontend resync on connection and after every scenario_forked event - access classification falls back to AccessLevel::Public when there is no Solidity classification map, matching what canvas_patch_from_solana already emits --- crates/ilold-web/src/api/session.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/ilold-web/src/api/session.rs b/crates/ilold-web/src/api/session.rs index fa63783..0a2fbc2 100644 --- a/crates/ilold-web/src/api/session.rs +++ b/crates/ilold-web/src/api/session.rs @@ -872,25 +872,22 @@ pub async fn get_scenarios( pub async fn get_all_scenarios( State(state): State>, ) -> Result, (StatusCode, String)> { - let s = require_solidity_msg(&state)?; + let solidity = state.solidity(); let guard = state.scenarios.read().unwrap(); let active = guard.active().to_string(); let mut scenarios: Vec = Vec::with_capacity(guard.len()); for name in guard.names() { let Some(session) = guard.get(name) else { continue }; - let classifs = s.classifications.get(&session.contract); + let classifs = solidity.and_then(|s| s.classifications.get(&session.contract)); let steps = session .steps .iter() .enumerate() .map(|(idx, step)| { - // Same lookup pattern as `execute_who`'s `access_for` - // (`commands.rs:515-519`): fall back to `Internal` when the - // classification is missing so the response shape is stable. let access = classifs .and_then(|c| c.iter().find(|(n, _)| n == &step.function)) .map(|(_, a)| a.clone()) - .unwrap_or(AccessLevel::Internal); + .unwrap_or(AccessLevel::Public); SessionStepView { function: step.function.clone(), access, From 96b821eb79c67a5466e9fa6d440508ee5cb191e2 Mon Sep 17 00:00:00 2001 From: scab24 Date: Thu, 7 May 2026 16:52:33 +0200 Subject: [PATCH 068/115] fix(frontend): drop fake sequences overview for Solana, start with empty canvas - the sequence overview painted instructions in alphabetical order with edges across every pair that shared an account, suggesting a temporal flow that does not exist in Solana - without source-level CFG analysis there is no honest static transition between Solana instructions, so the canvas now starts empty in cfg and sequences modes and the user adds instructions explicitly via sidebar clicks --- .../src/routes/contract/[name]/+page.svelte | 69 ------------------- 1 file changed, 69 deletions(-) diff --git a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte index ada1125..f441da8 100644 --- a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte +++ b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte @@ -692,73 +692,6 @@ }); } - function paintSequencesMode() { - if (!solanaProgram) return; - clearGraph(); - solanaCanvasIxs = new Set(); - solanaExpandedIxs = new Set(); - - const ixs = solanaProgram.instructions ?? []; - const accountUsage = new Map>(); - for (const ix of ixs) { - for (const acc of ix.accounts ?? []) { - const key = acc.name; - if (!accountUsage.has(key)) accountUsage.set(key, new Set()); - accountUsage.get(key)!.add(ix.name); - } - } - - const newNodes: any[] = ixs.map((ix, i) => ({ - id: `ix:${ix.name}`, - type: 'instruction', - position: { x: i * 220, y: 200 }, - data: { - _type: 'instruction', - label: ix.name, - programName: solanaProgram!.name, - programId: solanaProgram!.program_id, - argsCount: (ix.args ?? []).length, - accountsCount: (ix.accounts ?? []).length, - hasPdas: (ix.accounts ?? []).some((a: any) => a.pda != null), - signers: (ix.accounts ?? []).filter((a: any) => a.signer).map((a: any) => a.name), - }, - })); - - const newEdges: any[] = []; - const seen = new Set(); - const ixIndex = new Map(ixs.map((ix, i) => [ix.name, i])); - for (const [, sharingSet] of accountUsage) { - const arr = Array.from(sharingSet); - for (let i = 0; i < arr.length; i++) { - for (let j = 0; j < arr.length; j++) { - if (i === j) continue; - const a = arr[i]; - const b = arr[j]; - const ai = ixIndex.get(a) ?? 0; - const bi = ixIndex.get(b) ?? 0; - if (ai >= bi) continue; - const key = `${a}->${b}`; - if (seen.has(key)) continue; - seen.add(key); - newEdges.push({ - id: `transition:${key}`, - source: `ix:${a}`, - sourceHandle: 'r', - target: `ix:${b}`, - targetHandle: 'l', - animated: false, - style: 'stroke: var(--color-accent-hover); stroke-dasharray: 4 3; opacity: 0.7;', - markerEnd: { type: 'arrowclosed', color: 'var(--color-accent-hover)' }, - }); - } - } - } - - setNodes(newNodes); - setEdges(newEdges); - solanaCanvasIxs = new Set(ixs.map((i) => i.name)); - } - function handleSolanaIxExpand(ixName: string) { if (!solanaProgram) return; if (solanaExpandedIxs.has(ixName)) { @@ -905,7 +838,6 @@ } projectMap = []; await refreshSolanaUsers(); - if (mode === 'sequences') paintSequencesMode(); return; } projectMap = pm.contracts ?? []; @@ -1567,7 +1499,6 @@ solanaTraceCount = 0; selectedNode = null; mode = newMode; - if (newMode === 'sequences') paintSequencesMode(); return; } const toRemove = new Set(); From 657f5513001634cb3cd9501303cfb081eb70ebf7 Mon Sep 17 00:00:00 2001 From: scab24 Date: Thu, 7 May 2026 17:47:24 +0200 Subject: [PATCH 069/115] feat(ws): broadcast solana_users_changed and refresh in UI - canvas_patch_from_solana now emits SolanaUsersChanged after UsersNew and Airdropped so cross-surface state stays consistent - WS handler ServerMessage gains the SolanaUsersChanged variant; the frontend topic registry whitelists it so the warn-on-unknown does not fire - contract page subscribes to solana_users_changed when kind is solana and re-fetches the registry, keeping the State tab in sync with whatever happens in the TUI --- crates/ilold-session-core/src/exploration/canvas.rs | 1 + crates/ilold-solana-core/src/exploration/commands.rs | 5 +++++ crates/ilold-web/frontend/src/lib/api/types.ts | 7 +++++++ crates/ilold-web/frontend/src/lib/api/ws.ts | 6 ++++++ .../frontend/src/routes/contract/[name]/+page.svelte | 9 +++++++++ crates/ilold-web/src/ws/handler.rs | 5 +++++ 6 files changed, 33 insertions(+) diff --git a/crates/ilold-session-core/src/exploration/canvas.rs b/crates/ilold-session-core/src/exploration/canvas.rs index a9d9306..5961970 100644 --- a/crates/ilold-session-core/src/exploration/canvas.rs +++ b/crates/ilold-session-core/src/exploration/canvas.rs @@ -10,4 +10,5 @@ pub enum CanvasPatch { ClearAll { scenario: String }, Highlight { scenario: String, function: String }, ScenarioEvent(ScenarioEvent), + SolanaUsersChanged { scenario: String }, } diff --git a/crates/ilold-solana-core/src/exploration/commands.rs b/crates/ilold-solana-core/src/exploration/commands.rs index 77e7561..4929891 100644 --- a/crates/ilold-solana-core/src/exploration/commands.rs +++ b/crates/ilold-solana-core/src/exploration/commands.rs @@ -234,6 +234,11 @@ pub fn canvas_patch_from_solana( active: active_scenario.to_string(), }, )), + SolanaCommandResult::UserCreated { .. } | SolanaCommandResult::Airdropped { .. } => { + Some(CanvasPatch::SolanaUsersChanged { + scenario: active_scenario.to_string(), + }) + } _ => None, } } diff --git a/crates/ilold-web/frontend/src/lib/api/types.ts b/crates/ilold-web/frontend/src/lib/api/types.ts index 1973897..02e3992 100644 --- a/crates/ilold-web/frontend/src/lib/api/types.ts +++ b/crates/ilold-web/frontend/src/lib/api/types.ts @@ -136,10 +136,16 @@ export type ServerMessage = | ScenarioDeleted | ScenarioForked | ScenarioStoreReloaded + | SolanaUsersChanged | SearchResult | SearchComplete | SearchError; +export interface SolanaUsersChanged { + type: 'solana_users_changed'; + scenario: string; +} + // ── Connection events (synthetic, frontend-only) ──────────────────────────── export interface ConnectionEvent { @@ -154,6 +160,7 @@ export interface TopicMap { search_result: SearchResult; search_complete: SearchComplete; error: SearchError; + solana_users_changed: SolanaUsersChanged; session_add_node: SessionAddNode; session_remove_node: SessionRemoveNode; session_clear: SessionClear; diff --git a/crates/ilold-web/frontend/src/lib/api/ws.ts b/crates/ilold-web/frontend/src/lib/api/ws.ts index 0175fdf..2077b77 100644 --- a/crates/ilold-web/frontend/src/lib/api/ws.ts +++ b/crates/ilold-web/frontend/src/lib/api/ws.ts @@ -32,6 +32,12 @@ const knownTopics: ReadonlySet = new Set([ 'session_remove_node', 'session_clear', 'session_highlight', + 'scenario_created', + 'scenario_switched', + 'scenario_deleted', + 'scenario_forked', + 'scenario_store_reloaded', + 'solana_users_changed', ]); // ── Pub/Sub core ──────────────────────────────────────────────────────────── diff --git a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte index f441da8..d5099b8 100644 --- a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte +++ b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte @@ -6,6 +6,7 @@ import { toggleTerminal } from '$lib/stores/terminal.svelte'; import { openInIde } from '$lib/utils/ide-links'; import { setSearchContext, getSearchNavigate, setSearchNavigate } from '$lib/stores/search.svelte'; + import { subscribe as subscribeWs } from '$lib/api/ws'; import { togglePalette, setPaletteCommands, clearPaletteCommands } from '$lib/stores/palette.svelte'; import type { Command } from '$lib/commands/registry'; import { getHighlightedFunction, getScenarios, getActiveScenario, getForkOrigins } from '$lib/stores/session.svelte'; @@ -761,6 +762,14 @@ await handleSolanaSubmit(payload, ix); } + $effect(() => { + if (kind !== 'solana' || !solanaProgram) return; + const unsub = subscribeWs('solana_users_changed', () => { + refreshSolanaUsers(); + }); + return () => unsub(); + }); + async function refreshSolanaUsers() { if (!solanaProgram) return; try { diff --git a/crates/ilold-web/src/ws/handler.rs b/crates/ilold-web/src/ws/handler.rs index 6495a9c..200839a 100644 --- a/crates/ilold-web/src/ws/handler.rs +++ b/crates/ilold-web/src/ws/handler.rs @@ -46,6 +46,8 @@ enum ServerMessage { ScenarioForked { from: String, to: String, at_step: usize }, #[serde(rename = "scenario_store_reloaded")] ScenarioStoreReloaded { active: String }, + #[serde(rename = "solana_users_changed")] + SolanaUsersChanged { scenario: String }, } pub async fn ws_handler( @@ -76,6 +78,9 @@ fn server_message_from_patch(patch: CanvasPatch) -> ServerMessage { ServerMessage::ScenarioStoreReloaded { active } } }, + CanvasPatch::SolanaUsersChanged { scenario } => { + ServerMessage::SolanaUsersChanged { scenario } + } } } From 64aeb26b233baf4d188ceb00aa5c9edff62705cc Mon Sep 17 00:00:00 2001 From: scab24 Date: Thu, 7 May 2026 18:00:25 +0200 Subject: [PATCH 070/115] feat(ux): hide constant accounts and concise call syntax for Solana - SolanaRunForm hides accounts whose IDL provides a constant address (system_program, token_program, etc.) and lists them in an Auto-filled footer; the backend still resolves them via spec.address in build_instruction - the form pre-marks the sign checkbox for accounts the IDL declares signer:true so the common case needs no extra clicks; the user can still uncheck to simulate negative signer scenarios - TUI call now accepts call arg=val account=user with auto-distribution against the IDL fetched from /api/program/{name}; signers default to IDL signer:true accounts, --signer=a,b overrides and --no-signer=x removes; the original call {json} form is kept for full control --- crates/ilold-cli/src/explore.rs | 214 ++++++++++++++++-- .../components/contract/SolanaRunForm.svelte | 21 +- 2 files changed, 217 insertions(+), 18 deletions(-) diff --git a/crates/ilold-cli/src/explore.rs b/crates/ilold-cli/src/explore.rs index 1e01f0d..994de60 100644 --- a/crates/ilold-cli/src/explore.rs +++ b/crates/ilold-cli/src/explore.rs @@ -1234,27 +1234,45 @@ fn handle_solana_input( let parts: Vec<&str> = arg.splitn(2, ' ').collect(); if parts.is_empty() || parts[0].is_empty() { println!( - " Usage: call " + " Usage: call [arg=val ...] [account=user_or_pubkey ...]" ); + println!(" or: call {{json}} for full control"); return InputResult::Continue; } - let ix = parts[0]; - let payload = parts.get(1).copied().unwrap_or("{}"); - let parsed: serde_json::Value = match serde_json::from_str(payload) { - Ok(v) => v, - Err(e) => { - println!(" Invalid JSON: {e}"); - return InputResult::Continue; + let ix = parts[0].to_string(); + let payload_raw = parts.get(1).copied().unwrap_or("").trim(); + let body = if payload_raw.starts_with('{') { + let parsed: serde_json::Value = match serde_json::from_str(payload_raw) { + Ok(v) => v, + Err(e) => { + println!(" Invalid JSON: {e}"); + return InputResult::Continue; + } + }; + serde_json::json!({ + "Call": { + "ix": ix, + "args": parsed.get("args").cloned().unwrap_or(serde_json::json!({})), + "accounts": parsed.get("accounts").cloned().unwrap_or(serde_json::json!({})), + "signers": parsed.get("signers").cloned().unwrap_or(serde_json::json!([])), + } + }) + } else { + let program = match fetch_program_detail(handle, client, base_url, contract) { + Ok(p) => p, + Err(e) => { + eprintln!(" {}", c_danger(&format!("fetch program: {e}"))); + return InputResult::Continue; + } + }; + match build_call_from_kv(&program, &ix, payload_raw) { + Ok(body) => body, + Err(e) => { + eprintln!(" {}", c_danger(&e)); + return InputResult::Continue; + } } }; - let body = serde_json::json!({ - "Call": { - "ix": ix, - "args": parsed.get("args").cloned().unwrap_or(serde_json::json!({})), - "accounts": parsed.get("accounts").cloned().unwrap_or(serde_json::json!({})), - "signers": parsed.get("signers").cloned().unwrap_or(serde_json::json!([])), - } - }); dispatch_solana(handle, client, base_url, contract, body, steps) } "ct" | "contracts" | "programs" | "progs" => { @@ -1374,6 +1392,170 @@ fn dispatch_solana( } } +fn fetch_program_detail( + handle: &tokio::runtime::Handle, + client: &reqwest::Client, + base_url: &str, + name: &str, +) -> Result { + handle.block_on(async { + let resp = client + .get(format!("{base_url}/api/program/{name}")) + .send() + .await + .map_err(|e| format!("request: {e}"))?; + if !resp.status().is_success() { + return Err(format!("status {}", resp.status())); + } + resp.json::() + .await + .map_err(|e| format!("parse: {e}")) + }) +} + +fn build_call_from_kv( + program: &serde_json::Value, + ix_name: &str, + rest: &str, +) -> Result { + let ix = program + .get("instructions") + .and_then(|v| v.as_array()) + .and_then(|arr| arr.iter().find(|i| i.get("name").and_then(|n| n.as_str()) == Some(ix_name))) + .ok_or_else(|| format!("instruction '{ix_name}' not found in program"))?; + + let arg_keys: std::collections::HashSet = ix + .get("args") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|a| a.get("name").and_then(|n| n.as_str()).map(String::from)) + .collect() + }) + .unwrap_or_default(); + let arg_types: std::collections::HashMap = ix + .get("args") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|a| { + let n = a.get("name").and_then(|x| x.as_str())?.to_string(); + let t = a.get("type").cloned().unwrap_or(serde_json::Value::Null); + Some((n, t)) + }) + .collect() + }) + .unwrap_or_default(); + let account_keys: std::collections::HashSet = ix + .get("accounts") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|a| a.get("name").and_then(|n| n.as_str()).map(String::from)) + .collect() + }) + .unwrap_or_default(); + let signer_accounts: Vec = ix + .get("accounts") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter(|a| a.get("signer").and_then(|s| s.as_bool()).unwrap_or(false)) + .filter_map(|a| a.get("name").and_then(|n| n.as_str()).map(String::from)) + .collect() + }) + .unwrap_or_default(); + + let mut args = serde_json::Map::new(); + let mut accounts = serde_json::Map::new(); + let mut signer_overrides: Option> = None; + let mut signer_negatives: Vec = Vec::new(); + + for token in rest.split_whitespace() { + if let Some(name_csv) = token.strip_prefix("--no-signer=") { + for n in name_csv.split(',').map(|s| s.trim()) { + if !n.is_empty() { + signer_negatives.push(n.to_string()); + } + } + continue; + } + if let Some(name_csv) = token.strip_prefix("--signer=") { + let mut acc = signer_overrides.unwrap_or_default(); + for n in name_csv.split(',').map(|s| s.trim()) { + if !n.is_empty() { + acc.push(n.to_string()); + } + } + signer_overrides = Some(acc); + continue; + } + let (key, value) = match token.split_once('=') { + Some(kv) => kv, + None => return Err(format!("expected key=value, got '{token}'")), + }; + if arg_keys.contains(key) { + let ty = arg_types.get(key).cloned().unwrap_or(serde_json::Value::Null); + args.insert(key.to_string(), coerce_kv(value, &ty)); + } else if account_keys.contains(key) { + accounts.insert(key.to_string(), serde_json::Value::String(value.to_string())); + } else { + return Err(format!( + "unknown key '{key}'; expected one of args [{}] or accounts [{}]", + arg_keys.iter().cloned().collect::>().join(","), + account_keys.iter().cloned().collect::>().join(",") + )); + } + } + + let signers = match signer_overrides { + Some(list) => list + .into_iter() + .filter(|n| !signer_negatives.contains(n)) + .collect(), + None => signer_accounts + .iter() + .filter_map(|name| { + let resolved = accounts.get(name).and_then(|v| v.as_str()).map(String::from)?; + Some(resolved) + }) + .filter(|n| !signer_negatives.contains(n)) + .collect::>(), + }; + + Ok(serde_json::json!({ + "Call": { + "ix": ix_name, + "args": serde_json::Value::Object(args), + "accounts": serde_json::Value::Object(accounts), + "signers": signers, + } + })) +} + +fn coerce_kv(raw: &str, ty: &serde_json::Value) -> serde_json::Value { + if let Some(s) = ty.as_str() { + match s { + "bool" => return serde_json::Value::Bool(raw == "true" || raw == "1"), + "u8" | "u16" | "u32" | "u64" | "i8" | "i16" | "i32" | "i64" | "f32" | "f64" => { + if let Ok(n) = raw.parse::() { + return serde_json::Value::Number(n.into()); + } + if let Ok(n) = raw.parse::() { + return serde_json::Value::Number(n.into()); + } + if let Ok(f) = raw.parse::() { + if let Some(n) = serde_json::Number::from_f64(f) { + return serde_json::Value::Number(n); + } + } + } + _ => {} + } + } + serde_json::Value::String(raw.to_string()) +} + fn send_solana_command( handle: &tokio::runtime::Handle, client: &reqwest::Client, diff --git a/crates/ilold-web/frontend/src/lib/components/contract/SolanaRunForm.svelte b/crates/ilold-web/frontend/src/lib/components/contract/SolanaRunForm.svelte index 859fa23..05f8bca 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/SolanaRunForm.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/SolanaRunForm.svelte @@ -32,12 +32,15 @@ const sgn: Record = {}; for (const acc of ix.accounts ?? []) { accs[acc.name] = ''; - if (acc.signer) sgn[acc.name] = false; + if (acc.signer) sgn[acc.name] = true; } accountValues = accs; signerValues = sgn; }); + const visibleAccounts = $derived((ix.accounts ?? []).filter((a: any) => !a.address)); + const constantAccounts = $derived((ix.accounts ?? []).filter((a: any) => a.address)); + const NUMBER_INTS = new Set(['u8','u16','u32','u64','i8','i16','i32','i64','f32','f64']); const STRING_INTS = new Set(['u128','i128','u256','i256']); @@ -115,7 +118,7 @@ {/each} - {#each ix.accounts ?? [] as acc} + {#each visibleAccounts as acc} {/each} + {#if constantAccounts.length > 0} +
+ Auto-filled: {constantAccounts.map((a: any) => a.name).join(', ')} +
+ {/if} + {#if users.length > 0}
Users: {users.map((u) => u.name).join(', ')}
{/if} @@ -213,6 +222,14 @@ color: var(--color-text-dim); font-style: italic; } + .run-auto-fill { + font-size: 10px; + color: var(--color-text-dim); + font-style: italic; + background: var(--color-hover); + border-radius: 4px; + padding: 4px 6px; + } .run-error { font-size: 11px; color: var(--color-danger); From a1369b29e8afe64bf0393dcf2c738121bd1388fc Mon Sep 17 00:00:00 2001 From: scab24 Date: Sat, 9 May 2026 09:42:00 +0200 Subject: [PATCH 071/115] chore: ignore internal SDD working notes Local working notes under docs/sdd/ are scratch; only track curated docs under docs/guide/. --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 02243eb..e07c978 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ Thumbs.db # Environment .env + +# Internal SDD working notes +/docs/sdd/ From 665a9c6ce8a6f8bfd656e701faf0ab2e569063ed Mon Sep 17 00:00:00 2001 From: scab24 Date: Sat, 9 May 2026 09:42:24 +0200 Subject: [PATCH 072/115] feat(solana): rewindable VM, replay on load, blockhash rotation, analysis commands Adds a per-step lightweight StateSnapshot stack so Back, Clear and Fork(at_step) restore the VM accounts and Clock instead of leaving stale state behind. LoadSession reboots one VM per scenario and replays each Call from the persisted call_payload, re-airdropping the in-memory users so init constraints succeed; older saves without payload still load (steps preserved, VM at genesis). add_solana_step now expires the LiteSVM blockhash after every send_transaction so consecutive Calls in the same session do not silently fail with cu=0. The session_add_node WS broadcast carries the runtime metadata (compute units, diff count, logs excerpt, error) so calls executed from any client are reflected with real numbers in the canvas. The Solana command surface gains Step, Findings, Export, Who and Timeline so the auditor REPL covers the same analysis flows the guide documents for Solidity (re-inspect a step, list findings, render markdown report, cross-reference an account type, walk the cross-step mutation history of a pubkey). --- .../src/exploration/add_step_solidity.rs | 1 + crates/ilold-core/src/exploration/commands.rs | 1 + crates/ilold-core/src/exploration/timeline.rs | 1 + .../src/exploration/canvas.rs | 21 +- .../src/exploration/session.rs | 10 + crates/ilold-solana-core/src/execute/fork.rs | 66 +++++ crates/ilold-solana-core/src/execute/mod.rs | 2 +- .../src/exploration/add_step.rs | 7 + .../src/exploration/commands.rs | 97 +++++++- .../src/exploration/execute.rs | 229 +++++++++++++++++- .../ilold-solana-core/src/exploration/mod.rs | 7 +- crates/ilold-web/src/api/session.rs | 193 ++++++++++++++- crates/ilold-web/src/state.rs | 10 + crates/ilold-web/src/ws/handler.rs | 13 +- 14 files changed, 635 insertions(+), 23 deletions(-) diff --git a/crates/ilold-core/src/exploration/add_step_solidity.rs b/crates/ilold-core/src/exploration/add_step_solidity.rs index 0626728..adc6cfc 100644 --- a/crates/ilold-core/src/exploration/add_step_solidity.rs +++ b/crates/ilold-core/src/exploration/add_step_solidity.rs @@ -68,6 +68,7 @@ pub fn add_solidity_step<'a>( flow_tree: flow_tree_value, trace_config, runtime_trace: None, + call_payload: None, }); session.journal.record(JournalEntry::SequenceExplored { diff --git a/crates/ilold-core/src/exploration/commands.rs b/crates/ilold-core/src/exploration/commands.rs index 4be5443..2a97fbf 100644 --- a/crates/ilold-core/src/exploration/commands.rs +++ b/crates/ilold-core/src/exploration/commands.rs @@ -155,6 +155,7 @@ pub fn canvas_patch_from(result: &CommandResult, active_scenario: &str) -> Optio function: function.clone(), access: access.clone(), step_index: *step_index, + runtime: None, }) } CommandResult::StepRemoved { .. } => Some(CanvasPatch::RemoveLastNode { diff --git a/crates/ilold-core/src/exploration/timeline.rs b/crates/ilold-core/src/exploration/timeline.rs index 0de5eb7..9b838cf 100644 --- a/crates/ilold-core/src/exploration/timeline.rs +++ b/crates/ilold-core/src/exploration/timeline.rs @@ -121,6 +121,7 @@ mod tests { flow_tree: None, trace_config: TraceConfig::default(), runtime_trace: None, + call_payload: None, } } diff --git a/crates/ilold-session-core/src/exploration/canvas.rs b/crates/ilold-session-core/src/exploration/canvas.rs index 5961970..6925ac9 100644 --- a/crates/ilold-session-core/src/exploration/canvas.rs +++ b/crates/ilold-session-core/src/exploration/canvas.rs @@ -1,14 +1,33 @@ use serde::{Deserialize, Serialize}; +use serde_json::Value; use super::access::AccessLevel; use super::scenario::ScenarioEvent; #[derive(Debug, Clone, Serialize, Deserialize)] pub enum CanvasPatch { - AddNode { scenario: String, function: String, access: AccessLevel, step_index: usize }, + AddNode { + scenario: String, + function: String, + access: AccessLevel, + step_index: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + runtime: Option, + }, RemoveLastNode { scenario: String }, ClearAll { scenario: String }, Highlight { scenario: String, function: String }, ScenarioEvent(ScenarioEvent), SolanaUsersChanged { scenario: String }, } + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RuntimeMeta { + pub compute_units: u64, + pub diffs_count: usize, + pub logs_excerpt: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trace: Option, +} diff --git a/crates/ilold-session-core/src/exploration/session.rs b/crates/ilold-session-core/src/exploration/session.rs index 08ec8f6..4ca9221 100644 --- a/crates/ilold-session-core/src/exploration/session.rs +++ b/crates/ilold-session-core/src/exploration/session.rs @@ -30,6 +30,12 @@ pub struct ExplorationStep { pub trace_config: TraceConfig, #[serde(default)] pub runtime_trace: Option, + /// Solana-only: the original Call inputs (args, accounts, signer names) so + /// LoadSession can replay the step against a fresh VM and reconstruct the + /// post-step accounts state. None for Solidity (no replay) or for steps + /// that came from an older save without this field. + #[serde(default)] + pub call_payload: Option, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] @@ -169,6 +175,7 @@ mod tests { flow_tree: None, trace_config: TraceConfig::default(), runtime_trace: None, + call_payload: None, }); s.steps.push(ExplorationStep { function: "withdraw".into(), @@ -176,6 +183,7 @@ mod tests { flow_tree: None, trace_config: TraceConfig::default(), runtime_trace: None, + call_payload: None, }); assert_eq!(s.current_sequence(), vec!["deposit", "withdraw"]); s.clear(); @@ -210,6 +218,7 @@ mod tests { flow_tree: None, trace_config: TraceConfig::default(), runtime_trace: None, + call_payload: None, }); s.steps.push(ExplorationStep { function: "withdraw".into(), @@ -227,6 +236,7 @@ mod tests { flow_tree: None, trace_config: TraceConfig::default(), runtime_trace: None, + call_payload: None, }); let summaries = s.variable_summary(); diff --git a/crates/ilold-solana-core/src/execute/fork.rs b/crates/ilold-solana-core/src/execute/fork.rs index bd9a1e5..4fba91f 100644 --- a/crates/ilold-solana-core/src/execute/fork.rs +++ b/crates/ilold-solana-core/src/execute/fork.rs @@ -77,3 +77,69 @@ impl VmSnapshot { Ok(kp.pubkey()) } } + +/// Lightweight snapshot used between steps within the same VM. Skips the +/// `programs` blob (kept loaded in the VmHost) so each entry costs ~accounts +/// bytes instead of MBs. Restoring is done in-place via `restore_state`. +#[derive(Clone)] +pub struct StateSnapshot { + pub accounts: Vec<(Address, AccountSharedData)>, + pub clock: Clock, +} + +impl VmHost { + pub fn snapshot_state(&self) -> StateSnapshot { + let accounts = self + .svm() + .accounts_db() + .inner + .iter() + .map(|(k, v)| (*k, v.clone())) + .collect(); + StateSnapshot { + accounts, + clock: self.svm().get_sysvar::(), + } + } + + /// Replace the in-memory account/clock state with `snap` while keeping + /// the loaded programs intact. Used by Back to rewind a single step + /// without paying the cost of re-adding programs. + pub fn restore_state(&mut self, snap: StateSnapshot) -> Result<(), SolanaError> { + let live: std::collections::HashSet
= + self.svm().accounts_db().inner.keys().copied().collect(); + let snap_keys: std::collections::HashSet
= + snap.accounts.iter().map(|(k, _)| *k).collect(); + + // Drop accounts created after the snapshot — set_account with an + // empty Account zeroes the slot which LiteSVM treats as absent. + let to_drop: Vec
= live.difference(&snap_keys).copied().collect(); + for pk in to_drop { + let empty: solana_account::Account = solana_account::Account::default(); + self.svm_mut().set_account(pk, empty).map_err(|e| { + SolanaError::VmOperationFailed(format!("drop account {pk}: {e:?}")) + })?; + } + + let upgradeable = solana_sdk_ids::bpf_loader_upgradeable::id(); + const PROGRAMDATA_DISCRIMINATOR_BYTE: u8 = 3; + use solana_account::ReadableAccount; + let (programdata, other): (Vec<_>, Vec<_>) = + snap.accounts.iter().cloned().partition(|(_, acc)| { + acc.owner() == &upgradeable + && acc.data().first() == Some(&PROGRAMDATA_DISCRIMINATOR_BYTE) + }); + for (pk, acc) in programdata { + self.svm_mut().set_account(pk, acc.into()).map_err(|e| { + SolanaError::VmOperationFailed(format!("restore programdata {pk}: {e:?}")) + })?; + } + for (pk, acc) in other { + self.svm_mut().set_account(pk, acc.into()).map_err(|e| { + SolanaError::VmOperationFailed(format!("restore account {pk}: {e:?}")) + })?; + } + self.svm_mut().set_sysvar(&snap.clock); + Ok(()) + } +} diff --git a/crates/ilold-solana-core/src/execute/mod.rs b/crates/ilold-solana-core/src/execute/mod.rs index 191ffdf..178b795 100644 --- a/crates/ilold-solana-core/src/execute/mod.rs +++ b/crates/ilold-solana-core/src/execute/mod.rs @@ -4,6 +4,6 @@ pub mod pda; pub mod vm; pub use builder::{build_instruction, build_transaction}; -pub use fork::VmSnapshot; +pub use fork::{StateSnapshot, VmSnapshot}; pub use pda::derive_pda; pub use vm::VmHost; diff --git a/crates/ilold-solana-core/src/exploration/add_step.rs b/crates/ilold-solana-core/src/exploration/add_step.rs index bf1d32a..a069362 100644 --- a/crates/ilold-solana-core/src/exploration/add_step.rs +++ b/crates/ilold-solana-core/src/exploration/add_step.rs @@ -25,6 +25,7 @@ pub fn add_solana_step<'a>( accounts: HashMap, extra_signers: &[&Keypair], timestamp: &str, + call_payload: Option, ) -> Result<&'a ExplorationStep, SolanaError> { let step_index = session.steps.len(); let types = &program.types; @@ -41,6 +42,11 @@ pub fn add_solana_step<'a>( let blockhash = vm.svm().latest_blockhash(); let tx = build_transaction(instruction.clone(), vm.payer(), extra_signers, blockhash)?; let result = vm.svm_mut().send_transaction(tx); + // LiteSVM does NOT rotate the blockhash automatically. Two Calls in the same + // session would collide (BlockhashNotFound) and the second silently fails + // with cu=0 / no state mutation. Expire after every send so the next Call + // gets a fresh blockhash. + vm.svm_mut().expire_blockhash(); let (runtime_trace, mutations) = match result { Ok(meta) => { @@ -89,6 +95,7 @@ pub fn add_solana_step<'a>( flow_tree: None, trace_config: TraceConfig::default(), runtime_trace: trace_value, + call_payload, }); session diff --git a/crates/ilold-solana-core/src/exploration/commands.rs b/crates/ilold-solana-core/src/exploration/commands.rs index 4929891..8935e76 100644 --- a/crates/ilold-solana-core/src/exploration/commands.rs +++ b/crates/ilold-solana-core/src/exploration/commands.rs @@ -61,6 +61,17 @@ pub enum SolanaCommand { Scenario { sub: ScenarioAction, }, + Step { + index: usize, + }, + Findings, + Export, + Who { + account_type: String, + }, + Timeline { + pubkey: String, + }, } fn default_initial_lamports() -> u64 { @@ -182,24 +193,94 @@ pub enum SolanaCommandResult { ScenarioDeleted { name: String, }, + StepDetail { + step_index: usize, + instruction: String, + runtime_trace: Option, + diff_summary: Vec, + }, + FindingsList { + items: Vec, + }, + Exported { + markdown: String, + bytes: usize, + }, + WhoList { + account_type: String, + instructions: Vec, + }, + TimelineView { + pubkey: String, + label: Option, + entries: Vec, + }, Error { message: String, }, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StepDiffSummary { + pub address: String, + pub name: Option, + pub lamports_delta: i128, + pub data_changed: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FindingSummary { + pub id: String, + pub severity: String, + pub title: String, + pub description: String, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WhoEntry { + pub instruction: String, + pub account_field: String, + pub writable: bool, + pub signer: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TimelineEntry { + pub step_index: usize, + pub instruction: String, + pub scenario: String, + pub lamports_delta: i128, + pub data_changed: bool, + pub before_decoded: Option, + pub after_decoded: Option, +} + pub fn canvas_patch_from_solana( result: &SolanaCommandResult, active_scenario: &str, ) -> Option { + use ilold_session_core::exploration::canvas::RuntimeMeta; match result { - SolanaCommandResult::StepAdded { instruction, step_index, .. } => { - Some(CanvasPatch::AddNode { - scenario: active_scenario.to_string(), - function: instruction.clone(), - access: AccessLevel::Public, - step_index: *step_index, - }) - } + SolanaCommandResult::StepAdded { + instruction, + step_index, + logs_excerpt, + account_diffs_count, + compute_units, + } => Some(CanvasPatch::AddNode { + scenario: active_scenario.to_string(), + function: instruction.clone(), + access: AccessLevel::Public, + step_index: *step_index, + runtime: Some(RuntimeMeta { + compute_units: *compute_units, + diffs_count: *account_diffs_count, + logs_excerpt: logs_excerpt.clone(), + error: None, + trace: None, + }), + }), SolanaCommandResult::StepRemoved { .. } => Some(CanvasPatch::RemoveLastNode { scenario: active_scenario.to_string(), }), diff --git a/crates/ilold-solana-core/src/exploration/execute.rs b/crates/ilold-solana-core/src/exploration/execute.rs index a5014e6..bc0dd28 100644 --- a/crates/ilold-solana-core/src/exploration/execute.rs +++ b/crates/ilold-solana-core/src/exploration/execute.rs @@ -14,7 +14,8 @@ use crate::model::{AccountTypeDef, ProgramDef, SeedSpec}; use super::add_step::add_solana_step; use super::commands::{ - AccountSummary, InstructionEntry, PdaEntry, SolanaCommandResult, UserEntry, + AccountSummary, FindingSummary, InstructionEntry, PdaEntry, SolanaCommandResult, + StepDiffSummary, TimelineEntry, UserEntry, WhoEntry, }; const DEFAULT_USER_LAMPORTS: u64 = 10_000_000_000; @@ -213,6 +214,16 @@ pub fn execute_call( } }; + // Capture original inputs so LoadSession can replay this Call against a + // fresh VM. We serialize the user-name strings (not the resolved pubkeys) + // because user keypairs are recreated on Load — same name, same pubkey. + let call_payload = serde_json::json!({ + "ix": ix_name, + "args": args.clone(), + "accounts": accounts_input.clone(), + "signers": signer_names.clone(), + }); + let mut accounts: HashMap = HashMap::new(); for (key, raw) in accounts_input { if let Some(kp) = users.get(&raw) { @@ -278,6 +289,7 @@ pub fn execute_call( accounts, &extra_signers, timestamp, + Some(call_payload), ) { return SolanaCommandResult::Error { message: format!("{e:?}"), @@ -508,3 +520,218 @@ fn decode_account_bytes( _ => None, } } + +pub fn execute_step( + session: &ExplorationSession, + index: usize, +) -> SolanaCommandResult { + let step = match session.steps.get(index) { + Some(s) => s, + None => { + return SolanaCommandResult::Error { + message: format!( + "step {index} out of range (session has {} steps)", + session.steps.len() + ), + }; + } + }; + let runtime_trace = step.runtime_trace.clone(); + let diff_summary: Vec = runtime_trace + .as_ref() + .and_then(|v| v.get("account_diffs").and_then(|d| d.as_array())) + .map(|arr| { + arr.iter() + .map(|d| StepDiffSummary { + address: d.get("address").and_then(|v| v.as_str()).unwrap_or("").to_string(), + name: d.get("name").and_then(|v| v.as_str()).map(String::from), + lamports_delta: d + .get("lamports_delta") + .and_then(|v| v.as_i64()) + .map(|n| n as i128) + .unwrap_or(0), + data_changed: d + .get("before") + .and_then(|v| v.as_array()) + .zip(d.get("after").and_then(|v| v.as_array())) + .map(|(b, a)| b != a) + .unwrap_or(false), + }) + .collect() + }) + .unwrap_or_default(); + SolanaCommandResult::StepDetail { + step_index: index, + instruction: step.function.clone(), + runtime_trace, + diff_summary, + } +} + +pub fn execute_findings_list(session: &ExplorationSession) -> SolanaCommandResult { + let items: Vec = session + .journal + .findings + .iter() + .map(|f: &Finding| FindingSummary { + id: f.id.clone(), + severity: format!("{:?}", f.severity), + title: f.title.clone(), + description: f.description.clone(), + created_at: f.created_at.clone(), + }) + .collect(); + SolanaCommandResult::FindingsList { items } +} + +pub fn execute_export( + session: &ExplorationSession, + program: &ProgramDef, + scenario: &str, +) -> SolanaCommandResult { + let mut md = String::new(); + md.push_str(&format!("# Audit report — {} ({})\n\n", program.name, scenario)); + md.push_str(&format!("**Steps**: {}\n", session.steps.len())); + md.push_str(&format!("**Findings**: {}\n\n", session.journal.findings.len())); + + md.push_str("## Sequence\n\n"); + if session.steps.is_empty() { + md.push_str("_(no steps recorded)_\n\n"); + } else { + for (i, s) in session.steps.iter().enumerate() { + let cu = s.runtime_trace.as_ref() + .and_then(|v| v.get("compute_units")) + .and_then(|v| v.as_u64()) + .unwrap_or(0); + let err = s.runtime_trace.as_ref() + .and_then(|v| v.get("error")) + .and_then(|v| v.as_str()); + let mark = if err.is_some() { "FAIL" } else { "OK" }; + md.push_str(&format!("- **#{i}** `{}` — {} ({} CU)\n", s.function, mark, cu)); + if let Some(e) = err { + md.push_str(&format!(" - error: `{e}`\n")); + } + } + md.push('\n'); + } + + md.push_str("## Findings\n\n"); + if session.journal.findings.is_empty() { + md.push_str("_(no findings)_\n\n"); + } else { + for f in &session.journal.findings { + md.push_str(&format!( + "### {} — [{:?}] {}\n\n{}\n\n_recorded at {}_\n\n", + f.id, f.severity, f.title, f.description, f.created_at + )); + } + } + + md.push_str("## Program\n\n"); + md.push_str(&format!("- Program ID: `{}`\n", program.program_id)); + md.push_str(&format!("- Instructions: {}\n", program.instructions.len())); + md.push_str(&format!("- Account types: {}\n\n", program.account_types.len())); + + let bytes = md.len(); + SolanaCommandResult::Exported { markdown: md, bytes } +} + +pub fn execute_who( + program: &ProgramDef, + account_type: &str, +) -> SolanaCommandResult { + // Heuristic: an account field name maps to its type by snake_case → PascalCase + // (e.g. `pool` → `Pool`, `user_stake` → `UserStake`). Anchor IDL doesn't + // carry the explicit type-of-account in the instruction shape, so we + // approximate by name match. False positives possible if naming diverges. + fn snake_to_pascal(s: &str) -> String { + s.split('_') + .filter(|p| !p.is_empty()) + .map(|p| { + let mut c = p.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::() + c.as_str(), + } + }) + .collect() + } + let target = account_type.to_string(); + let mut hits: Vec = Vec::new(); + for ix in &program.instructions { + for acc in &ix.accounts { + if snake_to_pascal(&acc.name) == target || acc.name == target { + hits.push(WhoEntry { + instruction: ix.name.clone(), + account_field: acc.name.clone(), + writable: acc.writable, + signer: acc.signer, + }); + } + } + } + SolanaCommandResult::WhoList { + account_type: target, + instructions: hits, + } +} + +pub fn execute_timeline( + session: &ExplorationSession, + program: &ProgramDef, + pubkey: &str, + active_scenario: &str, +) -> SolanaCommandResult { + let mut entries: Vec = Vec::new(); + let mut label: Option = None; + for (idx, step) in session.steps.iter().enumerate() { + let trace = match &step.runtime_trace { + Some(t) => t, + None => continue, + }; + let diffs = match trace.get("account_diffs").and_then(|v| v.as_array()) { + Some(a) => a, + None => continue, + }; + for d in diffs { + let addr = d.get("address").and_then(|v| v.as_str()).unwrap_or(""); + if addr != pubkey { + continue; + } + if label.is_none() { + label = d.get("name").and_then(|v| v.as_str()).map(String::from); + } + let lamports_delta = d + .get("lamports_delta") + .and_then(|v| v.as_i64()) + .map(|n| n as i128) + .unwrap_or(0); + let data_changed = d + .get("before") + .and_then(|v| v.as_array()) + .zip(d.get("after").and_then(|v| v.as_array())) + .map(|(b, a)| b != a) + .unwrap_or(false); + // Try to decode before/after using IDL discriminators. + let decode = |bytes_v: Option<&Value>| -> Option { + let arr = bytes_v.and_then(|v| v.as_array())?; + let bytes: Vec = arr.iter().filter_map(|b| b.as_u64().map(|n| n as u8)).collect(); + decode_account_bytes(&bytes, &program.account_types, &program.types) + }; + entries.push(TimelineEntry { + step_index: idx, + instruction: step.function.clone(), + scenario: active_scenario.to_string(), + lamports_delta, + data_changed, + before_decoded: decode(d.get("before")), + after_decoded: decode(d.get("after")), + }); + } + } + SolanaCommandResult::TimelineView { + pubkey: pubkey.to_string(), + label, + entries, + } +} diff --git a/crates/ilold-solana-core/src/exploration/mod.rs b/crates/ilold-solana-core/src/exploration/mod.rs index 5b38e2a..23e8af1 100644 --- a/crates/ilold-solana-core/src/exploration/mod.rs +++ b/crates/ilold-solana-core/src/exploration/mod.rs @@ -8,7 +8,8 @@ pub use commands::{ SolanaCommandResult, UserEntry, }; pub use execute::{ - execute_airdrop, execute_back, execute_call, execute_clear, execute_finding, execute_funcs, - execute_inspect, execute_note, execute_pda, execute_session, execute_state, execute_status, - execute_time_warp, execute_users, execute_users_new, + execute_airdrop, execute_back, execute_call, execute_clear, execute_export, + execute_finding, execute_findings_list, execute_funcs, execute_inspect, execute_note, + execute_pda, execute_session, execute_state, execute_status, execute_step, + execute_time_warp, execute_timeline, execute_users, execute_users_new, execute_who, }; diff --git a/crates/ilold-web/src/api/session.rs b/crates/ilold-web/src/api/session.rs index 0a2fbc2..8198f40 100644 --- a/crates/ilold-web/src/api/session.rs +++ b/crates/ilold-web/src/api/session.rs @@ -21,6 +21,7 @@ use ilold_core::slicing::{build_slice_result, SliceDirection, SliceResult}; use ilold_session_core::exploration::scenario::ScenarioAction as SharedScenarioAction; use ilold_solana_core::exploration::{ canvas_patch_from_solana, execute_airdrop, execute_back, execute_call, execute_clear, + execute_export, execute_findings_list, execute_step, execute_timeline, execute_who, execute_finding, execute_funcs, execute_inspect, execute_note, execute_pda, execute_session, execute_state, execute_status, execute_time_warp, execute_users, execute_users_new, SolanaCommand, SolanaCommandResult, @@ -382,6 +383,9 @@ async fn handle_solana_command( } if let SolanaCommand::LoadSession { json } = command { let mut scenarios = state.scenarios.write().unwrap(); + let mut vms = solana.vms.write().unwrap(); + let mut users_lock = solana.users.write().unwrap(); + let mut snapshots = solana.step_snapshots.write().unwrap(); let result = match ScenarioStore::load_from_json(&json) { Ok(loaded) => { let prog = loaded.contract.clone(); @@ -392,9 +396,114 @@ async fn handle_solana_command( .map(|s| s.function.clone()) .collect(); *scenarios = loaded; - SolanaCommandResult::SessionLoaded { - program: prog, - steps: step_names, + + // Drop existing VMs/snapshot stacks; we rebuild from scratch. + vms.clear(); + snapshots.clear(); + + // Rebuild a VM per scenario by replaying each step from its + // saved call_payload. Steps without payload (legacy saves) + // can't replay, so we still boot the VM but leave its state + // un-mutated; the timeline remains visible but inspect/state + // will reflect genesis. This is the only feasible compromise + // without breaking older snapshots. + let mut replay_errors: Vec = Vec::new(); + let scenario_names: Vec = scenarios.names().to_vec(); + for scn_name in &scenario_names { + let mut vm = match ilold_solana_core::execute::VmHost::boot( + solana.program_artifacts.clone(), + ) { + Ok(v) => v, + Err(e) => { + replay_errors.push(format!("boot {scn_name}: {e:?}")); + continue; + } + }; + let scn_users = users_lock + .entry(scn_name.clone()) + .or_insert_with(std::collections::HashMap::new); + // Re-airdrop existing users into the freshly booted VM so + // replayed Calls can pay for `init` constraints. + const REPLAY_LAMPORTS: u64 = 10_000_000_000; + for kp in scn_users.values() { + use solana_keypair::Signer; + let _ = vm.airdrop(kp.pubkey(), REPLAY_LAMPORTS); + } + let mut stack: Vec = Vec::new(); + let session = match scenarios.get_mut(scn_name) { + Some(s) => s, + None => continue, + }; + let steps_clone = session.steps.clone(); + for (idx, step) in steps_clone.iter().enumerate() { + let payload = match &step.call_payload { + Some(p) => p, + None => continue, + }; + let ix_name = payload.get("ix").and_then(|v| v.as_str()).unwrap_or(""); + let args = payload.get("args").cloned().unwrap_or(Value::Null); + let accounts: std::collections::HashMap = payload + .get("accounts") + .and_then(|v| v.as_object()) + .map(|m| { + m.iter() + .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) + .collect() + }) + .unwrap_or_default(); + let signers: Vec = payload + .get("signers") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + let pre = vm.snapshot_state(); + // We replay against the current session in scn_users. + // Calls that reference users not yet recreated will + // fail; we record the error but continue so the rest + // of the timeline still loads. + let res = ilold_solana_core::exploration::execute::execute_call( + &program, + ix_name, + args, + accounts, + signers, + scn_users, + session, + &mut vm, + "load-replay", + ); + if matches!(res, ilold_solana_core::exploration::SolanaCommandResult::StepAdded { .. }) { + stack.push(pre); + // execute_call appended a NEW step at the end. The + // replayed timeline now has duplicate entries, so + // we pop the freshly-added step and keep the saved + // one (preserves runtime_trace from original run). + session.steps.pop(); + } else if let ilold_solana_core::exploration::SolanaCommandResult::Error { message } = res { + replay_errors.push(format!("{scn_name}#{idx}: {message}")); + } + } + vms.insert(scn_name.clone(), vm); + snapshots.insert(scn_name.clone(), stack); + } + + if replay_errors.is_empty() { + SolanaCommandResult::SessionLoaded { + program: prog, + steps: step_names, + } + } else { + SolanaCommandResult::Error { + message: format!( + "loaded session but {} step(s) failed to replay: {}", + replay_errors.len(), + replay_errors.join("; ") + ), + } } } Err(message) => SolanaCommandResult::Error { message }, @@ -409,6 +518,7 @@ async fn handle_solana_command( let mut scenarios = state.scenarios.write().unwrap(); let mut vms = solana.vms.write().unwrap(); let mut users = solana.users.write().unwrap(); + let mut snapshots = solana.step_snapshots.write().unwrap(); let active_scenario = scenarios.active().to_string(); let vm = vms.get_mut(&active_scenario).ok_or(( @@ -419,8 +529,18 @@ async fn handle_solana_command( StatusCode::INTERNAL_SERVER_ERROR, format!("users registry for scenario '{active_scenario}' missing"), ))?; + let stack = snapshots + .entry(active_scenario.clone()) + .or_insert_with(Vec::new); let session = scenarios.active_session_mut(); + // Pre-Call snapshot: taken BEFORE dispatching so Back can rewind to here. + // Computed only for Call; everything else doesn't mutate VM state. + let pre_call_snapshot = match &command { + SolanaCommand::Call { .. } => Some(vm.snapshot_state()), + _ => None, + }; + let result = match command { SolanaCommand::Funcs => execute_funcs(&program), SolanaCommand::State => execute_state(session, &program, vm), @@ -451,8 +571,34 @@ async fn handle_solana_command( vm, ×tamp, ), - SolanaCommand::Back => execute_back(session), - SolanaCommand::Clear => execute_clear(session), + SolanaCommand::Back => { + let r = execute_back(session); + if matches!(r, SolanaCommandResult::StepRemoved { .. }) { + if let Some(snap) = stack.pop() { + if let Err(e) = vm.restore_state(snap) { + return Ok(Json(serde_json::to_value(SolanaCommandResult::Error { + message: format!("Back: rewind VM failed: {e:?}"), + }).unwrap_or(Value::Null))); + } + } + } + r + } + SolanaCommand::Clear => { + // Rewind VM to pre-step-0 (the snapshot taken before the very first + // Call). Without this the VM keeps the post-execution state and the + // next Call operates on contaminated accounts. If the stack is empty + // (no Calls yet, or already rewound), Clear is a no-op for the VM. + if let Some(genesis) = stack.first().cloned() { + if let Err(e) = vm.restore_state(genesis) { + return Ok(Json(serde_json::to_value(SolanaCommandResult::Error { + message: format!("Clear: rewind VM failed: {e:?}"), + }).unwrap_or(Value::Null))); + } + } + stack.clear(); + execute_clear(session) + } SolanaCommand::Finding { severity, title, @@ -462,11 +608,24 @@ async fn handle_solana_command( SolanaCommand::Status { ix, status } => { execute_status(session, &program, &ix, status, ×tamp) } + SolanaCommand::Step { index } => execute_step(session, index), + SolanaCommand::Findings => execute_findings_list(session), + SolanaCommand::Export => execute_export(session, &program, &active_scenario), + SolanaCommand::Who { account_type } => execute_who(&program, &account_type), + SolanaCommand::Timeline { pubkey } => { + execute_timeline(session, &program, &pubkey, &active_scenario) + } SolanaCommand::SaveSession | SolanaCommand::LoadSession { .. } | SolanaCommand::Scenario { .. } => unreachable!("handled above"), }; + // Persist the pre-Call snapshot only when the Call actually appended a + // step. Failed Calls already left the VM untouched, so no rewind needed. + if let (Some(snap), SolanaCommandResult::StepAdded { .. }) = (pre_call_snapshot, &result) { + stack.push(snap); + } + if let Some(patch) = canvas_patch_from_solana(&result, &active_scenario) { state.session_tx.send(patch).ok(); } @@ -586,7 +745,7 @@ fn solana_scenario_action( }; } }; - let new_vm = match ilold_solana_core::execute::VmHost::restore(snap) { + let mut new_vm = match ilold_solana_core::execute::VmHost::restore(snap) { Ok(v) => v, Err(e) => { return SolanaCommandResult::Error { @@ -595,6 +754,26 @@ fn solana_scenario_action( } }; + // Fork at a specific step requires rewinding the cloned VM to that + // step's pre-Call snapshot (otherwise VM has the full timeline's + // state but the cloned `steps` are truncated — auditor sees diverging + // state vs timeline). origin_stack[N] is the snapshot taken right + // before step N executed; restoring it produces the state where + // exactly steps [0..N) have run. + let mut snapshots = solana.step_snapshots.write().unwrap(); + let origin_stack = snapshots.entry(from.clone()).or_insert_with(Vec::new); + let cloned_stack: Vec = + origin_stack.iter().take(effective).cloned().collect(); + if let Some(rewind_to) = origin_stack.get(effective) { + if let Err(e) = new_vm.restore_state(rewind_to.clone()) { + return SolanaCommandResult::Error { + message: format!("rewind branch VM to step {effective}: {e:?}"), + }; + } + } + // If effective == origin_stack.len() the branch keeps the full state + // (no rewind needed) — typical "fork at HEAD" usage. + let mut users = solana.users.write().unwrap(); let cloned_users: std::collections::HashMap = users .get(&from) @@ -608,6 +787,7 @@ fn solana_scenario_action( scenarios.insert(name.clone(), cloned); vms.insert(name.clone(), new_vm); users.insert(name.clone(), cloned_users); + snapshots.insert(name.clone(), cloned_stack); SolanaCommandResult::ScenarioForked { from, to: name, @@ -633,6 +813,7 @@ fn solana_scenario_action( scenarios.remove(&name); solana.vms.write().unwrap().remove(&name); solana.users.write().unwrap().remove(&name); + solana.step_snapshots.write().unwrap().remove(&name); SolanaCommandResult::ScenarioDeleted { name } } } diff --git a/crates/ilold-web/src/state.rs b/crates/ilold-web/src/state.rs index c417b4b..510d008 100644 --- a/crates/ilold-web/src/state.rs +++ b/crates/ilold-web/src/state.rs @@ -217,6 +217,11 @@ pub struct SolanaState { pub program_artifacts: Vec<(Address, Vec)>, pub vms: RwLock>, pub users: RwLock>>, + /// Per-scenario stack of pre-Call snapshots, indexed by step. Used by + /// `Back` to rewind the VM state and by `Fork(at_step=N)` to seed the + /// new scenario's VM with the N-th snapshot. Cleared on `Clear` and + /// truncated on `Back`. Empty after `LoadSession` (replay rebuilds it). + pub step_snapshots: RwLock>>, } pub enum Backend { @@ -317,12 +322,17 @@ impl AppState { let mut users: HashMap> = HashMap::new(); users.insert(DEFAULT_SCENARIO.to_string(), HashMap::new()); + let mut step_snapshots: HashMap> = + HashMap::new(); + step_snapshots.insert(DEFAULT_SCENARIO.to_string(), Vec::new()); + Ok(Self { backend: Backend::Solana(SolanaState { project, program_artifacts, vms: RwLock::new(vms), users: RwLock::new(users), + step_snapshots: RwLock::new(step_snapshots), }), annotations: RwLock::new(Vec::new()), scenarios: RwLock::new(ScenarioStore::new_for_contract(default_program)), diff --git a/crates/ilold-web/src/ws/handler.rs b/crates/ilold-web/src/ws/handler.rs index 200839a..f842837 100644 --- a/crates/ilold-web/src/ws/handler.rs +++ b/crates/ilold-web/src/ws/handler.rs @@ -29,7 +29,14 @@ enum ServerMessage { #[serde(rename = "error")] Error { message: String }, #[serde(rename = "session_add_node")] - SessionAddNode { scenario: String, function: String, access: AccessLevel, step_index: usize }, + SessionAddNode { + scenario: String, + function: String, + access: AccessLevel, + step_index: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + runtime: Option, + }, #[serde(rename = "session_remove_node")] SessionRemoveNode { scenario: String }, #[serde(rename = "session_clear")] @@ -59,8 +66,8 @@ pub async fn ws_handler( fn server_message_from_patch(patch: CanvasPatch) -> ServerMessage { match patch { - CanvasPatch::AddNode { scenario, function, access, step_index } => { - ServerMessage::SessionAddNode { scenario, function, access, step_index } + CanvasPatch::AddNode { scenario, function, access, step_index, runtime } => { + ServerMessage::SessionAddNode { scenario, function, access, step_index, runtime } } CanvasPatch::RemoveLastNode { scenario } => ServerMessage::SessionRemoveNode { scenario }, CanvasPatch::ClearAll { scenario } => ServerMessage::SessionClear { scenario }, From 059a42b8e1fbdce1a4b9527786ca58fa98e39d4f Mon Sep 17 00:00:00 2001 From: scab24 Date: Sat, 9 May 2026 09:42:39 +0200 Subject: [PATCH 073/115] =?UTF-8?q?feat(cli):=20Solana=20parity=20?= =?UTF-8?q?=E2=80=94=20prompt=20sync,=20analysis=20commands,=20save/load,?= =?UTF-8?q?=20inline=20help?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sync_steps now dispatches by BackendKind and uses SolanaCommandResult::SessionView in attach mode, plus syncs active scenario and the scenario list every loop iteration so the prompt and tab completion stay in sync with changes from the frontend or another terminal. Adds REPL handlers for the new Solana analysis surface (step, findings, export, who, timeline) plus save/load that mirror the Solidity flow against the same on-disk session directory. The print pipeline now formats StepDetail (CU, logs excerpt, diffs), FindingsList, Exported markdown, WhoList and TimelineView with decoded before/after JSON. Inline help adds entries for the Solana-specific commands (users, airdrop, time-warp, pda, inspect, programs) so 'cmd?' no longer rejects them as unknown. --- crates/ilold-cli/src/explore.rs | 653 ++++++++++++++++++++++++++++++-- 1 file changed, 625 insertions(+), 28 deletions(-) diff --git a/crates/ilold-cli/src/explore.rs b/crates/ilold-cli/src/explore.rs index 994de60..2678901 100644 --- a/crates/ilold-cli/src/explore.rs +++ b/crates/ilold-cli/src/explore.rs @@ -323,21 +323,47 @@ fn repl_loop( // Initial prompt sync in --attach mode: pick up steps from other terminals if state.is_none() { - if let Some(server_steps) = sync_steps(&handle, &client, &base_url, &contract) { + if let Some(server_steps) = sync_steps(&handle, &client, &base_url, &contract, backend) { steps = server_steps; prompt.steps = steps.clone(); } + if backend == BackendKind::Solana { + if let Some(active) = sync_active_scenario(&handle, &client, &base_url, &contract) { + scenario_name = active; + prompt.scenario = scenario_name.clone(); + } + if let Some(scns) = sync_scenarios(&handle, &client, &base_url, &contract) { + if let Ok(mut comp) = completer.lock() { + comp.scenarios = scns; + } + } + } } loop { // Sync prompt from server in --attach mode (catches changes from other terminals) if state.is_none() { - if let Some(server_steps) = sync_steps(&handle, &client, &base_url, &contract) { + if let Some(server_steps) = sync_steps(&handle, &client, &base_url, &contract, backend) { if server_steps != steps { steps = server_steps; prompt.steps = steps.clone(); } } + if backend == BackendKind::Solana { + if let Some(active) = sync_active_scenario(&handle, &client, &base_url, &contract) { + if active != scenario_name { + scenario_name = active; + prompt.scenario = scenario_name.clone(); + } + } + if let Some(scns) = sync_scenarios(&handle, &client, &base_url, &contract) { + if let Ok(mut comp) = completer.lock() { + if comp.scenarios != scns { + comp.scenarios = scns; + } + } + } + } } match editor.read_line(&prompt) { @@ -1123,6 +1149,32 @@ fn handle_solana_input( steps, ) } + "funcs-all" | "fa" => { + match fetch_program_detail(handle, client, base_url, contract) { + Ok(p) => print_solana_funcs_all(&p), + Err(e) => eprintln!(" {}", c_danger(&format!("fetch program: {e}"))), + } + InputResult::Continue + } + "info" | "i" => { + if arg.is_empty() { + println!(" Usage: info "); + return InputResult::Continue; + } + match fetch_program_detail(handle, client, base_url, contract) { + Ok(p) => print_solana_ix_info(&p, arg), + Err(e) => eprintln!(" {}", c_danger(&format!("fetch program: {e}"))), + } + InputResult::Continue + } + "vars" | "v" | "vars-all" | "va" => { + let verbose = matches!(cmd.as_str(), "vars-all" | "va"); + match fetch_program_detail(handle, client, base_url, contract) { + Ok(p) => print_solana_vars(&p, verbose), + Err(e) => eprintln!(" {}", c_danger(&format!("fetch program: {e}"))), + } + InputResult::Continue + } "state" => dispatch_solana( handle, client, @@ -1340,6 +1392,112 @@ fn handle_solana_input( let body = serde_json::json!({"Note": {"text": arg}}); dispatch_solana(handle, client, base_url, contract, body, steps) } + "step" | "st" => { + let idx: usize = match arg.trim().parse() { + Ok(n) => n, + Err(_) => { + println!(" Usage: step "); + return InputResult::Continue; + } + }; + let body = serde_json::json!({"Step": {"index": idx}}); + dispatch_solana(handle, client, base_url, contract, body, steps) + } + "save" => { + if arg.is_empty() { + println!(" Usage: save "); + return InputResult::Continue; + } + let body = serde_json::json!({"contract": contract, "command": "SaveSession"}); + match send_solana_command(handle, client, base_url, &body) { + Ok(SolanaCommandResult::SessionSaved { json }) => { + let dir = dirs::home_dir() + .map(|h| h.join(".ilold").join("sessions")) + .unwrap_or_else(|| std::path::PathBuf::from(".ilold/sessions")); + std::fs::create_dir_all(&dir).ok(); + let path = dir.join(format!("{}.json", arg)); + match std::fs::write(&path, &json) { + Ok(_) => println!( + " {} Saved to {}", + c_ok("✓"), + c_accent(&path.display().to_string()) + ), + Err(e) => eprintln!(" {} Write failed: {}", c_danger("✗"), e), + } + } + Ok(other) => print_solana_result(&other), + Err(e) => eprintln!(" {}", c_danger(&e)), + } + InputResult::Continue + } + "load" => { + if arg.is_empty() { + println!(" Usage: load "); + return InputResult::Continue; + } + let dir = dirs::home_dir() + .map(|h| h.join(".ilold").join("sessions")) + .unwrap_or_else(|| std::path::PathBuf::from(".ilold/sessions")); + let path = dir.join(format!("{}.json", arg)); + let json = match std::fs::read_to_string(&path) { + Ok(j) => j, + Err(e) => { + eprintln!( + " {} File not found: {} ({})", + c_danger("✗"), + path.display(), + e + ); + return InputResult::Continue; + } + }; + let body = serde_json::json!({ + "contract": contract, + "command": {"LoadSession": {"json": json}} + }); + match send_solana_command(handle, client, base_url, &body) { + Ok(SolanaCommandResult::SessionLoaded { steps: loaded, .. }) => { + *steps = loaded; + println!(" {} Session loaded ({} steps)", c_ok("✓"), steps.len()); + return InputResult::UpdatePrompt; + } + Ok(other) => print_solana_result(&other), + Err(e) => eprintln!(" {}", c_danger(&e)), + } + InputResult::Continue + } + "findings" | "fl" => dispatch_solana( + handle, + client, + base_url, + contract, + serde_json::json!("Findings"), + steps, + ), + "export" | "ex" => dispatch_solana( + handle, + client, + base_url, + contract, + serde_json::json!("Export"), + steps, + ), + "who" => { + if arg.is_empty() { + println!(" Usage: who (e.g. who Pool)"); + return InputResult::Continue; + } + let body = serde_json::json!({"Who": {"account_type": arg}}); + dispatch_solana(handle, client, base_url, contract, body, steps) + } + "timeline" | "tl" => { + if arg.is_empty() { + println!(" Usage: timeline "); + return InputResult::Continue; + } + let body = serde_json::json!({"Timeline": {"pubkey": arg}}); + dispatch_solana(handle, client, base_url, contract, body, steps) + } "status" => { let parts: Vec<&str> = arg.split_whitespace().collect(); if parts.len() != 2 { @@ -1556,6 +1714,88 @@ fn coerce_kv(raw: &str, ty: &serde_json::Value) -> serde_json::Value { serde_json::Value::String(raw.to_string()) } +#[cfg(test)] +mod tests { + use super::*; + + fn staking_initialize_pool() -> serde_json::Value { + serde_json::json!({ + "name": "staking", + "instructions": [{ + "name": "initialize_pool", + "args": [{ "name": "reward_rate", "type": "u64" }], + "accounts": [ + { "name": "pool", "writable": true, "signer": true }, + { "name": "admin", "writable": true, "signer": true }, + { "name": "system_program", "address": "11111111111111111111111111111111" } + ] + }] + }) + } + + #[test] + fn kv_parser_distributes_args_and_accounts() { + let program = staking_initialize_pool(); + let body = build_call_from_kv( + &program, + "initialize_pool", + "reward_rate=10 pool=pool admin=admin", + ) + .expect("kv parse"); + assert_eq!(body["Call"]["ix"], "initialize_pool"); + assert_eq!(body["Call"]["args"]["reward_rate"], 10); + assert_eq!(body["Call"]["accounts"]["pool"], "pool"); + assert_eq!(body["Call"]["accounts"]["admin"], "admin"); + let signers: Vec<_> = body["Call"]["signers"].as_array().unwrap().iter().collect(); + assert!(signers.iter().any(|v| v.as_str() == Some("pool"))); + assert!(signers.iter().any(|v| v.as_str() == Some("admin"))); + } + + #[test] + fn kv_parser_supports_no_signer_override() { + let program = staking_initialize_pool(); + let body = build_call_from_kv( + &program, + "initialize_pool", + "reward_rate=10 pool=pool admin=admin --no-signer=admin", + ) + .expect("kv parse"); + let signers: Vec<&str> = body["Call"]["signers"] + .as_array() + .unwrap() + .iter() + .filter_map(|v| v.as_str()) + .collect(); + assert!(signers.contains(&"pool")); + assert!(!signers.contains(&"admin")); + } + + #[test] + fn kv_parser_rejects_unknown_key() { + let program = staking_initialize_pool(); + let err = build_call_from_kv( + &program, + "initialize_pool", + "reward_rate=10 ghost=foo", + ) + .unwrap_err(); + assert!(err.contains("ghost"), "got: {err}"); + } + + #[test] + fn kv_parser_omits_constant_accounts_from_form() { + let program = staking_initialize_pool(); + let body = build_call_from_kv( + &program, + "initialize_pool", + "reward_rate=10 pool=pool admin=admin", + ) + .expect("kv parse"); + // system_program should not be sent — backend resolves it from spec.address + assert!(body["Call"]["accounts"].get("system_program").is_none()); + } +} + fn send_solana_command( handle: &tokio::runtime::Handle, client: &reqwest::Client, @@ -1810,6 +2050,115 @@ fn print_solana_result(result: &SolanaCommandResult) { SolanaCommandResult::ScenarioDeleted { name } => { println!(" {} scenario {} deleted", c_ok("✓"), name); } + SolanaCommandResult::StepDetail { step_index, instruction, runtime_trace, diff_summary } => { + println!(" {} step {} · {}", c_accent("·"), step_index, c_accent(instruction)); + if let Some(trace) = runtime_trace { + if let Some(cu) = trace.get("compute_units").and_then(|v| v.as_u64()) { + println!(" {} {} CU", c_muted("compute units:"), cu); + } + if let Some(err) = trace.get("error").and_then(|v| v.as_str()) { + println!(" {} {}", c_danger("error:"), err); + } + if let Some(logs) = trace.get("logs").and_then(|v| v.as_array()) { + println!(" {} ({} lines)", c_muted("logs:"), logs.len()); + for line in logs.iter().take(20) { + if let Some(s) = line.as_str() { + println!(" {}", c_muted(s)); + } + } + if logs.len() > 20 { + println!(" {}", c_muted(&format!("... +{} more", logs.len() - 20))); + } + } + } + if !diff_summary.is_empty() { + println!(" {} ({})", c_muted("account diffs:"), diff_summary.len()); + for d in diff_summary { + let label = d.name.clone().unwrap_or_else(|| d.address.clone()); + let lam = if d.lamports_delta != 0 { + format!("Δlamports={}", d.lamports_delta) + } else { + String::new() + }; + let dat = if d.data_changed { "data changed" } else { "" }; + println!(" {} {} {} {}", c_accent("·"), label, c_muted(&lam), c_muted(dat)); + } + } + } + SolanaCommandResult::FindingsList { items } => { + if items.is_empty() { + println!(" {}", c_muted("no findings recorded")); + } else { + for f in items { + println!( + " {} {} [{}] {}", + c_accent(&f.id), + c_warn(&f.severity), + c_muted(&f.created_at), + c_accent(&f.title) + ); + if !f.description.is_empty() { + println!(" {}", c_muted(&f.description)); + } + } + } + } + SolanaCommandResult::Exported { markdown, bytes } => { + println!(" {} markdown report ({} bytes)", c_ok("✓"), bytes); + println!(); + for line in markdown.lines() { + println!(" {}", line); + } + } + SolanaCommandResult::WhoList { account_type, instructions } => { + if instructions.is_empty() { + println!(" {} no instruction references account_type '{}'", c_muted("·"), account_type); + } else { + println!(" {} '{}' referenced by:", c_accent("·"), c_accent(account_type)); + for w in instructions { + let mut flags = Vec::new(); + if w.signer { flags.push("signer"); } + if w.writable { flags.push("writable"); } + println!( + " {} {} (as {}) {}", + c_accent("·"), + c_accent(&w.instruction), + w.account_field, + c_muted(&flags.join(" ")) + ); + } + } + } + SolanaCommandResult::TimelineView { pubkey, label, entries } => { + let header = label.clone().unwrap_or_else(|| pubkey.clone()); + println!(" {} timeline for {} ({})", c_accent("·"), c_accent(&header), c_muted(pubkey)); + if entries.is_empty() { + println!(" {}", c_muted("no mutations recorded for this pubkey")); + } else { + for e in entries { + let lam = if e.lamports_delta != 0 { + format!(" Δlamports={}", e.lamports_delta) + } else { + String::new() + }; + let dat = if e.data_changed { " data" } else { "" }; + println!( + " {} #{} {} ({}){}{}", + c_accent("·"), + e.step_index, + c_accent(&e.instruction), + e.scenario, + c_muted(&lam), + c_muted(dat) + ); + if let Some(after) = &e.after_decoded { + let s = serde_json::to_string(after).unwrap_or_default(); + let preview = if s.len() > 200 { format!("{}…", &s[..200]) } else { s }; + println!(" {}", c_muted(&preview)); + } + } + } + } SolanaCommandResult::Error { message } => { eprintln!(" {} {}", c_danger("✗"), message); } @@ -1854,19 +2203,29 @@ fn sync_scenarios( } /// Fetch current session steps from the server (for --attach prompt sync). +/// Dispatches by backend kind: Solidity uses `CommandResult::SessionView`, +/// Solana uses `SolanaCommandResult::SessionView` (different shape, same name). fn sync_steps( handle: &tokio::runtime::Handle, client: &reqwest::Client, base_url: &str, contract: &str, + backend: BackendKind, ) -> Option> { let body = serde_json::json!({ "contract": contract, "command": "Session" }); - match send_command(handle, client, base_url, &body) { - Ok(CommandResult::SessionView { steps, .. }) => Some(steps), - _ => None, + if backend == BackendKind::Solana { + match send_solana_command(handle, client, base_url, &body) { + Ok(SolanaCommandResult::SessionView { steps, .. }) => Some(steps), + _ => None, + } + } else { + match send_command(handle, client, base_url, &body) { + Ok(CommandResult::SessionView { steps, .. }) => Some(steps), + _ => None, + } } } @@ -2157,6 +2516,222 @@ fn print_programs(state: &std::sync::Arc, current: & println!(); } +fn idl_type_label(ty: &serde_json::Value) -> String { + if let Some(s) = ty.as_str() { + return s.to_string(); + } + if let Some(obj) = ty.as_object() { + if let Some(d) = obj.get("defined") { + if let Some(s) = d.as_str() { + return s.to_string(); + } + if let Some(n) = d.get("name").and_then(|v| v.as_str()) { + return n.to_string(); + } + } + if let Some(inner) = obj.get("option") { + return format!("Option<{}>", idl_type_label(inner)); + } + if let Some(inner) = obj.get("vec") { + return format!("Vec<{}>", idl_type_label(inner)); + } + if let Some(inner) = obj.get("array") { + return format!("[{}]", idl_type_label(inner)); + } + } + ty.to_string() +} + +fn print_solana_funcs_all(program: &serde_json::Value) { + let arr = match program.get("instructions").and_then(|v| v.as_array()) { + Some(a) => a, + None => { + println!(" {}", c_muted("No instructions")); + return; + } + }; + println!(); + let max_name = arr + .iter() + .filter_map(|ix| ix.get("name").and_then(|n| n.as_str())) + .map(|n| n.chars().count()) + .max() + .unwrap_or(0); + for ix in arr { + let name = ix.get("name").and_then(|v| v.as_str()).unwrap_or("?"); + let args = ix.get("args").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0); + let accs = ix.get("accounts").and_then(|v| v.as_array()); + let acc_count = accs.map(|a| a.len()).unwrap_or(0); + let signers = accs + .map(|a| { + a.iter() + .filter(|x| x.get("signer").and_then(|s| s.as_bool()).unwrap_or(false)) + .count() + }) + .unwrap_or(0); + let pdas = accs + .map(|a| a.iter().filter(|x| x.get("pda").is_some()).count()) + .unwrap_or(0); + let writables = accs + .map(|a| { + a.iter() + .filter(|x| x.get("writable").and_then(|s| s.as_bool()).unwrap_or(false)) + .count() + }) + .unwrap_or(0); + let padded = fmt::pad_right(name, max_name); + println!( + " {} {} {}", + c_accent("[ix]"), + padded, + c_muted(&format!( + "args={args} accs={acc_count} signers={signers} writables={writables} pdas={pdas}" + )) + ); + } + println!(); +} + +fn print_solana_ix_info(program: &serde_json::Value, ix_name: &str) { + let arr = program.get("instructions").and_then(|v| v.as_array()); + let ix = arr.and_then(|a| { + a.iter() + .find(|x| x.get("name").and_then(|n| n.as_str()) == Some(ix_name)) + }); + let ix = match ix { + Some(v) => v, + None => { + eprintln!(" {}", c_danger(&format!("instruction '{ix_name}' not found"))); + return; + } + }; + println!(); + println!(" {} {}", c_accent("instruction"), ix_name); + if let Some(disc) = ix.get("discriminator").and_then(|v| v.as_array()) { + let bytes: Vec = disc.iter().filter_map(|b| b.as_u64()).map(|n| format!("{n:02x}")).collect(); + println!(" {} {}", c_muted("discriminator"), bytes.join(" ")); + } + let args = ix.get("args").and_then(|v| v.as_array()).cloned().unwrap_or_default(); + println!(); + println!(" {} ({})", c_accent("args"), args.len()); + if args.is_empty() { + println!(" {}", c_muted("(none)")); + } else { + let max = args + .iter() + .filter_map(|a| a.get("name").and_then(|n| n.as_str()).map(|s| s.chars().count())) + .max() + .unwrap_or(0); + for a in &args { + let name = a.get("name").and_then(|v| v.as_str()).unwrap_or("?"); + let ty = a.get("type").cloned().unwrap_or(serde_json::Value::Null); + println!( + " {} {} {}", + c_accent("·"), + fmt::pad_right(name, max), + c_muted(&idl_type_label(&ty)) + ); + } + } + let accs = ix + .get("accounts") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + println!(); + println!(" {} ({})", c_accent("accounts"), accs.len()); + if accs.is_empty() { + println!(" {}", c_muted("(none)")); + } else { + let max = accs + .iter() + .filter_map(|a| a.get("name").and_then(|n| n.as_str()).map(|s| s.chars().count())) + .max() + .unwrap_or(0); + for a in &accs { + let name = a.get("name").and_then(|v| v.as_str()).unwrap_or("?"); + let signer = a.get("signer").and_then(|s| s.as_bool()).unwrap_or(false); + let writable = a.get("writable").and_then(|s| s.as_bool()).unwrap_or(false); + let pda = a.get("pda").is_some(); + let constant = a.get("address").and_then(|v| v.as_str()); + let mut flags = Vec::new(); + if signer { flags.push("signer"); } + if writable { flags.push("writable"); } + if pda { flags.push("pda"); } + let suffix = if let Some(addr) = constant { + format!("const {addr}") + } else { + flags.join(" ") + }; + println!( + " {} {} {}", + c_accent("·"), + fmt::pad_right(name, max), + c_muted(&suffix) + ); + } + } + println!(); +} + +fn print_solana_vars(program: &serde_json::Value, verbose: bool) { + let arr = match program.get("account_types").and_then(|v| v.as_array()) { + Some(a) => a, + None => { + println!(" {}", c_muted("No account types")); + return; + } + }; + println!(); + if arr.is_empty() { + println!(" {}", c_muted("No account types declared in IDL")); + println!(); + return; + } + for at in arr { + let name = at.get("name").and_then(|v| v.as_str()).unwrap_or("?"); + let disc = at + .get("discriminator") + .and_then(|v| v.as_array()) + .map(|d| { + d.iter() + .filter_map(|b| b.as_u64()) + .map(|n| format!("{n:02x}")) + .collect::>() + .join(" ") + }) + .unwrap_or_default(); + println!(" {} {} {}", c_accent("[T]"), name, c_muted(&disc)); + if verbose { + let fields = at + .pointer("/layout/type/fields") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default(); + if fields.is_empty() { + println!(" {}", c_muted("(opaque or zero-copy layout)")); + } else { + let max = fields + .iter() + .filter_map(|f| f.get("name").and_then(|n| n.as_str()).map(|s| s.chars().count())) + .max() + .unwrap_or(0); + for f in &fields { + let fname = f.get("name").and_then(|v| v.as_str()).unwrap_or("?"); + let fty = f.get("type").cloned().unwrap_or(serde_json::Value::Null); + println!( + " {} {} {}", + c_accent("·"), + fmt::pad_right(fname, max), + c_muted(&idl_type_label(&fty)) + ); + } + } + } + } + println!(); +} + fn print_contracts(state: &std::sync::Arc, current: &str) { use ilold_core::model::contract::ContractKind; let s = state.unwrap_solidity(); @@ -2741,35 +3316,50 @@ fn print_help_for(backend: BackendKind) { let groups: &[(&str, &[(&str, &str, &str)])] = match backend { BackendKind::Solana => &[ ("Session", &[ - ("c", "call ", "Send instruction (json: {args, accounts, signers})"), - ("b", "back", "Remove last step"), - ("cl", "clear", "Reset session"), - ("", "state", "Decoded view of mutated accounts"), - ("s", "session", "Steps + scenario summary"), - ]), - ("Solana", &[ - ("", "users", "List keypairs"), - ("", "users new [lamports]", "Create + airdrop"), - ("", "airdrop ", "Top up balance"), - ("tw", "time-warp ", "Advance Clock"), - ("", "pda ", "List declared PDAs of an instruction"), - ("", "inspect ", "Decode account by discriminator"), + ("c", "call arg=val acc=user", "Concise: keys auto-distributed; signers auto from IDL"), + ("", "call {json}", "Full control with explicit args/accounts/signers"), + ("", " --signer=a,b", "Add signers (override IDL defaults)"), + ("", " --no-signer=a", "Remove a signer (simulate negative cases)"), + ("b", "back", "Remove last step from active scenario"), + ("cl", "clear", "Reset active scenario steps"), + ("", "state", "Decoded view of accounts mutated this session"), + ("s", "session", "Active scenario summary (steps + findings)"), ]), ("Programs", &[ - ("ct", "programs", "List programs in workspace"), - ("", "use ", "Switch active program"), - ("", "funcs", "List instructions of active program"), + ("ct", "programs (alias contracts)", "List programs in workspace"), + ("", "use ", "Switch active program"), + ("f", "funcs (alias functions)", "List instructions of active program"), + ("fa", "funcs-all", "Instructions with arg/account/signer/pda counts"), + ("i", "info ", "Detail an instruction: args (typed), accounts (flags), discriminator"), + ("v", "vars", "List declared account types with discriminators"), + ("va", "vars-all", "Account types with their decoded field layout"), + ]), + ("Solana runtime", &[ + ("", "users", "List keypairs in active scenario"), + ("", "users new [lamports]", "Create keypair + airdrop (default 10 SOL)"), + ("", "airdrop ", "Top up an existing keypair"), + ("tw", "time-warp ", "Advance Clock unix_timestamp + slot"), + ("", "pda ", "List PDAs declared by an instruction (symbolic)"), + ("", "inspect ", "Read VM account, decode by Anchor discriminator"), + ]), + ("Analysis", &[ + ("st", "step ", "Re-inspect a step: CU, logs, diffs"), + ("", "who ", "List instructions referencing this account type (e.g. who Pool)"), + ("tl", "timeline ", "Cross-step mutation history of an account, decoded"), ]), ("Findings", &[ - ("fi", "finding ", "Record a finding"), - ("n", "note ", "Add note"), - ("", "status ", "Change review status"), - ("sc", "scenario ", "new|list|switch|fork|delete"), + ("fi", "finding ", "Record a security finding"), + ("fl", "findings", "List recorded findings"), + ("n", "note <text>", "Add annotation to active sequence"), + ("", "status <ix> <s>", "Set review status: open|reviewed|finding"), + ("ex", "export", "Markdown report: sequence + findings + program info"), + ("sc", "scenario <sub>", "new|list|switch|fork|delete <name> [step]"), ]), ("Workspace", &[ - ("", "save <name>", "Save session JSON"), - ("", "load <name>", "Load session JSON"), - ("q", "quit/exit", "Exit"), + ("", "save <name>", "Save active scenario JSON to disk"), + ("", "load <name>", "Load scenario JSON from disk"), + ("?", "help", "Print this help"), + ("q", "quit/exit", "Exit"), ]), ], _ => &[ @@ -2862,6 +3452,13 @@ fn print_inline_help(cmd: &str) { (&["save"], "save <name>", "Save session to disk. Example: save my-audit"), (&["load"], "load <name>", "Load session from disk. Example: load my-audit"), (&["browser"], "browser", "Open the web UI in a browser."), + // Solana-specific commands: + (&["users"], "users [new <name> [<lamports>]]", "List or create keypairs in active scenario."), + (&["airdrop", "air"], "airdrop <name> <lamports>", "Top up a user's SOL balance."), + (&["tw", "time-warp"],"time-warp <delta_seconds>", "Warp the Clock sysvar (positive forward, negative back)."), + (&["pda"], "pda <instruction>", "List declared PDAs of an instruction (Anchor IDL)."), + (&["inspect", "acc"], "inspect <pubkey>", "Decode an account by Anchor discriminator."), + (&["programs", "progs"], "programs", "List programs in the workspace (Solana)."), ]; for (aliases, usage, desc) in entries { From aa3e421dc4e14536bc1941a1abc91f4d85eda62d Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Sat, 9 May 2026 09:43:15 +0200 Subject: [PATCH 074/115] =?UTF-8?q?fix(ui):=20Solana=20parity=20=E2=80=94?= =?UTF-8?q?=20trace=20context=20menu,=20palette=20modes,=20WS=20runtime,?= =?UTF-8?q?=20tab=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right-clicking a trace card on the Solana canvas now offers Fork scenario here and Remove from here (the guard accepted only function nodes, so trace nodes were stuck with a single Remove from canvas option). handleSessionClear stops painting locally and confirms before sending Clear, so the WS broadcast drives the repaint just like Solidity. paintSolanaScenarioTree now drops orphaned entries from the runtime cache so a re-execute of the same step shows fresh CU/diffs instead of the previous run's data. The Cmd+K palette gains Mode commands for Solana (Program/Sequences/Session). SessionSidebar tracks userPickedTab so node selections do not yank the user back to Inspector after a fork, and the each-traceSteps key is now scenario:stepIndex (was just stepIndex, which collided across forks and made the Timeline button stop responding). The frontend subscribes to session_add_node and fills solanaRuntimeByStep from the broadcast so calls executed from another CLI/browser show real CU/diffs/logs in the canvas. SessionAddNode TS interface gains the optional runtime field. The two empty try/catch blocks around getSequences/getSequenceAnalysis now skip the warn for kind solana (Solidity-only endpoints, expected to 400). --- .../ilold-web/frontend/src/lib/api/types.ts | 9 ++ .../components/session/SessionSidebar.svelte | 32 ++++--- .../src/routes/contract/[name]/+page.svelte | 89 +++++++++++++------ 3 files changed, 91 insertions(+), 39 deletions(-) diff --git a/crates/ilold-web/frontend/src/lib/api/types.ts b/crates/ilold-web/frontend/src/lib/api/types.ts index 02e3992..9608064 100644 --- a/crates/ilold-web/frontend/src/lib/api/types.ts +++ b/crates/ilold-web/frontend/src/lib/api/types.ts @@ -16,6 +16,15 @@ export interface SessionAddNode { function: string; access: AccessLevel; step_index: number; + /** Solana-only: runtime metadata so other CLIs/browsers see CU/diffs/logs + * without having to re-fetch. None for Solidity (no VM). */ + runtime?: { + compute_units: number; + diffs_count: number; + logs_excerpt: string[]; + error?: string | null; + trace?: unknown; + }; } export interface SessionRemoveNode { diff --git a/crates/ilold-web/frontend/src/lib/components/session/SessionSidebar.svelte b/crates/ilold-web/frontend/src/lib/components/session/SessionSidebar.svelte index 88cb5fd..8e82630 100644 --- a/crates/ilold-web/frontend/src/lib/components/session/SessionSidebar.svelte +++ b/crates/ilold-web/frontend/src/lib/components/session/SessionSidebar.svelte @@ -66,7 +66,10 @@ return getNodes() .filter((n) => (n.data as any)?._type === 'trace') .map((n) => n.data as TraceNodeData) - .sort((a, b) => a.stepIndex - b.stepIndex); + .sort((a, b) => { + if (a.scenario !== b.scenario) return a.scenario.localeCompare(b.scenario); + return a.stepIndex - b.stepIndex; + }); }); async function handleCreateUser(e: SubmitEvent) { @@ -97,24 +100,27 @@ let open = $state(true); let activeTab: 'timeline' | 'state' | 'inspector' = $state('timeline'); + // Once the user picks a tab manually we stop auto-switching: otherwise + // selecting a node after a fork yanks the user back to Inspector even + // though they explicitly clicked Timeline/State. + let userPickedTab = $state(false); - // Auto-switch to the Inspector tab the moment the user selects a node - // on the canvas. Mirrors Figma / VSCode — selection should reveal the - // details without the user having to hunt for a tab. We only switch on - // the null → non-null transition so re-selecting a different node - // doesn't keep yanking the user back to this tab mid-navigation. let prevSelectedId = $state<string | null>(null); $effect(() => { const currentId = selectedNode?.id ?? null; - // Read the tracker via untrack so writing it at the end doesn't - // re-trigger this effect — only selectedNode changes should flow in. const prev = untrack(() => prevSelectedId); - if (currentId && !prev) { + const manual = untrack(() => userPickedTab); + if (currentId && !prev && !manual) { activeTab = 'inspector'; } prevSelectedId = currentId; }); + function pickTab(tab: 'timeline' | 'state' | 'inspector') { + activeTab = tab; + userPickedTab = true; + } + // Reactive view of scenarios (ordered map + active name, both from session store) const scenarioEntries = $derived(Array.from(getScenarios().entries())); const activeScenario = $derived(getActiveScenario()); @@ -268,21 +274,21 @@ <button class="flex-1 py-2 bg-transparent border-none text-[10px] font-semibold uppercase tracking-wider cursor-pointer transition-colors duration-150 {activeTab === 'timeline' ? 'text-accent' : 'text-text-muted hover:text-text'}" style="border-bottom: 2px solid {activeTab === 'timeline' ? 'var(--color-accent)' : 'transparent'};" - onclick={() => activeTab = 'timeline'} + onclick={() => pickTab('timeline')} > Timeline </button> <button class="flex-1 py-2 bg-transparent border-none text-[10px] font-semibold uppercase tracking-wider cursor-pointer transition-colors duration-150 {activeTab === 'state' ? 'text-accent' : 'text-text-muted hover:text-text'}" style="border-bottom: 2px solid {activeTab === 'state' ? 'var(--color-accent)' : 'transparent'};" - onclick={() => activeTab = 'state'} + onclick={() => pickTab('state')} > State </button> <button class="flex-1 py-2 bg-transparent border-none text-[10px] font-semibold uppercase tracking-wider cursor-pointer transition-colors duration-150 {activeTab === 'inspector' ? 'text-accent' : 'text-text-muted hover:text-text'}" style="border-bottom: 2px solid {activeTab === 'inspector' ? 'var(--color-accent)' : 'transparent'};" - onclick={() => activeTab = 'inspector'} + onclick={() => pickTab('inspector')} title={selectedNode ? `Inspect: ${selectedNode._funcName || selectedNode.label}` : 'Inspector — select a node to populate'} > Inspector{#if selectedNode}<span class="ml-1 text-accent-light">●</span>{/if} @@ -298,7 +304,7 @@ <div class="solana-empty-hint">Click an instruction in the sidebar and press Execute, or run <code>call <ix> <json></code> in the terminal.</div> </div> {/if} - {#each traceSteps as step (step.stepIndex)} + {#each traceSteps as step (`${step.scenario}:${step.stepIndex}`)} <div class="trace-step"> <div class="trace-step-head"> <span class="trace-step-idx">#{step.stepIndex}</span> diff --git a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte index d5099b8..a9b65bc 100644 --- a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte +++ b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte @@ -517,22 +517,20 @@ }); }); - // Session mode visibility filter: hide every non-session node and every - // non-session-path edge so the canvas shows the scenarios tree alone. - // Switching mode back unhides — no state is destroyed. + // Mode visibility filter: in session mode show only session nodes/edges; in + // any other mode hide them. Nodes are kept in the graph so switching back + // restores them without re-painting. $effect(() => { const currentMode = mode; - // Wrapped in untrack: setNodes/setEdges would otherwise re-trigger this - // effect via getNodes()/getEdges() subscriptions. untrack(() => { setNodes(getNodes().map((n) => { - const isSessionNode = (n.data as any)._sessionStep === true; - const shouldHide = currentMode === 'session' && !isSessionNode; + const isSessionNode = (n.data as any)?._sessionStep === true; + const shouldHide = currentMode === 'session' ? !isSessionNode : isSessionNode; return n.hidden === shouldHide ? n : { ...n, hidden: shouldHide }; })); setEdges(getEdges().map((e) => { const isSessionEdge = (e.data as any)?._type === 'session-path'; - const shouldHide = currentMode === 'session' && !isSessionEdge; + const shouldHide = currentMode === 'session' ? !isSessionEdge : isSessionEdge; return e.hidden === shouldHide ? e : { ...e, hidden: shouldHide }; })); }); @@ -689,7 +687,20 @@ addEdges(composedEdges); solanaTraceCount = tree.nodes.length; - if (flowApi) flowApi.fitView({ padding: 0.2, duration: 300 }); + + // Drop runtime entries whose trace node no longer exists (after Clear, + // Back, scenario delete) so a re-execute of the same step doesn't show + // stale CU/logs from a previous run. + const liveKeys = new Set(tree.nodes.map((n) => `${n._scenario}:${n.stepIndex}`)); + let mutated = false; + const next = new Map(solanaRuntimeByStep); + for (const k of next.keys()) { + if (!liveKeys.has(k)) { + next.delete(k); + mutated = true; + } + } + if (mutated) solanaRuntimeByStep = next; }); } @@ -762,12 +773,28 @@ await handleSolanaSubmit(payload, ix); } - $effect(() => { - if (kind !== 'solana' || !solanaProgram) return; + onMount(() => { const unsub = subscribeWs('solana_users_changed', () => { - refreshSolanaUsers(); + if (solanaProgram) refreshSolanaUsers(); }); - return () => unsub(); + // Fill solanaRuntimeByStep from the broadcast so calls executed in another + // terminal (CLI, second browser) show CU/diffs/logs in this canvas instead + // of "0 CU 0 diffs". Locally-issued calls also flow through here, harmless + // because handleSolanaSubmit already wrote the same data. + const unsubAdd = subscribeWs('session_add_node', (msg) => { + const runtime = (msg as any).runtime; + if (!runtime) return; + const key = `${msg.scenario}:${msg.step_index}`; + const next = new Map(solanaRuntimeByStep); + next.set(key, { + computeUnits: runtime.compute_units ?? 0, + diffsCount: runtime.diffs_count ?? 0, + logsExcerpt: runtime.logs_excerpt ?? [], + error: runtime.error ?? null, + }); + solanaRuntimeByStep = next; + }); + return () => { unsub(); unsubAdd(); }; }); async function refreshSolanaUsers() { @@ -853,8 +880,13 @@ contract = await getContract(contractName); const callgraphData = await getCallGraph(contractName); callgraphRaw = callgraphData; - try { seqTree = await getSequences(contractName); } catch {} - try { seqAnalysis = await getSequenceAnalysis(contractName); } catch {} + // Sequences/analysis are Solidity-only; expected to 400 for Solana. + try { seqTree = await getSequences(contractName); } catch (e) { + if (kind !== 'solana') console.warn('getSequences failed:', e); + } + try { seqAnalysis = await getSequenceAnalysis(contractName); } catch (e) { + if (kind !== 'solana') console.warn('getSequenceAnalysis failed:', e); + } } catch (e) { error = `Contract "${contractName}" not found`; } @@ -1157,22 +1189,18 @@ } async function handleSessionClear() { + const active = getActiveScenario(); + const steps = getScenarios().get(active) ?? []; + if (steps.length === 0) return; + if (!confirm(`Clear all ${steps.length} step(s) from scenario "${active}"?`)) return; if (kind === 'solana' && solanaProgram) { try { await postSolanaCommand('Clear', solanaProgram.name); - solanaTraceCount = 0; - const composed = composeProgramGraph(solanaProgram); - setNodes(composed.nodes); - setEdges(composed.edges); } catch (e) { notifyFailure('session clear', e); } return; } - const active = getActiveScenario(); - const steps = getScenarios().get(active) ?? []; - if (steps.length === 0) return; - if (!confirm(`Clear all ${steps.length} step(s) from scenario "${active}"?`)) return; try { await postCommand('Clear', contract?.name); } catch (e) { @@ -1227,7 +1255,7 @@ // it from an ancestor. `_scenariosPassingThrough` already encodes both // cases so the check is a single Array.includes. let sessionStep: { stepIndex: number } | undefined; - if (data._type === 'function' && data._sessionStep === true) { + if ((data._type === 'function' || data._type === 'trace') && data._sessionStep === true) { const { _scenariosPassingThrough: scns, _activeScenario: active, stepIndex: idx } = data; if (typeof idx === 'number' && active && scns?.includes(active)) { sessionStep = { stepIndex: idx }; @@ -1502,10 +1530,14 @@ function switchMode(newMode: 'cfg' | 'sequences' | 'session') { if (kind === 'solana') { - clearGraph(); + const toRemove = new Set<string>(); + for (const n of getNodes()) { + const isSessionNode = (n.data as any)?._sessionStep === true; + if (!isSessionNode) toRemove.add(n.id); + } + if (toRemove.size > 0) removeNodesById(toRemove); solanaCanvasIxs = new Set(); solanaExpandedIxs = new Set(); - solanaTraceCount = 0; selectedNode = null; mode = newMode; return; @@ -1570,6 +1602,11 @@ if (kind === 'solana' && solanaProgram) { const prog = solanaProgram; const cmds: Command[] = []; + cmds.push( + { id: 'mode:cfg', label: 'Mode: Program', category: 'Mode', icon: '⊟', keywords: ['cfg', 'program', 'instructions'], run: () => switchMode('cfg') }, + { id: 'mode:sequences', label: 'Mode: Sequences', category: 'Mode', icon: '⇵', keywords: ['seq', 'flow'], run: () => switchMode('sequences') }, + { id: 'mode:session', label: 'Mode: Session', category: 'Mode', icon: '⎇', keywords: ['scenario', 'session', 'trace'], run: () => switchMode('session') }, + ); cmds.push({ id: 'canvas:center', label: 'Center canvas', From 51c4b380e90a2801103f935a5b7718dd4c114702 Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Sat, 9 May 2026 09:45:41 +0200 Subject: [PATCH 075/115] feat(cli): finding, sequence, browser commands for Solana parity The Solana REPL now exposes finding (record severity+title via interactive parsing), sequence/seq (aliased to Session view since Solana lacks the Solidity narrative endpoint, surfaces step list with CU and findings count), and browser (mirrors the Solidity hint pointing to the running API). Without these the Solana auditor could not record findings from the REPL and seq/browser hit the unknown-command branch. --- crates/ilold-cli/src/explore.rs | 46 +++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/crates/ilold-cli/src/explore.rs b/crates/ilold-cli/src/explore.rs index 2678901..c288d0c 100644 --- a/crates/ilold-cli/src/explore.rs +++ b/crates/ilold-cli/src/explore.rs @@ -1392,6 +1392,52 @@ fn handle_solana_input( let body = serde_json::json!({"Note": {"text": arg}}); dispatch_solana(handle, client, base_url, contract, body, steps) } + "fi" | "finding" => { + if arg.is_empty() { + println!(" Usage: finding <severity> <title>"); + println!(" Severity: critical | high | medium | low | info"); + return InputResult::Continue; + } + let parts: Vec<&str> = arg.splitn(2, ' ').collect(); + if parts.len() < 2 { + println!(" Usage: finding <severity> <title>"); + return InputResult::Continue; + } + let severity = match normalize_severity(parts[0]) { + Some(s) => s, + None => { + println!( + " {}", + c_danger("Invalid severity. Valid: critical, high, medium, low, info") + ); + return InputResult::Continue; + } + }; + let body = serde_json::json!({ + "Finding": {"severity": severity, "title": parts[1], "description": ""} + }); + dispatch_solana(handle, client, base_url, contract, body, steps) + } + "seq" | "sequence" => { + // Solana lacks the dedicated sequence-narrative endpoint; fall back + // to the Session view which renders the step list with CU/diffs. + dispatch_solana( + handle, + client, + base_url, + contract, + serde_json::json!("Session"), + steps, + ) + } + "browser" => { + println!( + " {} Web UI not yet available in explore mode.", + c_muted("·") + ); + println!(" {} API running at {}/api/", c_muted("·"), base_url); + InputResult::Continue + } "step" | "st" => { let idx: usize = match arg.trim().parse() { Ok(n) => n, From 20d3c9f2f6a4200b286e7541250ff63c260763b0 Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Sat, 9 May 2026 09:48:16 +0200 Subject: [PATCH 076/115] test(solana): regression coverage for blockhash rotation and Back rewind Two new integration tests against the lever fixture pin down T-R40 and T-R33. solana_consecutive_calls_actually_execute issues three switch_power calls back to back and asserts cu > 0 on each, catching the silent-failure mode where LiteSVM keeps the same blockhash across calls (verified in litesvm 0.6.1 src/lib.rs lines 1141-1188 and 1234). solana_back_rewinds_vm_state runs initialize + switch_power + Back + switch_power and asserts the second switch_power still produces the program log line, which only happens if Back actually restored the pre-Call snapshot via restore_state. --- .../tests/solana_command_pipeline.rs | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/crates/ilold-web/tests/solana_command_pipeline.rs b/crates/ilold-web/tests/solana_command_pipeline.rs index 898a501..84e4d89 100644 --- a/crates/ilold-web/tests/solana_command_pipeline.rs +++ b/crates/ilold-web/tests/solana_command_pipeline.rs @@ -194,3 +194,165 @@ async fn solana_call_switch_power_via_http() { "expected lever log line, got: {logs}" ); } + +// Regression for T-R40: LiteSVM does not rotate latest_blockhash after +// send_transaction (verified in litesvm 0.6.1 src/lib.rs:1141-1188 — only +// expire_blockhash mutates the field). Without our explicit rotation, the +// 2nd Call shares the 1st's blockhash and silently fails with cu=0 / no +// state change. This test runs three consecutive switch_power calls and +// asserts that each one actually executed (cu > 0 and a fresh log line). +#[tokio::test] +async fn solana_consecutive_calls_actually_execute() { + let (client, port) = start_solana().await; + + for n in ["admin", "power"] { + let _ = cmd( + &client, + port, + "lever", + serde_json::json!({"UsersNew": {"name": n, "lamports": 5_000_000_000u64}}), + ) + .await; + } + + let init = cmd( + &client, + port, + "lever", + serde_json::json!({ + "Call": { + "ix": "initialize", + "args": {}, + "accounts": { + "power": "power", + "user": "admin", + "system_program": "11111111111111111111111111111111" + }, + "signers": ["admin", "power"] + } + }), + ) + .await; + assert!(init.get("StepAdded").is_some(), "init: {init}"); + + // Three consecutive switch_power calls — pre-T-R40 only the first ran. + for label in ["alpha", "beta", "gamma"] { + let res = cmd( + &client, + port, + "lever", + serde_json::json!({ + "Call": { + "ix": "switch_power", + "args": {"name": label}, + "accounts": {"power": "power"}, + "signers": [] + } + }), + ) + .await; + let step = res.get("StepAdded").unwrap_or_else(|| panic!("StepAdded for {label}: {res}")); + let cu = step + .get("compute_units") + .and_then(|v| v.as_u64()) + .unwrap_or(0); + assert!(cu > 0, "consecutive Call '{label}' executed with cu=0 — blockhash rotation regression"); + } +} + +// Regression for T-R33: Back must rewind the VM to the pre-Call snapshot, +// not just remove the step from the timeline. Verified by re-issuing the +// same Call after Back and checking the state mutates as if it were the +// first time. +#[tokio::test] +async fn solana_back_rewinds_vm_state() { + let (client, port) = start_solana().await; + + for n in ["admin", "power"] { + let _ = cmd( + &client, + port, + "lever", + serde_json::json!({"UsersNew": {"name": n, "lamports": 5_000_000_000u64}}), + ) + .await; + } + let _ = cmd( + &client, + port, + "lever", + serde_json::json!({ + "Call": { + "ix": "initialize", + "args": {}, + "accounts": { + "power": "power", + "user": "admin", + "system_program": "11111111111111111111111111111111" + }, + "signers": ["admin", "power"] + } + }), + ) + .await; + + let _ = cmd( + &client, + port, + "lever", + serde_json::json!({ + "Call": { + "ix": "switch_power", + "args": {"name": "first"}, + "accounts": {"power": "power"}, + "signers": [] + } + }), + ) + .await; + let session_after_two = cmd(&client, port, "lever", serde_json::json!("Session")).await; + assert_eq!( + session_after_two + .get("SessionView") + .and_then(|v| v.get("steps")) + .and_then(|v| v.as_array()) + .map(|a| a.len()), + Some(2) + ); + + let back = cmd(&client, port, "lever", serde_json::json!("Back")).await; + assert!(back.get("StepRemoved").is_some(), "Back: {back}"); + + // Re-issue the same switch_power; if Back didn't rewind the VM the second + // time the program logs the "previous" state which would still reflect + // "first". After T-R33 it should be back to whatever initialize left. + let replay = cmd( + &client, + port, + "lever", + serde_json::json!({ + "Call": { + "ix": "switch_power", + "args": {"name": "second"}, + "accounts": {"power": "power"}, + "signers": [] + } + }), + ) + .await; + let logs = replay + .get("StepAdded") + .and_then(|v| v.get("logs_excerpt")) + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|v| v.as_str()) + .collect::<Vec<_>>() + .join("\n") + }) + .unwrap_or_default(); + assert!( + logs.contains("pulling the power switch"), + "switch_power should still run after Back+replay; got: {logs}" + ); +} From 9af0df4c2ba6ac18fd0b7238ef6beb443624e62f Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Sat, 9 May 2026 09:49:17 +0200 Subject: [PATCH 077/115] docs(guide): Solana support page covering parity, gaps, and quickstart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guide previously documented only Solidity flows. Adds a dedicated Solana page that explains the conceptual differences (instruction vs function, accounts vs state vars, real LiteSVM vs symbolic CFG), a quickstart that covers users + call + state + step + who + timeline + finding + export, the Solana-only commands (users, airdrop, time-warp, pda, inspect), the partial Analysis surface (slice and trace deferred to Phase 2 — they need an Anchor handler AST), and known limitations (heuristic who, time-warp not undone by back, legacy save replay is best-effort). --- docs/guide/src/SUMMARY.md | 1 + docs/guide/src/solana-support.md | 111 +++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) create mode 100644 docs/guide/src/solana-support.md diff --git a/docs/guide/src/SUMMARY.md b/docs/guide/src/SUMMARY.md index a353de2..a06a517 100644 --- a/docs/guide/src/SUMMARY.md +++ b/docs/guide/src/SUMMARY.md @@ -15,6 +15,7 @@ - [Audit Walkthrough](./workflows/audit-walkthrough.md) - [Taint Analysis](./workflows/taint-analysis.md) +- [Solana Support](./solana-support.md) # Reference diff --git a/docs/guide/src/solana-support.md b/docs/guide/src/solana-support.md new file mode 100644 index 0000000..acd98b4 --- /dev/null +++ b/docs/guide/src/solana-support.md @@ -0,0 +1,111 @@ +# Solana Support + +Ilold also analyzes Solana programs (Anchor IDL based) through the same REPL, +with a different execution backend. This page explains what works, what does +not, and how the auditor workflow maps from Solidity to Solana. + +## Loading a Solana program + +Point ilold at a directory that contains `Anchor.toml`, `idls/<program>.json` +and (optionally) a compiled `target/deploy/<program>.so`: + +``` +ilold serve tests/fixtures/solana/staking +``` + +Without the `.so` the REPL still loads — IDL navigation works — but anything +that requires VM execution (`call`, `state`, `inspect`) fails until the +program is built. + +## Mental model: how Solana differs from Solidity + +| Concept | Solidity | Solana (Anchor) | +| --- | --- | --- | +| Entry point | function on a contract | instruction on a program | +| Persistent state | contract state variables | accounts owned by the program | +| Caller identity | `msg.sender` | signers passed by the client | +| Side-effect surface | storage writes / events | account mutations + logs | +| Execution | symbolic (CFG + paths) | concrete (LiteSVM with real bytecode) | + +Because the Solana side runs the real BPF program, every Call needs the +auditor to provide accounts (often new keypairs created with `users new`) +and signers. PDAs are derived on demand, not declared up front. + +## Quickstart + +```text +ilold[staking]> users new admin 100000000 +ilold[staking]> users new pool 2000000 +ilold[staking]> users new alice 50000000 +ilold[staking]> users new alice_stake 2000000 +ilold[staking]> call initialize_pool reward_rate=10 pool=pool admin=admin +ilold[staking → initialize_pool]> call stake amount=1000 pool=pool user_stake=alice_stake user=alice +ilold[staking → initialize_pool → stake]> state +ilold[staking → initialize_pool → stake]> step 1 +ilold[staking → initialize_pool → stake]> who Pool +ilold[staking → initialize_pool → stake]> timeline <pool-pubkey> +ilold[staking → initialize_pool → stake]> finding High "missing reentrancy guard" +ilold[staking → initialize_pool → stake]> export +``` + +`call` accepts two forms: a concise key-value form (`arg=value`, +`account_field=user_name`) where unmapped names are turned into local +keypairs, or a fully explicit JSON payload `call <ix> {"args":..., +"accounts":...,"signers":...}`. + +## Command parity + +Everything documented under [Session](./commands/session.md), [Contract](./commands/contract.md), +[Findings](./commands/findings.md) and [Workspace](./commands/workspace.md) +works against Solana with the same syntax. The Analysis surface is partial: + +| Command | Status | Notes | +| --- | --- | --- | +| `info <ix>` | works | args + account flags + discriminator | +| `who <account_type>` | works | maps `snake_case` field names to PascalCase types | +| `timeline <pubkey>` | works | cross-step mutation history with before/after decoded | +| `step <index>` | works | re-prints CU, logs, account diffs | +| `findings` / `export` | works | Markdown report includes scenario, sequence, findings | +| `slice` | not implemented | requires Anchor handler AST — Phase 2 | +| `trace` | not implemented | requires per-instruction CFG — Phase 2 | +| `sequence` | aliased to `session` | full narrative with CPI dependencies needs CFG | + +## Solana-only commands + +| Command | Use | +| --- | --- | +| `users new <name> [lamports]` | create a keypair and airdrop SOL into it | +| `airdrop <name> <lamports>` | top up an existing keypair | +| `time-warp <delta_seconds>` | advance the `Clock` sysvar (positive forward, negative back) | +| `pda <ix>` | list PDAs declared by the instruction in the IDL | +| `inspect <pubkey>` | decode an account by Anchor discriminator | + +## Scenarios and VM rewind + +Scenarios (`scenario new`, `scenario fork`, `scenario switch`) keep an +independent VM per branch. `back` and `clear` rewind the VM to the +pre-step snapshot, so re-issuing the same Call after `back` produces +fresh CU and diffs (instead of replaying stale state). `time-warp` is a +global side-effect on the Clock and is not undone by `back` — it is the +auditor's responsibility to reset the clock if needed. + +## Save and load + +`save <name>` writes the entire scenario tree (steps, runtime traces, +findings, fork origins, original Call inputs) to +`~/.ilold/sessions/<name>.json`. `load <name>` reboots a fresh VM per +scenario, re-airdrops the in-memory users, and replays each Call from +the persisted payload. The reconstructed VM state matches the saved +snapshot for typical Anchor flows; programs with non-deterministic +behaviour or balances above the default replay cap may diverge. + +## Known limitations + +- No static control-flow graph or path-tree analysis (Solana programs + are bytecode at this point, not parsed Rust). +- `who` is heuristic: it matches account field names against IDL account + types via snake_case → PascalCase, so unconventional naming will miss. +- `time-warp` advances `unix_timestamp` linearly but does not move the + slot counter backwards on negative deltas. +- LoadSession is best-effort for legacy saves without `call_payload` + (timeline restores, VM stays at genesis). From 6cbc825f8a826e819ccc83d3bab9361440f89f80 Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Sat, 9 May 2026 10:06:45 +0200 Subject: [PATCH 078/115] test(scenarios): declarative scenario suite over staking fixture Adds tests/scenarios with 10 black-box scenarios driven by curl against a fresh ilold serve. Covers happy path, four negative attack vectors (reinit pool, claim without stake, unstake overflow, non-admin add_rewards), fork isolation with VM rewind, Back rewind regression for T-R33, blockhash rotation regression for T-R40 (50 consecutive calls in ~450ms), Save/Load roundtrip via call_payload replay, and the cross-scenario export aggregation that the round 8 audit caught was missing. The runner spawns a server per scenario and aggregates pass/fail, reporting 10 green / 39 assertions in under a minute. Same run also fixes a stale add_solana_step signature in tests/e2e_lever.rs (missing call_payload arg), an unwrap on session.steps.last() in add_step.rs that could panic in edge cases (replaced with VmOperationFailed), step-failed call output in the CLI now flags AnchorError lines in red and prints FAILED next to the step, and execute_export now walks every scenario in the store so findings recorded in any branch are present in the markdown. --- crates/ilold-cli/src/explore.rs | 26 +++++- .../src/exploration/add_step.rs | 7 +- .../src/exploration/execute.rs | 71 +++++++++------ crates/ilold-solana-core/tests/e2e_lever.rs | 1 + crates/ilold-web/src/api/session.rs | 13 ++- tests/scenarios/01-happy-path.sh | 15 ++++ tests/scenarios/02-attack-reinit-pool.sh | 13 +++ .../03-attack-claim-without-stake.sh | 18 ++++ tests/scenarios/04-attack-unstake-overflow.sh | 16 ++++ .../scenarios/05-attack-non-admin-rewards.sh | 14 +++ tests/scenarios/06-fork-isolation.sh | 27 ++++++ tests/scenarios/07-back-rewinds-vm.sh | 20 +++++ tests/scenarios/08-blockhash-rotation.sh | 21 +++++ tests/scenarios/09-save-load-roundtrip.sh | 28 ++++++ tests/scenarios/10-export-cross-scenario.sh | 33 +++++++ tests/scenarios/_lib.sh | 87 +++++++++++++++++++ tests/scenarios/run.sh | 75 ++++++++++++++++ 17 files changed, 452 insertions(+), 33 deletions(-) create mode 100755 tests/scenarios/01-happy-path.sh create mode 100755 tests/scenarios/02-attack-reinit-pool.sh create mode 100755 tests/scenarios/03-attack-claim-without-stake.sh create mode 100755 tests/scenarios/04-attack-unstake-overflow.sh create mode 100755 tests/scenarios/05-attack-non-admin-rewards.sh create mode 100755 tests/scenarios/06-fork-isolation.sh create mode 100755 tests/scenarios/07-back-rewinds-vm.sh create mode 100755 tests/scenarios/08-blockhash-rotation.sh create mode 100755 tests/scenarios/09-save-load-roundtrip.sh create mode 100755 tests/scenarios/10-export-cross-scenario.sh create mode 100755 tests/scenarios/_lib.sh create mode 100755 tests/scenarios/run.sh diff --git a/crates/ilold-cli/src/explore.rs b/crates/ilold-cli/src/explore.rs index c288d0c..f9d9a17 100644 --- a/crates/ilold-cli/src/explore.rs +++ b/crates/ilold-cli/src/explore.rs @@ -1893,10 +1893,23 @@ fn print_solana_result(result: &SolanaCommandResult) { account_diffs_count, compute_units, } => { + // The Call appended the step regardless of VM outcome — the + // auditor must see whether it actually executed. Anchor reports + // failure via "AnchorError" / "failed:" / "panicked" in logs. + let failed = logs_excerpt.iter().any(|l| { + let s = l.as_str(); + s.contains("AnchorError") || s.contains("failed:") || s.contains("panicked") + }); + let (mark, label) = if failed { + (c_danger("✗").to_string(), c_danger("FAILED").to_string()) + } else { + (c_ok("✓").to_string(), c_ok("ok").to_string()) + }; println!( - " {} step {}: {} {}", - c_ok("✓"), + " {} step {} [{}]: {} {}", + mark, step_index, + label, c_accent(instruction), c_muted(&format!( "({} CU, {} diffs)", @@ -1904,7 +1917,14 @@ fn print_solana_result(result: &SolanaCommandResult) { )) ); for log in logs_excerpt { - println!(" {}", c_muted(log)); + if log.contains("AnchorError") + || log.contains("failed:") + || log.contains("panicked") + { + println!(" {}", c_danger(log)); + } else { + println!(" {}", c_muted(log)); + } } } SolanaCommandResult::StepRemoved { remaining } => { diff --git a/crates/ilold-solana-core/src/exploration/add_step.rs b/crates/ilold-solana-core/src/exploration/add_step.rs index a069362..6504c96 100644 --- a/crates/ilold-solana-core/src/exploration/add_step.rs +++ b/crates/ilold-solana-core/src/exploration/add_step.rs @@ -105,7 +105,12 @@ pub fn add_solana_step<'a>( timestamp: timestamp.into(), }); - Ok(session.steps.last().unwrap()) + session + .steps + .last() + .ok_or_else(|| SolanaError::VmOperationFailed( + "step push succeeded but session.steps.last() is empty".into(), + )) } fn compute_diffs( diff --git a/crates/ilold-solana-core/src/exploration/execute.rs b/crates/ilold-solana-core/src/exploration/execute.rs index bc0dd28..387c08f 100644 --- a/crates/ilold-solana-core/src/exploration/execute.rs +++ b/crates/ilold-solana-core/src/exploration/execute.rs @@ -584,20 +584,52 @@ pub fn execute_findings_list(session: &ExplorationSession) -> SolanaCommandResul SolanaCommandResult::FindingsList { items } } -pub fn execute_export( - session: &ExplorationSession, +pub fn execute_export<'a, I>( + scenarios: I, + active: &str, program: &ProgramDef, - scenario: &str, -) -> SolanaCommandResult { +) -> SolanaCommandResult +where + I: IntoIterator<Item = (&'a str, &'a ExplorationSession)>, +{ + let scenarios: Vec<(&str, &ExplorationSession)> = scenarios.into_iter().collect(); + let total_steps: usize = scenarios.iter().map(|(_, s)| s.steps.len()).sum(); + let total_findings: usize = scenarios.iter().map(|(_, s)| s.journal.findings.len()).sum(); + let mut md = String::new(); - md.push_str(&format!("# Audit report — {} ({})\n\n", program.name, scenario)); - md.push_str(&format!("**Steps**: {}\n", session.steps.len())); - md.push_str(&format!("**Findings**: {}\n\n", session.journal.findings.len())); + md.push_str(&format!("# Audit report — {}\n\n", program.name)); + md.push_str(&format!( + "**Active scenario**: `{}` · **Scenarios**: {} · **Total steps**: {} · **Total findings**: {}\n\n", + active, scenarios.len(), total_steps, total_findings, + )); - md.push_str("## Sequence\n\n"); - if session.steps.is_empty() { - md.push_str("_(no steps recorded)_\n\n"); - } else { + md.push_str("## Program\n\n"); + md.push_str(&format!("- Program ID: `{}`\n", program.program_id)); + md.push_str(&format!("- Instructions: {}\n", program.instructions.len())); + md.push_str(&format!("- Account types: {}\n\n", program.account_types.len())); + + md.push_str("## Findings (all scenarios)\n\n"); + let mut any = false; + for (scn_name, session) in &scenarios { + for f in &session.journal.findings { + any = true; + md.push_str(&format!( + "### {} — [{:?}] {}\n\n_scenario: `{}` · recorded at {}_\n\n{}\n\n", + f.id, f.severity, f.title, scn_name, f.created_at, f.description, + )); + } + } + if !any { + md.push_str("_(no findings recorded)_\n\n"); + } + + for (scn_name, session) in &scenarios { + md.push_str(&format!("## Scenario: `{}`\n\n", scn_name)); + md.push_str(&format!("**Steps**: {}\n\n", session.steps.len())); + if session.steps.is_empty() { + md.push_str("_(no steps)_\n\n"); + continue; + } for (i, s) in session.steps.iter().enumerate() { let cu = s.runtime_trace.as_ref() .and_then(|v| v.get("compute_units")) @@ -615,23 +647,6 @@ pub fn execute_export( md.push('\n'); } - md.push_str("## Findings\n\n"); - if session.journal.findings.is_empty() { - md.push_str("_(no findings)_\n\n"); - } else { - for f in &session.journal.findings { - md.push_str(&format!( - "### {} — [{:?}] {}\n\n{}\n\n_recorded at {}_\n\n", - f.id, f.severity, f.title, f.description, f.created_at - )); - } - } - - md.push_str("## Program\n\n"); - md.push_str(&format!("- Program ID: `{}`\n", program.program_id)); - md.push_str(&format!("- Instructions: {}\n", program.instructions.len())); - md.push_str(&format!("- Account types: {}\n\n", program.account_types.len())); - let bytes = md.len(); SolanaCommandResult::Exported { markdown: md, bytes } } diff --git a/crates/ilold-solana-core/tests/e2e_lever.rs b/crates/ilold-solana-core/tests/e2e_lever.rs index 5df80fd..d52d678 100644 --- a/crates/ilold-solana-core/tests/e2e_lever.rs +++ b/crates/ilold-solana-core/tests/e2e_lever.rs @@ -72,6 +72,7 @@ fn add_solana_step_runs_switch_power_against_real_program() { accounts, &[], "2026-05-06T00:00:00Z", + None, ) .expect("add_solana_step"); diff --git a/crates/ilold-web/src/api/session.rs b/crates/ilold-web/src/api/session.rs index 8198f40..baecfe8 100644 --- a/crates/ilold-web/src/api/session.rs +++ b/crates/ilold-web/src/api/session.rs @@ -610,7 +610,18 @@ async fn handle_solana_command( } SolanaCommand::Step { index } => execute_step(session, index), SolanaCommand::Findings => execute_findings_list(session), - SolanaCommand::Export => execute_export(session, &program, &active_scenario), + SolanaCommand::Export => { + // Export aggregates findings and step lists across ALL scenarios so + // the auditor's deliverable reflects the full investigation, not + // just the currently-active branch. + let names: Vec<String> = scenarios.names().to_vec(); + let entries: Vec<(&str, &ilold_session_core::exploration::session::ExplorationSession)> = + names + .iter() + .filter_map(|n| scenarios.get(n).map(|s| (n.as_str(), s))) + .collect(); + execute_export(entries, &active_scenario, &program) + } SolanaCommand::Who { account_type } => execute_who(&program, &account_type), SolanaCommand::Timeline { pubkey } => { execute_timeline(session, &program, &pubkey, &active_scenario) diff --git a/tests/scenarios/01-happy-path.sh b/tests/scenarios/01-happy-path.sh new file mode 100755 index 0000000..949c858 --- /dev/null +++ b/tests/scenarios/01-happy-path.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Happy path: init pool + 2 stakes accumulate total_staked. +set -e +. "$(dirname "$0")/_lib.sh" +NAME="01-happy-path" +echo "## $NAME" + +setup_users +expect_ok_call "initialize_pool" "$(init_pool)" +expect_ok_call "alice stake 1000" "$(stake_as alice alice_stake 1000)" +expect_ok_call "bob stake 2000" "$(stake_as bob bob_stake 2000)" +expect_eq "total_staked=3000" "3000" "$(pool_total_staked)" +expect_eq "session has 3 steps" "3" "$(session_steps_len)" + +scenario_summary "$NAME" diff --git a/tests/scenarios/02-attack-reinit-pool.sh b/tests/scenarios/02-attack-reinit-pool.sh new file mode 100755 index 0000000..d915860 --- /dev/null +++ b/tests/scenarios/02-attack-reinit-pool.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Negative scenario: re-initializing an already-initialized pool must fail. +# Anchor's `init` constraint should reject the second call with "already in use". +set -e +. "$(dirname "$0")/_lib.sh" +NAME="02-attack-reinit-pool" +echo "## $NAME" + +setup_users +expect_ok_call "first initialize_pool" "$(init_pool)" +expect_failed_call "re-init same pool rejected" "$(init_pool)" + +scenario_summary "$NAME" diff --git a/tests/scenarios/03-attack-claim-without-stake.sh b/tests/scenarios/03-attack-claim-without-stake.sh new file mode 100755 index 0000000..3deabb2 --- /dev/null +++ b/tests/scenarios/03-attack-claim-without-stake.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +# Negative: claim_rewards without a UserStake account must fail (account +# constraint violation: AccountOwnedByWrongProgram). +set -e +. "$(dirname "$0")/_lib.sh" +NAME="03-attack-claim-without-stake" +echo "## $NAME" + +setup_users +post '{"contract":"staking","command":{"UsersNew":{"name":"carol","lamports":10000000}}}' >/dev/null +post '{"contract":"staking","command":{"UsersNew":{"name":"carol_stake","lamports":2000000}}}' >/dev/null + +expect_ok_call "init" "$(init_pool)" + +R=$(post '{"contract":"staking","command":{"Call":{"ix":"claim_rewards","args":{},"accounts":{"pool":"pool","user_stake":"carol_stake","user":"carol"},"signers":["carol"]}}}') +expect_failed_call "claim without stake rejected" "$R" + +scenario_summary "$NAME" diff --git a/tests/scenarios/04-attack-unstake-overflow.sh b/tests/scenarios/04-attack-unstake-overflow.sh new file mode 100755 index 0000000..a5518e9 --- /dev/null +++ b/tests/scenarios/04-attack-unstake-overflow.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Negative: unstake more than staked must fail (InsufficientBalance). +set -e +. "$(dirname "$0")/_lib.sh" +NAME="04-attack-unstake-overflow" +echo "## $NAME" + +setup_users +expect_ok_call "init" "$(init_pool)" +expect_ok_call "alice stake 1000" "$(stake_as alice alice_stake 1000)" + +R=$(post '{"contract":"staking","command":{"Call":{"ix":"unstake","args":{"amount":99999999},"accounts":{"pool":"pool","user_stake":"alice_stake","user":"alice"},"signers":["alice"]}}}') +expect_failed_call "overflow unstake rejected" "$R" +expect_eq "state preserved at 1000" "1000" "$(pool_total_staked)" + +scenario_summary "$NAME" diff --git a/tests/scenarios/05-attack-non-admin-rewards.sh b/tests/scenarios/05-attack-non-admin-rewards.sh new file mode 100755 index 0000000..6b2aace --- /dev/null +++ b/tests/scenarios/05-attack-non-admin-rewards.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +# Negative: add_rewards signed by a non-admin user must fail (WrongAdmin). +set -e +. "$(dirname "$0")/_lib.sh" +NAME="05-attack-non-admin-rewards" +echo "## $NAME" + +setup_users +expect_ok_call "init" "$(init_pool)" + +R=$(post '{"contract":"staking","command":{"Call":{"ix":"add_rewards","args":{"amount":100},"accounts":{"pool":"pool","admin":"bob"},"signers":["bob"]}}}') +expect_failed_call "non-admin add_rewards rejected" "$R" + +scenario_summary "$NAME" diff --git a/tests/scenarios/06-fork-isolation.sh b/tests/scenarios/06-fork-isolation.sh new file mode 100755 index 0000000..b79d027 --- /dev/null +++ b/tests/scenarios/06-fork-isolation.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Forking a scenario at_step=N must rewind the cloned VM to that step's +# pre-Call snapshot, leaving main untouched. Regression for T-R33 fork rewind. +set -e +. "$(dirname "$0")/_lib.sh" +NAME="06-fork-isolation" +echo "## $NAME" + +setup_users +post '{"contract":"staking","command":{"UsersNew":{"name":"dave","lamports":50000000}}}' >/dev/null +post '{"contract":"staking","command":{"UsersNew":{"name":"dave_stake","lamports":2000000}}}' >/dev/null + +expect_ok_call "init" "$(init_pool)" +expect_ok_call "alice stake 500" "$(stake_as alice alice_stake 500)" +expect_eq "main pre-fork=500" "500" "$(pool_total_staked)" + +post '{"contract":"staking","command":{"Scenario":{"sub":{"Fork":{"name":"branch","at_step":1}}}}}' >/dev/null +post '{"contract":"staking","command":{"Scenario":{"sub":{"Switch":{"name":"branch"}}}}}' >/dev/null +expect_eq "branch starts at 0 (rewound)" "0" "$(pool_total_staked)" + +expect_ok_call "branch dave stake 777" "$(stake_as dave dave_stake 777)" +expect_eq "branch=777" "777" "$(pool_total_staked)" + +post '{"contract":"staking","command":{"Scenario":{"sub":{"Switch":{"name":"main"}}}}}' >/dev/null +expect_eq "main preserved=500" "500" "$(pool_total_staked)" + +scenario_summary "$NAME" diff --git a/tests/scenarios/07-back-rewinds-vm.sh b/tests/scenarios/07-back-rewinds-vm.sh new file mode 100755 index 0000000..2cec426 --- /dev/null +++ b/tests/scenarios/07-back-rewinds-vm.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Back must restore the VM accounts to the pre-Call snapshot, not just pop +# the timeline entry. Regression for T-R33. +set -e +. "$(dirname "$0")/_lib.sh" +NAME="07-back-rewinds-vm" +echo "## $NAME" + +setup_users +expect_ok_call "init" "$(init_pool)" +expect_ok_call "alice stake 1000" "$(stake_as alice alice_stake 1000)" +expect_ok_call "bob stake 2000" "$(stake_as bob bob_stake 2000)" +expect_eq "after 2 stakes=3000" "3000" "$(pool_total_staked)" + +post '{"contract":"staking","command":"Back"}' >/dev/null +expect_eq "after 1 back=1000" "1000" "$(pool_total_staked)" +post '{"contract":"staking","command":"Back"}' >/dev/null +expect_eq "after 2 backs=0" "0" "$(pool_total_staked)" + +scenario_summary "$NAME" diff --git a/tests/scenarios/08-blockhash-rotation.sh b/tests/scenarios/08-blockhash-rotation.sh new file mode 100755 index 0000000..ce8980c --- /dev/null +++ b/tests/scenarios/08-blockhash-rotation.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +# T-R40 regression: 50 consecutive add_rewards calls must each execute +# (not silently fail with cu=0 due to blockhash collision in LiteSVM). +set -e +. "$(dirname "$0")/_lib.sh" +NAME="08-blockhash-rotation" +echo "## $NAME" + +setup_users +expect_ok_call "init" "$(init_pool)" + +START=$(date +%s%N) +for _ in $(seq 1 50); do + post '{"contract":"staking","command":{"Call":{"ix":"add_rewards","args":{"amount":1},"accounts":{"pool":"pool","admin":"admin"},"signers":["admin"]}}}' >/dev/null +done +END=$(date +%s%N) +ELAPSED_MS=$(((END - START) / 1000000)) +expect_eq "total_rewards=50 after 50 calls" "50" "$(pool_total_rewards)" +echo " info: 50 calls in ${ELAPSED_MS}ms (avg $((ELAPSED_MS / 50))ms/call)" + +scenario_summary "$NAME" diff --git a/tests/scenarios/09-save-load-roundtrip.sh b/tests/scenarios/09-save-load-roundtrip.sh new file mode 100755 index 0000000..c74d1c7 --- /dev/null +++ b/tests/scenarios/09-save-load-roundtrip.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +# T-R39 regression: SaveSession + Clear + LoadSession reconstructs VM via +# replay of call_payload. The pool's total_staked must come back. +set -e +. "$(dirname "$0")/_lib.sh" +NAME="09-save-load-roundtrip" +echo "## $NAME" + +setup_users +expect_ok_call "init" "$(init_pool)" +expect_ok_call "alice stake 888" "$(stake_as alice alice_stake 888)" +expect_eq "pre-save=888" "888" "$(pool_total_staked)" + +SAVED=$(post '{"contract":"staking","command":"SaveSession"}' | jq -r '.SessionSaved.json') +post '{"contract":"staking","command":"Clear"}' >/dev/null +# After Clear, the VM rewinds to pre-step-0 — pool account doesn't exist yet, +# so the State view either omits it (empty string) or jq returns "null". +post_clear=$(pool_total_staked) +case "$post_clear" in + ""|null) echo " PASS post-clear pool gone"; PASS=$((PASS+1)) ;; + *) echo " FAIL post-clear leaks pool=$post_clear"; FAIL=$((FAIL+1)) ;; +esac + +ESC=$(echo "$SAVED" | jq -Rs '.') +post "{\"contract\":\"staking\",\"command\":{\"LoadSession\":{\"json\":$ESC}}}" >/dev/null +expect_eq "post-load=888" "888" "$(pool_total_staked)" + +scenario_summary "$NAME" diff --git a/tests/scenarios/10-export-cross-scenario.sh b/tests/scenarios/10-export-cross-scenario.sh new file mode 100755 index 0000000..dea90e9 --- /dev/null +++ b/tests/scenarios/10-export-cross-scenario.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Export must aggregate findings from ALL scenarios, not just the active one. +# Regression for the bug discovered in audit round 8 (real-flow harness). +set -e +. "$(dirname "$0")/_lib.sh" +NAME="10-export-cross-scenario" +echo "## $NAME" + +setup_users +expect_ok_call "init" "$(init_pool)" +expect_ok_call "stake" "$(stake_as alice alice_stake 1000)" + +# Fork and record a finding in the branch. +post '{"contract":"staking","command":{"Scenario":{"sub":{"Fork":{"name":"attack","at_step":1}}}}}' >/dev/null +post '{"contract":"staking","command":{"Scenario":{"sub":{"Switch":{"name":"attack"}}}}}' >/dev/null +post '{"contract":"staking","command":{"Finding":{"severity":"High","title":"branch finding","description":"only in branch"}}}' >/dev/null + +# Switch back to main (no findings here) and export. +post '{"contract":"staking","command":{"Scenario":{"sub":{"Switch":{"name":"main"}}}}}' >/dev/null +MD=$(post '{"contract":"staking","command":"Export"}' | jq -r '.Exported.markdown') + +if echo "$MD" | grep -q "branch finding"; then + echo " PASS export aggregates findings across scenarios"; PASS=$((PASS+1)) +else + echo " FAIL export omitted findings from non-active scenario"; FAIL=$((FAIL+1)) +fi +if echo "$MD" | grep -q "Scenario: \`attack\`"; then + echo " PASS export includes attack scenario section"; PASS=$((PASS+1)) +else + echo " FAIL export missing attack section"; FAIL=$((FAIL+1)) +fi + +scenario_summary "$NAME" diff --git a/tests/scenarios/_lib.sh b/tests/scenarios/_lib.sh new file mode 100755 index 0000000..9e4887d --- /dev/null +++ b/tests/scenarios/_lib.sh @@ -0,0 +1,87 @@ +# Shared helpers for scenario scripts. Each scenario sources this and exposes +# a series of `expect_*` assertions; the runner counts them globally. +BASE="${BASE:-http://127.0.0.1:8081}" + +post() { + curl -s -X POST "$BASE/api/cmd" -H 'content-type: application/json' -d "$1" +} + +# Highest-numbered pool entry — State labels look like "pool#N" where N is +# the step index of the most recent mutation. +pool_total_staked() { + post '{"contract":"staking","command":"State"}' \ + | jq -r '[.StateView.accounts[]? | select(.label|startswith("pool"))][-1].decoded.total_staked' \ + 2>/dev/null +} + +pool_total_rewards() { + post '{"contract":"staking","command":"State"}' \ + | jq -r '[.StateView.accounts[]? | select(.label|startswith("pool"))][-1].decoded.total_rewards' \ + 2>/dev/null +} + +session_steps_len() { + post '{"contract":"staking","command":"Session"}' | jq -r '.SessionView.steps | length' +} + +active_scenario() { + post '{"contract":"staking","command":{"Scenario":{"sub":"List"}}}' \ + | jq -r '.ScenarioList.items[] | select(.active==true) | .name' +} + +step_failed() { + # Returns 0 if the step at $1 has an error in its runtime trace. + local idx="$1" + local err + err=$(post "{\"contract\":\"staking\",\"command\":{\"Step\":{\"index\":$idx}}}" \ + | jq -r '.StepDetail.runtime_trace.error // ""') + [ -n "$err" ] +} + +call_failed_in_logs() { + # $1 is the JSON response from a Call; returns 0 if logs show AnchorError. + echo "$1" | jq -r '.StepAdded.logs_excerpt[]? // empty' \ + | grep -qE 'AnchorError|failed:|panicked' +} + +setup_users() { + # Convenience: create pool + admin + alice + alice_stake + bob + bob_stake. + for n in admin pool alice alice_stake bob bob_stake; do + local L=100000000 + case "$n" in alice_stake|bob_stake|pool) L=2000000 ;; esac + post "{\"contract\":\"staking\",\"command\":{\"UsersNew\":{\"name\":\"$n\",\"lamports\":$L}}}" >/dev/null + done +} + +init_pool() { + post '{"contract":"staking","command":{"Call":{"ix":"initialize_pool","args":{"reward_rate":10},"accounts":{"pool":"pool","admin":"admin"},"signers":["pool","admin"]}}}' +} + +stake_as() { + # stake_as <user> <user_stake> <amount> + post "{\"contract\":\"staking\",\"command\":{\"Call\":{\"ix\":\"stake\",\"args\":{\"amount\":$3},\"accounts\":{\"pool\":\"pool\",\"user_stake\":\"$2\",\"user\":\"$1\"},\"signers\":[\"$2\",\"$1\"]}}}" +} + +# ── Assertion helpers — each one increments PASS or FAIL globally ─────────── +PASS=${PASS:-0}; FAIL=${FAIL:-0} +expect_eq() { # name expected actual + if [ "$3" = "$2" ]; then echo " PASS $1"; PASS=$((PASS+1)) + else echo " FAIL $1 | expected:'$2' actual:'$3'"; FAIL=$((FAIL+1)); fi +} +expect_true() { # name boolean + if [ "$2" = "1" ] || [ "$2" = "true" ]; then echo " PASS $1"; PASS=$((PASS+1)) + else echo " FAIL $1 | expected truthy, got '$2'"; FAIL=$((FAIL+1)); fi +} +expect_failed_call() { # name response_json + if call_failed_in_logs "$2"; then echo " PASS $1 (call failed as expected)"; PASS=$((PASS+1)) + else echo " FAIL $1 — call did NOT fail; logs: $(echo "$2" | jq -r '.StepAdded.logs_excerpt[0]?' 2>/dev/null)"; FAIL=$((FAIL+1)); fi +} +expect_ok_call() { # name response_json + if call_failed_in_logs "$2"; then echo " FAIL $1 — call failed unexpectedly; logs: $(echo "$2" | jq -r '.StepAdded.logs_excerpt[]?' 2>/dev/null | head -3)"; FAIL=$((FAIL+1)) + else echo " PASS $1"; PASS=$((PASS+1)); fi +} + +scenario_summary() { + echo " ── $1: $PASS passed / $FAIL failed ──" + return $FAIL +} diff --git a/tests/scenarios/run.sh b/tests/scenarios/run.sh new file mode 100755 index 0000000..cda6139 --- /dev/null +++ b/tests/scenarios/run.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Scenario runner: spins up a fresh `ilold serve` per scenario file so they +# don't pollute each other, runs the scenario, and aggregates pass/fail. +set -uo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +PORT="${ILOLD_TEST_PORT:-8081}" +FIXTURE="${ILOLD_FIXTURE:-$ROOT/tests/fixtures/solana/staking}" +BIN="${ILOLD_BIN:-$ROOT/target/release/ilold}" +LOG="/tmp/ilold-scenarios-serve.log" + +if [ ! -x "$BIN" ]; then + echo "binary not found: $BIN — run 'cargo build --release --bin ilold -p ilold-cli'" >&2 + exit 2 +fi + +start_serve() { + pkill -f "ilold serve --port $PORT" 2>/dev/null || true + sleep 0.5 + "$BIN" serve --port "$PORT" "$FIXTURE" > "$LOG" 2>&1 & + for _ in 1 2 3 4 5 6 7 8 9 10; do + sleep 0.4 + if curl -sf "http://127.0.0.1:$PORT/api/project/map" >/dev/null; then return 0; fi + done + echo "serve failed to come up; tail of $LOG:" >&2 + tail -20 "$LOG" >&2 + return 1 +} + +stop_serve() { + pkill -f "ilold serve --port $PORT" 2>/dev/null || true +} + +TOTAL_PASS=0 +TOTAL_FAIL=0 +SCENARIOS_OK=() +SCENARIOS_FAIL=() + +DIR="$(dirname "$0")" +echo "================================================================" +echo " ilold scenario suite — fixture: $FIXTURE" +echo "================================================================" + +for f in "$DIR"/[0-9][0-9]-*.sh; do + [ -f "$f" ] || continue + name=$(basename "$f" .sh) + start_serve || { echo " skipped $name (serve down)"; continue; } + set +e + output=$(env BASE="http://127.0.0.1:$PORT" PASS=0 FAIL=0 bash "$f" 2>&1) + exit_code=$? + set -e + echo "$output" + counts=$(echo "$output" | tail -3 | grep -oE '[0-9]+ passed / [0-9]+ failed' | head -1) + p=$(echo "$counts" | grep -oE '^[0-9]+' | head -1) + fc=$(echo "$counts" | grep -oE '[0-9]+ failed' | grep -oE '[0-9]+') + TOTAL_PASS=$((TOTAL_PASS + ${p:-0})) + TOTAL_FAIL=$((TOTAL_FAIL + ${fc:-0})) + if [ "$exit_code" = "0" ] && [ "${fc:-0}" = "0" ]; then + SCENARIOS_OK+=("$name") + else + SCENARIOS_FAIL+=("$name") + fi + stop_serve +done + +echo +echo "================================================================" +echo " SUMMARY: ${#SCENARIOS_OK[@]} scenarios green, ${#SCENARIOS_FAIL[@]} scenarios red" +echo " $TOTAL_PASS assertions passed / $TOTAL_FAIL failed" +echo "================================================================" +if [ ${#SCENARIOS_FAIL[@]} -gt 0 ]; then + echo "FAILED scenarios:" + for s in "${SCENARIOS_FAIL[@]}"; do echo " - $s"; done + exit 1 +fi +exit 0 From 1dc0f8104bc28cfde65f8e06467be13456d91530 Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Sat, 9 May 2026 10:12:00 +0200 Subject: [PATCH 079/115] test(scenarios): add WebSocket broadcast scenario via python websockets Black-box bash scenarios cover REST and state mutations but cannot validate that the WebSocket pipeline actually emits the right frames. The new ws-broadcast.py opens a /ws connection, drives a representative sequence (UsersNew x4, two Calls, Fork, Switch, Back, Clear, Delete), and asserts each emitted topic count matches expectations and that session_add_node frames carry the runtime metadata that the canvas relies on (T-R37 regression). The runner now picks it up automatically when python3 and the websockets package are available, and skips with a friendly message otherwise so CI without websockets is not blocked. --- tests/scenarios/run.sh | 23 +++++++ tests/scenarios/ws-broadcast.py | 103 ++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) create mode 100644 tests/scenarios/ws-broadcast.py diff --git a/tests/scenarios/run.sh b/tests/scenarios/run.sh index cda6139..a37ff42 100755 --- a/tests/scenarios/run.sh +++ b/tests/scenarios/run.sh @@ -62,6 +62,29 @@ for f in "$DIR"/[0-9][0-9]-*.sh; do stop_serve done +echo +# Optional WebSocket broadcast test — requires python3 + websockets. +WS_PY="$DIR/ws-broadcast.py" +if [ -f "$WS_PY" ] && command -v python3 >/dev/null 2>&1 \ + && python3 -c 'import websockets' 2>/dev/null; then + echo "## ws-broadcast (python3 + websockets)" + start_serve || { echo " skipped ws-broadcast (serve down)"; } + set +e + python3 "$WS_PY" 2>&1 + ws_exit=$? + set -e + stop_serve + if [ "$ws_exit" = "0" ]; then + SCENARIOS_OK+=("ws-broadcast") + TOTAL_PASS=$((TOTAL_PASS + 1)) + else + SCENARIOS_FAIL+=("ws-broadcast") + TOTAL_FAIL=$((TOTAL_FAIL + 1)) + fi +else + echo "## ws-broadcast — skipped (need python3 + 'pip install websockets')" +fi + echo echo "================================================================" echo " SUMMARY: ${#SCENARIOS_OK[@]} scenarios green, ${#SCENARIOS_FAIL[@]} scenarios red" diff --git a/tests/scenarios/ws-broadcast.py b/tests/scenarios/ws-broadcast.py new file mode 100644 index 0000000..ec2d26a --- /dev/null +++ b/tests/scenarios/ws-broadcast.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Captura broadcasts WS durante una sesión REST y reporta lo que recibió.""" +import asyncio +import json +import sys +import time +import urllib.request +import websockets + +BASE = "http://127.0.0.1:8081" + +def post(body): + req = urllib.request.Request( + f"{BASE}/api/cmd", + data=json.dumps(body).encode(), + headers={"content-type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=5) as r: + return json.loads(r.read()) + +async def listen(received: list, ready: asyncio.Event): + async with websockets.connect("ws://127.0.0.1:8081/ws") as ws: + ready.set() + try: + while True: + msg = await asyncio.wait_for(ws.recv(), timeout=10.0) + received.append(json.loads(msg)) + except asyncio.TimeoutError: + return + +async def driver(): + received = [] + ready = asyncio.Event() + listener = asyncio.create_task(listen(received, ready)) + await ready.wait() + await asyncio.sleep(0.1) + + # Setup + for name, lam in [("admin", 100_000_000), ("pool", 2_000_000), + ("alice", 50_000_000), ("alice_stake", 2_000_000)]: + post({"contract":"staking","command":{"UsersNew":{"name":name,"lamports":lam}}}) + + post({"contract":"staking","command":{"Call":{"ix":"initialize_pool","args":{"reward_rate":10}, + "accounts":{"pool":"pool","admin":"admin"},"signers":["pool","admin"]}}}) + post({"contract":"staking","command":{"Call":{"ix":"stake","args":{"amount":1234}, + "accounts":{"pool":"pool","user_stake":"alice_stake","user":"alice"},"signers":["alice_stake","alice"]}}}) + + post({"contract":"staking","command":{"Scenario":{"sub":{"Fork":{"name":"branch_x","at_step":1}}}}}) + post({"contract":"staking","command":{"Scenario":{"sub":{"Switch":{"name":"branch_x"}}}}}) + post({"contract":"staking","command":"Back"}) + post({"contract":"staking","command":"Clear"}) + post({"contract":"staking","command":{"Scenario":{"sub":{"Switch":{"name":"main"}}}}}) + post({"contract":"staking","command":{"Scenario":{"sub":{"Delete":{"name":"branch_x"}}}}}) + + await asyncio.sleep(0.5) + listener.cancel() + try: await listener + except: pass + return received + +async def main(): + received = await driver() + counts = {} + runtime_with_data = 0 + for m in received: + t = m.get("type", "?") + counts[t] = counts.get(t, 0) + 1 + if t == "session_add_node" and m.get("runtime"): + r = m["runtime"] + if r.get("compute_units", 0) > 0 and r.get("logs_excerpt"): + runtime_with_data += 1 + + print("=== WS broadcast count by type ===") + for k, v in sorted(counts.items()): + print(f" {k}: {v}") + print() + print(f"=== session_add_node con runtime CU+logs: {runtime_with_data} ===") + print() + + expected_topics = { + "solana_users_changed": 4, # 4 UsersNew + "session_add_node": 2, # 2 Calls + "scenario_forked": 1, + "scenario_switched": 2, # main→branch, branch→main + "session_remove_node": 1, # Back + "session_clear": 1, + "scenario_deleted": 1, + } + issues = 0 + for topic, expected in expected_topics.items(): + actual = counts.get(topic, 0) + if actual != expected: + print(f" ISSUE {topic}: expected {expected}, got {actual}") + issues += 1 + else: + print(f" OK {topic}: {actual}") + + print() + print(f"=== {len(received)} frames total / {issues} issues ===") + sys.exit(0 if issues == 0 else 1) + +asyncio.run(main()) From 5b32e415fa3793ab648e0ef7b0188d34035cc291 Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Sat, 9 May 2026 10:17:48 +0200 Subject: [PATCH 080/115] fix(ui): guard async onMount race + Solana-aware legend hints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit round 10 caught two real frontend issues. The contract page's onMount awaits getProjectMap, getProgram, getContract, getCallGraph and getSequences in series, then writes kind, solanaProgram, contract, callgraphRaw and seqTree. If the user navigates to a different contract while any of those promises is in flight, the resolution wins the race and stomps the new mount's state — surfacing as 'Solana program suddenly thinks it is Solidity' or stale function lists. Each await now checks a stillFresh() guard (mountCancelled + page.params.name match) before assigning, with onDestroy bumping the cancel flag. The Legend component had a binary mode === 'cfg' / else branch that printed Solidity-only hints (Function/Entry block/Return/Revert in CFG, State-changing/Read-only/Conditions in others) regardless of backend; auditors on Solana saw 'Function' on a node labelled Instruction. Legend now takes a kind prop and prints Instruction/Account type/PDA hints for Solana CFG, Trace step/Failed for Session, and an explicit not-implemented note for Sequences. --- .../src/lib/components/contract/Legend.svelte | 21 +++++++++- .../src/routes/contract/[name]/+page.svelte | 40 ++++++++++++++----- 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/crates/ilold-web/frontend/src/lib/components/contract/Legend.svelte b/crates/ilold-web/frontend/src/lib/components/contract/Legend.svelte index 230f4e0..2a161c0 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/Legend.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/Legend.svelte @@ -1,5 +1,11 @@ <script lang="ts"> - let { mode }: { mode: 'cfg' | 'sequences' | 'session' } = $props(); + let { + mode, + kind = 'solidity', + }: { + mode: 'cfg' | 'sequences' | 'session'; + kind?: 'solidity' | 'solana'; + } = $props(); </script> <div @@ -15,7 +21,18 @@ 0 0 0 1px rgba(91, 155, 213, 0.04); " > - {#if mode === 'cfg'} + {#if kind === 'solana' && mode === 'cfg'} + <span><span class="inline-block w-2 h-2 align-middle mr-1" style="border-radius: 4px; background: var(--color-accent); box-shadow: 0 0 6px color-mix(in srgb, var(--color-accent) 40%, transparent);"></span>Instruction</span> + <span><span class="inline-block w-2 h-2 align-middle mr-1" style="border-radius: 4px; border: 1px solid var(--color-success);"></span>Account type</span> + <span><span class="inline-block w-2 h-2 align-middle mr-1" style="border-radius: 4px; background: var(--color-warning); box-shadow: 0 0 6px color-mix(in srgb, var(--color-warning) 40%, transparent);"></span>PDA / signer</span> + <span class="text-text-dim">Click an instruction → add to canvas</span> + {:else if kind === 'solana' && mode === 'session'} + <span><span class="inline-block w-2 h-2 align-middle mr-1" style="border-radius: 4px; background: var(--color-accent); box-shadow: 0 0 6px color-mix(in srgb, var(--color-accent) 40%, transparent);"></span>Trace step</span> + <span><span class="inline-block w-2 h-2 align-middle mr-1" style="border-radius: 4px; background: var(--color-danger); box-shadow: 0 0 6px color-mix(in srgb, var(--color-danger) 40%, transparent);"></span>Failed</span> + <span class="text-text-dim">Right-click → fork scenario · ⌘K → Execute</span> + {:else if kind === 'solana'} + <span class="text-text-dim">Sequences view is not implemented for Solana yet — switch to Program or Session</span> + {:else if mode === 'cfg'} <span><span class="inline-block w-2 h-2 align-middle mr-1" style="border-radius: 4px; background: var(--color-accent); box-shadow: 0 0 6px color-mix(in srgb, var(--color-accent) 40%, transparent);"></span>Function</span> <span><span class="inline-block w-2 h-2 align-middle mr-1" style="border-radius: 4px; background: var(--color-accent-dark); box-shadow: 0 0 6px color-mix(in srgb, var(--color-accent-dark) 40%, transparent);"></span>Entry block</span> <span><span class="inline-block w-2 h-2 align-middle mr-1" style="border-radius: 4px; background: var(--color-success); box-shadow: 0 0 6px color-mix(in srgb, var(--color-success) 40%, transparent);"></span>Return</span> diff --git a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte index a9b65bc..cffbe3e 100644 --- a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte +++ b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte @@ -849,9 +849,19 @@ await refreshSolanaUsers(); } + // Guard against navigation race: an async onMount that awaits multiple + // network calls can complete AFTER the user navigates to a different + // contract, overwriting the new contract's state with the old one's + // data. mountToken is captured before the first await; if the user + // navigates we bump cancel via onDestroy and the guard short-circuits. + let mountCancelled = $state(false); + onDestroy(() => { mountCancelled = true; }); + onMount(async () => { const contractName = page.params.name; if (!contractName) return; + const expected = contractName; + const stillFresh = () => !mountCancelled && page.params.name === expected; // Graph store is global — stale nodes from a previous contract must be // wiped or they'd pollute this contract's canvas (and leave the sidebar // out of sync because local Sets re-init empty on re-mount). @@ -864,12 +874,15 @@ setSearchContext(contractName); try { const pm = await getProjectMap(); + if (!stillFresh()) return; kind = pm.kind === 'solana' ? 'solana' : 'solidity'; if (kind === 'solana') { try { - solanaProgram = await getProgram(contractName); + const prog = await getProgram(contractName); + if (!stillFresh()) return; + solanaProgram = prog; } catch { - error = `Program "${contractName}" not found`; + if (stillFresh()) error = `Program "${contractName}" not found`; return; } projectMap = []; @@ -877,18 +890,27 @@ return; } projectMap = pm.contracts ?? []; - contract = await getContract(contractName); + const ctr = await getContract(contractName); + if (!stillFresh()) return; + contract = ctr; const callgraphData = await getCallGraph(contractName); + if (!stillFresh()) return; callgraphRaw = callgraphData; // Sequences/analysis are Solidity-only; expected to 400 for Solana. - try { seqTree = await getSequences(contractName); } catch (e) { - if (kind !== 'solana') console.warn('getSequences failed:', e); + try { + const tree = await getSequences(contractName); + if (stillFresh()) seqTree = tree; + } catch (e) { + if (stillFresh() && kind !== 'solana') console.warn('getSequences failed:', e); } - try { seqAnalysis = await getSequenceAnalysis(contractName); } catch (e) { - if (kind !== 'solana') console.warn('getSequenceAnalysis failed:', e); + try { + const analysis = await getSequenceAnalysis(contractName); + if (stillFresh()) seqAnalysis = analysis; + } catch (e) { + if (stillFresh() && kind !== 'solana') console.warn('getSequenceAnalysis failed:', e); } } catch (e) { - error = `Contract "${contractName}" not found`; + if (stillFresh()) error = `Contract "${contractName}" not found`; } }); @@ -1884,7 +1906,7 @@ /> {/if} - <Legend {mode} /> + <Legend {mode} {kind} /> {/if} </div> From 70f7c56de805ab5cb20cf9e99b0bfe85c671fee3 Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Sat, 9 May 2026 10:28:44 +0200 Subject: [PATCH 081/115] docs: audit history, SDD roadmap, and Solana testing guide Three new documents close out the Solana parity sprint and set up the next one. docs/internal/audits-summary.md catalogues every audit round 1 through 10 with the verified findings, the rejected false positives, and the corresponding fix commit, so the next contributor can trace why each part of the surface looks the way it does. docs/internal/sdd-roadmap.md justifies the three remaining items (CI pipeline, audit deliverable export, deterministic save/load) under the gentle-ai SDD methodology, with scope, out-of-scope, threat-model questions and apply estimates. docs/guide/src/solana-testing.md is the practical companion to solana-support.md: build, serve, REPL command tables grouped by area, exact differences vs Solidity, scenario suite usage, manual audit walkthrough, and known limitations. SUMMARY.md links the new testing page so it is reachable from the published guide. --- docs/guide/src/SUMMARY.md | 1 + docs/guide/src/solana-testing.md | 194 +++++++++++++++++++++++++++++++ docs/internal/audits-summary.md | 165 ++++++++++++++++++++++++++ docs/internal/sdd-roadmap.md | 136 ++++++++++++++++++++++ 4 files changed, 496 insertions(+) create mode 100644 docs/guide/src/solana-testing.md create mode 100644 docs/internal/audits-summary.md create mode 100644 docs/internal/sdd-roadmap.md diff --git a/docs/guide/src/SUMMARY.md b/docs/guide/src/SUMMARY.md index a06a517..12edcaa 100644 --- a/docs/guide/src/SUMMARY.md +++ b/docs/guide/src/SUMMARY.md @@ -16,6 +16,7 @@ - [Audit Walkthrough](./workflows/audit-walkthrough.md) - [Taint Analysis](./workflows/taint-analysis.md) - [Solana Support](./solana-support.md) +- [Solana Testing Guide](./solana-testing.md) # Reference diff --git a/docs/guide/src/solana-testing.md b/docs/guide/src/solana-testing.md new file mode 100644 index 0000000..8c8d713 --- /dev/null +++ b/docs/guide/src/solana-testing.md @@ -0,0 +1,194 @@ +# Testing Ilold against a Solana program + +This page is the practical guide for running, exercising and validating +Ilold's Solana support. It complements `solana-support.md` (which +explains the conceptual model) and is meant to be read by someone who +wants to break the tool, contribute fixes, or audit a real program. + +## 1. Build + serve + +```bash +cargo build --release --bin ilold -p ilold-cli +./target/release/ilold serve --port 8080 tests/fixtures/solana/staking +``` + +`tests/fixtures/solana/staking` is the canonical Anchor fixture: a +toy staking program with `initialize_pool`, `stake`, `unstake`, +`add_rewards`, `claim_rewards`. The IDL lives in `idls/staking.json` +and the compiled `.so` is committed to `bin/staking.so` so the suite +runs even on machines without the Anchor toolchain. + +## 2. The REPL + +In a second terminal: + +```bash +./target/release/ilold explore --base-url http://127.0.0.1:8080 --contract staking +``` + +Type `?` for the help. Two-terminal flows (one for `serve`, one for +`explore`) are the supported mode — both stay in sync via REST + WS. + +## 3. Command surface + +### Session + +| Command | Description | +| --- | --- | +| `call <ix> arg=val acc=user` | Concise key-value call. Unmapped names become local keypairs. | +| `call <ix> {json}` | Full payload `{args, accounts, signers}` form. | +| `back` / `b` | Pop the last step and rewind the VM to its pre-Call snapshot. | +| `clear` / `cl` | Drop all steps in the active scenario; rewinds the VM to genesis. | +| `state` / `s` | Decoded view of accounts mutated this session. | +| `session` / `s` | Active scenario summary (steps + findings count). | +| `step <i>` / `st <i>` | Re-inspect step `i`: CU, logs, decoded diffs. | + +### Scenarios + +| Command | Description | +| --- | --- | +| `scenario new <name>` / `sc new` | Create an empty scenario. | +| `scenario fork <name> [step]` | Fork at step N (or HEAD). The branch VM is rewound to that step's pre-Call snapshot. | +| `scenario switch <name>` | Activate a scenario. Each scenario has its own VM and users. | +| `scenario list` | Show all scenarios with active marker + step count. | +| `scenario delete <name>` | Remove a scenario (cannot delete the active one). | + +### Solana runtime (no Solidity counterpart) + +| Command | Description | +| --- | --- | +| `users new <name> [lamports]` | Create a keypair and airdrop SOL into it. Default: 10 SOL. | +| `airdrop <name> <lamports>` | Top up an existing keypair. | +| `time-warp <delta_seconds>` / `tw` | Advance the `Clock` sysvar. | +| `pda <ix>` | List PDAs declared by an instruction in the IDL. | +| `inspect <pubkey>` | Decode an account by Anchor discriminator. | + +### Analysis + +| Command | Description | +| --- | --- | +| `info <ix>` / `i <ix>` | Args, accounts, flags, discriminator. | +| `funcs` / `f` / `funcs-all` / `fa` | Instruction list (compact / verbose). | +| `vars` / `v` / `vars-all` / `va` | Account types declared in IDL. | +| `who <account_type>` | Instructions referencing this type (heuristic snake_case → PascalCase). | +| `timeline <pubkey>` / `tl` | Cross-step mutation history with before/after decoded. | + +### Findings & workspace + +| Command | Description | +| --- | --- | +| `finding <severity> <title>` / `fi` | Record a finding. Severities: critical, high, medium, low, info. | +| `note <text>` / `n` | Annotation on the active sequence. | +| `status <ix> <state>` | Mark instruction reviewed / suspicious / etc. | +| `findings` / `fl` | List recorded findings. | +| `export` / `ex` | Markdown report aggregating findings + steps from ALL scenarios. | +| `save <name>` / `load <name>` | Persist / restore a session JSON in `~/.ilold/sessions/`. | + +## 4. Differences vs Solidity + +The REPL is the same shell but the backends are very different. This +table says exactly what overlaps, what diverges and what is not yet +implemented. + +| Concept | Solidity | Solana | +| --- | --- | --- | +| Entry point | function on a contract | instruction on a program | +| Persistent state | contract state variables | accounts owned by the program | +| Caller identity | `msg.sender` (implicit) | signers passed by the client | +| `who <X>` | finds writers / readers of a state variable (uses CFG) | finds instructions referencing an account type (heuristic on the IDL) | +| `timeline <X>` | mutation history of a state variable | mutation history of an account pubkey, decoded | +| `step <i>` | reads the step's narrative from the saved CFG path | re-prints the runtime trace (CU, logs, account diffs) of step i | +| `slice <fn> <var>` | backward / forward dataflow on the function CFG | **not implemented** — needs a handler AST extractor (Phase 2) | +| `trace <fn>` | full execution flow with modifier inlining | **not implemented** — same reason | +| `sequence` | narrative of cross-step dependencies | aliased to `session` (no narrative engine yet) | +| Execution model | symbolic (CFG + paths) | concrete (LiteSVM with the real BPF binary) | +| Save/Load | restores step list + paths; no VM | restores step list + replays Calls against a fresh VM (T-R39) | +| `Back` | drops the last step from the timeline only | drops the step AND restores the VM to the pre-Call snapshot (T-R33) | + +## 5. Smoke test suite + +```bash +bash tests/scenarios/run.sh +``` + +12 scenarios under `tests/scenarios/`. Each spawns a fresh `ilold serve` +on port 8081 (override with `ILOLD_TEST_PORT`) and aggregates pass/fail. +Adding a scenario: + +1. Create `tests/scenarios/NN-name.sh` (use `01-happy-path.sh` as + template, source `_lib.sh`). +2. Make it executable: `chmod +x …`. +3. Re-run the runner; it picks up `[0-9][0-9]-*.sh` automatically. +4. Optionally provide a python websockets script next to the bash + files; if the runner finds a `.py` it will run it after the bash + suite when `python3 + websockets` is installed. + +The current set covers: + +- Happy path with state accumulation. +- Four negative attack vectors that must be rejected by the program + (re-init, claim without stake, unstake overflow, non-admin + add_rewards). +- Fork isolation: a branch VM rewinds to the fork point and changes + there do not leak to main. +- `Back` rewinds the VM (T-R33). +- 50 consecutive Calls execute (T-R40 blockhash rotation). +- `Save → Clear → Load` reconstructs the VM (T-R39). +- Findings recorded in any scenario surface in the markdown export. +- WebSocket broadcast count + payload (T-R37 runtime metadata). + +## 6. Reproducing an audit walkthrough manually + +```text +ilold[staking]> users new admin 100000000 +ilold[staking]> users new pool 2000000 +ilold[staking]> users new alice 50000000 +ilold[staking]> users new alice_stake 2000000 +ilold[staking]> call initialize_pool reward_rate=10 pool=pool admin=admin +ilold[staking → initialize_pool]> call stake amount=1000 pool=pool user_stake=alice_stake user=alice +ilold[staking → … → stake]> step 1 +ilold[staking → … → stake]> who Pool +ilold[staking → … → stake]> timeline <pool-pubkey> +ilold[staking → … → stake]> finding High "missing reentrancy guard" +ilold[staking → … → stake]> findings +ilold[staking → … → stake]> export +ilold[staking → … → stake]> save my-audit +ilold[staking → … → stake]> clear +ilold[staking]> load my-audit +``` + +## 7. Frontend testing + +```bash +cd crates/ilold-web/frontend +npm install +npm run dev # vite proxy points /api and /ws to http://localhost:8080 +``` + +In another terminal `./target/release/ilold serve --port 8080 …` and +open `http://localhost:5173/contract/staking`. The page subscribes to +the same WS stream the CLI consumes, so any command run from the REPL +shows up live (with full CU / diffs / logs after T-R37). + +To stress the UI specifically (race fix in audit round 10): + +1. Open `/contract/staking` (Solana). +2. Click a different contract from the project map mid-load (before + the page renders). +3. The new contract must render — no `kind=solidity` regression on a + Solana program. + +## 8. Known limitations as of this writing + +- `slice`, `trace`, `sequence` analysis for Solana require a handler + AST and are deferred (Phase 2 in the SDD roadmap). +- `Save → Load` regenerates user keypairs on the next session, so + programs that hash signer pubkeys into PDAs will see different + derived addresses after `load`. Tracked under + `docs/internal/sdd-roadmap.md` — topic 3. +- `time-warp` advances `unix_timestamp` linearly; negative deltas do + not reverse `slot`. +- `who` uses snake_case → PascalCase heuristic to map account fields + to types; non-conventional naming will miss. +- The CFG visual layout for Solana is a flat bipartite (instructions + vs accounts). A redesign is queued pending design feedback. diff --git a/docs/internal/audits-summary.md b/docs/internal/audits-summary.md new file mode 100644 index 0000000..85a88c7 --- /dev/null +++ b/docs/internal/audits-summary.md @@ -0,0 +1,165 @@ +# Audit rounds 1–10 — summary + +This document records every audit pass run during the Solana-parity sprint, +what each round found, and which findings turned out to be real after +verification on disk. The 30–40% false-positive rate of sub-agent reports +is the reason every finding here is annotated with “verified” or +“rejected”. + +## Methodology + +- Each round is a sub-agent that explores a slice of the codebase with a + prompt focused on a specific concern (CLI parity, frontend, WS, VM + lifecycle, etc.). +- The orchestrator (me) verifies every finding against the real source + before applying a fix. False positives are dropped without code change. +- After fixes ship, the bash + python scenario suite under + `tests/scenarios/` is re-run as smoke test. + +## Round 1 — initial parity baseline (T-R29) + +- Goal: map every Solidity command in the REPL onto its Solana + counterpart. +- Findings: confirmed Solana lacked `step`, `findings`, `export`, + `who`, `timeline` (logged as T-R38). +- False positives: none. + +## Round 2 — frontend handlers + store WS + +- Goal: review handlers in `+page.svelte` and the WS store. +- Verified bugs: + - Trace context menu offered no Fork / Remove from here for trace + nodes (`+page.svelte:1226`). → **T-R30**. + - `handleSessionClear` painted the canvas locally instead of trusting + the WS broadcast. → **T-R30**. +- Rejected: `notifyFailure` missing in `handleSolanaSubmit` (already + caught inline by `SolanaRunForm.svelte:95`). + +## Round 3 — backend exploration + VM + +- Verified bugs: + - `Back` did not rewind the VM, only popped the timeline. → **T-R33**. + - `Fork(at_step=N)` left the cloned VM with the full timeline’s state. + → **T-R33 fork rewind**. + - `LoadSession` did not rebuild the VMs at all. → **T-R39**. + - `TimeWarp` persisted across `Back`. Documented as design in + `solana-support.md`. +- Rejected: `TransactionError` Debug formatting (`logs_excerpt` already + carries the full Anchor error chain). + +## Round 4 — CFG visual + frontend kind handling + +- Verified: trace card key collisions in `{#each traceSteps as step + (step.stepIndex)}` (collisions across scenarios broke the Timeline + button). → fixed in `aa3e421`. +- Documented: CFG visual layout is bipartite by design; the user + reported it as “feo” but no code defect — pending captura to scope + redesign. + +## Round 5 — CLI prompt sync + +- Verified bugs: + - `sync_steps` only deserialized `CommandResult::SessionView` + (Solidity), so Solana attach mode never updated the prompt. → + **T-R35**. + - `sync_scenarios` ran only inside the `scenario` handler, not in the + REPL loop, so a second terminal saw stale lists. → **T-R36**. +- Verified bug found by orchestrator (not the sub-agent): WS + `session_add_node` carried no runtime metadata, so calls executed from + CLI showed `0 CU 0 diffs` in the canvas. → **T-R37**. + +## Round 6 — endpoints, store, edge cases + +- Findings classified after disk verification: + - Real: scenario list not synced in loop (already covered T-R36). + - Real: TimeWarp emits no broadcast — deferred (no UI consumes it + yet). + - Rejected: users/airdrop sync “bug” — CLI has no users cache to keep + in sync. + - Rejected: notes/findings broadcast — not consumed by frontend. + +## Round 7 — CLI + frontend deep dive + +- Verified bugs: + - Solana CLI lacked `finding`, `sequence`, and `browser` handlers + (cell-fall to the “Unknown command” arm). → fixed in `51c4b38`. +- Documented gaps: + - `slice` and `trace` are not implemented for Solana; they need a + handler-level AST. Documented in `solana-support.md`. + - Tab completion does not complete command names — UX nicety, not a + blocker. + +## Round 8 — developer UX + +- Verified bugs: + - `tests/e2e_lever.rs` had an outdated `add_solana_step` signature + after T-R39 added `call_payload`. → fixed in `6cbc825`. + - `add_step.rs:108` used `.unwrap()` on `session.steps.last()` — + replaced by `VmOperationFailed`. → fixed in `6cbc825`. +- Deferred: edition-2024 nightly requirement, missing CI workflow, + zero rustdoc on public model types, no “adding a program” guide. + +## Round 9 — auditor UX + +- Verified bugs: + - Step failures were not visible in CLI output — the `StepAdded` + print didn’t scan logs for `AnchorError`. → fixed in `6cbc825`, + failing steps now print `[FAILED]` in red and Anchor lines stand + out. + - `execute_export` only walked the active scenario; findings + recorded in any other branch were silently dropped from the + deliverable. → fixed in `6cbc825`, signature now takes an iterator + over `(scenario_name, session)` and produces a Findings (all + scenarios) section. +- Rejected: `--no-signer` claimed to be dead code — verified at + `explore.rs:1679` that it is actually wired. +- Deferred: keypair persistence (Save/Load reproducibility), audit + metadata + recommendations templates in export, constraint + introspection (anchor-syn AST), CPI cross-program flow. + +## Round 10 — frontend Svelte 5 deep + +- Verified bugs: + - **CRITICAL**: `onMount` in `+page.svelte:852` awaited + `getProjectMap`, `getProgram`, `getContract`, `getCallGraph`, + `getSequences`, `getSequenceAnalysis` and assigned the results to + component state without checking the route was still on the same + contract. Race window between routes corrupted state. → fixed in + `5b32e41` with `mountCancelled` + `stillFresh()` guard before each + assignment. + - **LOW**: `Legend.svelte` showed Solidity-only hints (Function / + Entry block / Return / Revert) regardless of backend. → fixed in + the same commit by branching on `kind`. +- Sections that came back clean: runes correctness, WS reconnection + back-off, concurrency, bundle size, type safety. + +## Decreasing-returns curve + +| Round | Real bugs found | False positives | +| --- | --- | --- | +| 1 | 1 (parity gap) | 0 | +| 2 | 2 | 1 | +| 3 | 4 | 1 | +| 4 | 1 | 0 | +| 5 | 3 (incl. orchestrator) | 0 | +| 6 | 1 | 3 | +| 7 | 3 | 0 | +| 8 | 2 | 0 (4 deferred) | +| 9 | 2 | 1 (4 deferred) | +| 10 | 2 | 0 | + +Total: 21 real bugs shipped, 6 false positives identified, ~15 items +deferred as design / architectural debt with engram + doc trail. + +## Closed by the smoke suite + +Every fix has at least one assertion in `tests/scenarios/`: + +- Happy path + 4 attack vectors (scenarios 01–05). +- Fork isolation + Back rewind (06, 07) cover T-R33. +- Blockhash rotation (08) covers T-R40. +- Save/Load round-trip (09) covers T-R39. +- Cross-scenario export (10) covers the round 9 export gap. +- WebSocket broadcast count (`ws-broadcast.py`) covers T-R37. + +`bash tests/scenarios/run.sh` is the regression bar: 11 green / 40 PASS. diff --git a/docs/internal/sdd-roadmap.md b/docs/internal/sdd-roadmap.md new file mode 100644 index 0000000..f303d90 --- /dev/null +++ b/docs/internal/sdd-roadmap.md @@ -0,0 +1,136 @@ +# SDD roadmap — what we ship next, and why + +We close the Solana-parity sprint with 10 commits and a full smoke suite +green. Three pieces of debt remain that the auditor and developer +audits flagged. This roadmap explains, for each one, the business +reason, the proposed scope, the risk of *not* doing it, and the SDD +artifacts we will produce before touching code. + +## Why SDD here + +The Solana parity sprint moved fast: 10 audit rounds, ~21 fixes, a +scenario suite. From here forward the changes are smaller in line +count but bigger in surface area (CI policy, customer deliverable +format, security-sensitive persistence). SDD (proposal → spec / design +→ tasks → apply → verify → archive) protects us from: + +- Shipping a CI policy nobody reviewed. +- Producing a deliverable export that misleads clients. +- Persisting auditor keypairs without thinking through the threat model. + +Each item below produces its artifacts under `docs/sdd/<change>/`. That +directory is gitignored on purpose (it is internal scratch); only the +final code change and the commit body land in version control. The +contents of this roadmap are the public-facing summary. + +## Topic 1 — `sdd/01-ci-pipeline` (smallest, ships first) + +**Goal**: every PR that touches the workspace must run `cargo test +--workspace` and `bash tests/scenarios/run.sh`. Without it, the next +contributor (human or LLM) can break T-R33/T-R37/T-R40 silently. + +**Justification**: audit round 8 found `tests/e2e_lever.rs` already +broken on `main` because the function signature changed and nobody +caught it. CI is the cheapest hedge against that. + +**Scope**: +- `.github/workflows/test.yml` runs the unit tests + the scenario + suite on Linux runners. +- Skip the python WS scenario when `pip install websockets` is not + available, so a minimal CI image still works. +- No external infra (no Docker, no caching tweaks beyond cargo's + default). + +**Out of scope**: building/serving the Anchor `.so` from scratch (the +fixture ships pre-compiled), Solidity tests (already covered in unit +tests), publish/release pipelines. + +**SDD artifacts**: `docs/sdd/01-ci-pipeline/{proposal,spec,design,tasks}.md`. + +**Apply estimate**: 30 minutes. + +## Topic 2 — `sdd/02-audit-deliverable-export` (next) + +**Goal**: `execute_export` produces a markdown document a security +auditor can hand to a client, not the current minimalist debug dump. + +**Justification**: round 9 (auditor UX) flagged the export as a +blocker for delivering paid audits — it lacks audit metadata +(date, auditor name, contract version), methodology section, +per-finding code location / reproduction / impact / recommendation, +and severity matrix. + +**Scope**: +- `SolanaCommand::Export { audit_metadata: Option<AuditMetadata> }` + with an explicit struct (auditor name, project, commit hash, + date). +- New sections in the markdown: Methodology, Severity Matrix, + Per-finding template (Title / Severity / Location / Reproduction / + Impact / Recommendation), Coverage (scenarios + steps explored). +- A `Finding` model extended with `affected_step_index`, + `affected_account` so the per-finding location is concrete. +- Bilingual hooks: keep markdown rendering pure, easy to swap to + HTML / PDF later. + +**Out of scope**: PDF rendering, tracking links to remediation PRs, +multi-program reports. + +**SDD artifacts**: `docs/sdd/02-audit-deliverable-export/{proposal,spec,design,tasks}.md`. + +**Apply estimate**: 2–3 hours. + +## Topic 3 — `sdd/03-save-load-deterministic` (highest risk, last) + +**Goal**: `Save → Load` reproduces PDAs, signatures and balances +exactly. Today `LoadSession` re-creates user keypairs with `Keypair::new()`, +so any program that derives PDAs from a user pubkey or validates +`require!(account.owner == previous_signer)` breaks under reload. + +**Justification**: audit round 9 raised this as the deal-breaker for +reproducing findings the next morning. Without it the scenario suite +itself can drift. + +**Scope (security implications, hence SDD)**: +- Persist `users` keypairs in the JSON. Default opt-out: encrypted + with a passphrase derived from the auditor's input. Plaintext + available behind a `--insecure-save` flag for local-only sessions. +- Verify replay against persisted pubkeys, not regenerated ones. +- Emit a versioned JSON header (`session-format: "2"`) so older saves + keep loading via the existing best-effort path. + +**Threat model questions for the spec**: +- Where do encryption keys live? Local keystore? KDF on prompt? +- What happens if a saved session is committed to git by mistake? +- Test programs vs production programs: do we want to *forbid* + saving with mainnet program IDs? + +**Out of scope**: hardware-wallet integration, multi-user shared +sessions. + +**SDD artifacts**: `docs/sdd/03-save-load-deterministic/{proposal,spec,design,tasks}.md`. + +**Apply estimate**: 3–5 hours, with a security review pass before +shipping. + +## Order, gating, parallelism + +We ship serially: 1 → 2 → 3. Reasons: + +- Topic 1 (CI) protects the work in 2 and 3. +- Topic 2 changes data shapes that topic 3 needs. +- Topic 3 requires careful threat-model review and is the last to + ship, so it inherits the safety net of CI + a richer Finding model. + +Each topic exits `apply` only when the matching scenario test (or a +new one in `tests/scenarios/`) is green. + +## What we are NOT doing in this roadmap + +- `slice` / `trace` / `sequence` for Solana (`Phase 2` backlog — + needs anchor-syn AST extractor + per-instruction CFG). +- CFG visual redesign (waiting on user screenshot). +- Cross-program CPI modeling (T-R16). +- TUI ratatui Solana mode. + +These are documented in `solana-support.md` and the engram timeline so +the next contributor finds them quickly. From d4acc7c3592161c1d49fccf731529c5bb7484597 Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Sat, 9 May 2026 10:28:52 +0200 Subject: [PATCH 082/115] chore(ci): run cargo tests + scenario suite on PRs GitHub Actions workflow with two parallel jobs on ubuntu-latest. cargo-tests runs cargo build --workspace --release then cargo test --workspace --release, covering unit and integration tests including the T-R33 Back-rewinds-VM and T-R40 blockhash rotation regressions. scenario-suite builds the ilold CLI binary, installs python websockets best-effort, and runs bash tests/scenarios/run.sh which drives 11 black-box scenarios against the staking fixture. Triggers on push to main and on PRs that touch crates, tests, Cargo manifests, or workflows. Both jobs use Swatinem/rust-cache for cargo, target/, and registry. Acceptance gate per docs/sdd/01-ci-pipeline: 11 scenarios green, all assertions passing, under 10 minutes wall time on a free runner. --- .github/workflows/test.yml | 65 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..7d1dfae --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,65 @@ +name: test + +on: + push: + branches: [main] + pull_request: + branches: [main] + paths: + - 'crates/**' + - 'tests/**' + - 'Cargo.toml' + - 'Cargo.lock' + - '.github/workflows/**' + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + cargo-tests: + name: cargo test --workspace --release + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + target + uses: Swatinem/rust-cache@v2 + with: + shared-key: cargo-tests + + - name: Build workspace (release) + run: cargo build --workspace --release + + - name: Run unit + integration tests + run: cargo test --workspace --release + + scenario-suite: + name: tests/scenarios/run.sh + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Install Rust stable + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + target + uses: Swatinem/rust-cache@v2 + with: + shared-key: scenario-suite + + - name: Build ilold CLI binary + run: cargo build --release --bin ilold -p ilold-cli + + - name: Install python websockets (optional) + run: | + python3 -m pip install --user websockets || \ + echo "websockets unavailable — runner will skip ws-broadcast.py" + + - name: Run scenario suite + run: bash tests/scenarios/run.sh From 19b51da710224dfb3b5f561789acca51e196dbbf Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Sat, 9 May 2026 10:36:57 +0200 Subject: [PATCH 083/115] chore(docs): privatize internal sprint notes via .gitignore docs/internal/{audits-summary,sdd-roadmap}.md were tracked in commit 70f7c56 but the project is not public yet, and those files describe internal triage choices (audit false-positive rates, deferred work, threat-model notes) that we want to vet before opening up. Move them out of the index by adding /docs/internal/ to .gitignore alongside /docs/sdd/, and untrack the two existing files. Local copies remain so the next session can keep iterating; when the project goes public we promote the curated parts to docs/dev/ or similar and re-add explicitly. --- .gitignore | 2 + docs/internal/audits-summary.md | 165 -------------------------------- docs/internal/sdd-roadmap.md | 136 -------------------------- 3 files changed, 2 insertions(+), 301 deletions(-) delete mode 100644 docs/internal/audits-summary.md delete mode 100644 docs/internal/sdd-roadmap.md diff --git a/.gitignore b/.gitignore index e07c978..656fbb1 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ Thumbs.db # Internal SDD working notes /docs/sdd/ + +/docs/internal/ diff --git a/docs/internal/audits-summary.md b/docs/internal/audits-summary.md deleted file mode 100644 index 85a88c7..0000000 --- a/docs/internal/audits-summary.md +++ /dev/null @@ -1,165 +0,0 @@ -# Audit rounds 1–10 — summary - -This document records every audit pass run during the Solana-parity sprint, -what each round found, and which findings turned out to be real after -verification on disk. The 30–40% false-positive rate of sub-agent reports -is the reason every finding here is annotated with “verified” or -“rejected”. - -## Methodology - -- Each round is a sub-agent that explores a slice of the codebase with a - prompt focused on a specific concern (CLI parity, frontend, WS, VM - lifecycle, etc.). -- The orchestrator (me) verifies every finding against the real source - before applying a fix. False positives are dropped without code change. -- After fixes ship, the bash + python scenario suite under - `tests/scenarios/` is re-run as smoke test. - -## Round 1 — initial parity baseline (T-R29) - -- Goal: map every Solidity command in the REPL onto its Solana - counterpart. -- Findings: confirmed Solana lacked `step`, `findings`, `export`, - `who`, `timeline` (logged as T-R38). -- False positives: none. - -## Round 2 — frontend handlers + store WS - -- Goal: review handlers in `+page.svelte` and the WS store. -- Verified bugs: - - Trace context menu offered no Fork / Remove from here for trace - nodes (`+page.svelte:1226`). → **T-R30**. - - `handleSessionClear` painted the canvas locally instead of trusting - the WS broadcast. → **T-R30**. -- Rejected: `notifyFailure` missing in `handleSolanaSubmit` (already - caught inline by `SolanaRunForm.svelte:95`). - -## Round 3 — backend exploration + VM - -- Verified bugs: - - `Back` did not rewind the VM, only popped the timeline. → **T-R33**. - - `Fork(at_step=N)` left the cloned VM with the full timeline’s state. - → **T-R33 fork rewind**. - - `LoadSession` did not rebuild the VMs at all. → **T-R39**. - - `TimeWarp` persisted across `Back`. Documented as design in - `solana-support.md`. -- Rejected: `TransactionError` Debug formatting (`logs_excerpt` already - carries the full Anchor error chain). - -## Round 4 — CFG visual + frontend kind handling - -- Verified: trace card key collisions in `{#each traceSteps as step - (step.stepIndex)}` (collisions across scenarios broke the Timeline - button). → fixed in `aa3e421`. -- Documented: CFG visual layout is bipartite by design; the user - reported it as “feo” but no code defect — pending captura to scope - redesign. - -## Round 5 — CLI prompt sync - -- Verified bugs: - - `sync_steps` only deserialized `CommandResult::SessionView` - (Solidity), so Solana attach mode never updated the prompt. → - **T-R35**. - - `sync_scenarios` ran only inside the `scenario` handler, not in the - REPL loop, so a second terminal saw stale lists. → **T-R36**. -- Verified bug found by orchestrator (not the sub-agent): WS - `session_add_node` carried no runtime metadata, so calls executed from - CLI showed `0 CU 0 diffs` in the canvas. → **T-R37**. - -## Round 6 — endpoints, store, edge cases - -- Findings classified after disk verification: - - Real: scenario list not synced in loop (already covered T-R36). - - Real: TimeWarp emits no broadcast — deferred (no UI consumes it - yet). - - Rejected: users/airdrop sync “bug” — CLI has no users cache to keep - in sync. - - Rejected: notes/findings broadcast — not consumed by frontend. - -## Round 7 — CLI + frontend deep dive - -- Verified bugs: - - Solana CLI lacked `finding`, `sequence`, and `browser` handlers - (cell-fall to the “Unknown command” arm). → fixed in `51c4b38`. -- Documented gaps: - - `slice` and `trace` are not implemented for Solana; they need a - handler-level AST. Documented in `solana-support.md`. - - Tab completion does not complete command names — UX nicety, not a - blocker. - -## Round 8 — developer UX - -- Verified bugs: - - `tests/e2e_lever.rs` had an outdated `add_solana_step` signature - after T-R39 added `call_payload`. → fixed in `6cbc825`. - - `add_step.rs:108` used `.unwrap()` on `session.steps.last()` — - replaced by `VmOperationFailed`. → fixed in `6cbc825`. -- Deferred: edition-2024 nightly requirement, missing CI workflow, - zero rustdoc on public model types, no “adding a program” guide. - -## Round 9 — auditor UX - -- Verified bugs: - - Step failures were not visible in CLI output — the `StepAdded` - print didn’t scan logs for `AnchorError`. → fixed in `6cbc825`, - failing steps now print `[FAILED]` in red and Anchor lines stand - out. - - `execute_export` only walked the active scenario; findings - recorded in any other branch were silently dropped from the - deliverable. → fixed in `6cbc825`, signature now takes an iterator - over `(scenario_name, session)` and produces a Findings (all - scenarios) section. -- Rejected: `--no-signer` claimed to be dead code — verified at - `explore.rs:1679` that it is actually wired. -- Deferred: keypair persistence (Save/Load reproducibility), audit - metadata + recommendations templates in export, constraint - introspection (anchor-syn AST), CPI cross-program flow. - -## Round 10 — frontend Svelte 5 deep - -- Verified bugs: - - **CRITICAL**: `onMount` in `+page.svelte:852` awaited - `getProjectMap`, `getProgram`, `getContract`, `getCallGraph`, - `getSequences`, `getSequenceAnalysis` and assigned the results to - component state without checking the route was still on the same - contract. Race window between routes corrupted state. → fixed in - `5b32e41` with `mountCancelled` + `stillFresh()` guard before each - assignment. - - **LOW**: `Legend.svelte` showed Solidity-only hints (Function / - Entry block / Return / Revert) regardless of backend. → fixed in - the same commit by branching on `kind`. -- Sections that came back clean: runes correctness, WS reconnection - back-off, concurrency, bundle size, type safety. - -## Decreasing-returns curve - -| Round | Real bugs found | False positives | -| --- | --- | --- | -| 1 | 1 (parity gap) | 0 | -| 2 | 2 | 1 | -| 3 | 4 | 1 | -| 4 | 1 | 0 | -| 5 | 3 (incl. orchestrator) | 0 | -| 6 | 1 | 3 | -| 7 | 3 | 0 | -| 8 | 2 | 0 (4 deferred) | -| 9 | 2 | 1 (4 deferred) | -| 10 | 2 | 0 | - -Total: 21 real bugs shipped, 6 false positives identified, ~15 items -deferred as design / architectural debt with engram + doc trail. - -## Closed by the smoke suite - -Every fix has at least one assertion in `tests/scenarios/`: - -- Happy path + 4 attack vectors (scenarios 01–05). -- Fork isolation + Back rewind (06, 07) cover T-R33. -- Blockhash rotation (08) covers T-R40. -- Save/Load round-trip (09) covers T-R39. -- Cross-scenario export (10) covers the round 9 export gap. -- WebSocket broadcast count (`ws-broadcast.py`) covers T-R37. - -`bash tests/scenarios/run.sh` is the regression bar: 11 green / 40 PASS. diff --git a/docs/internal/sdd-roadmap.md b/docs/internal/sdd-roadmap.md deleted file mode 100644 index f303d90..0000000 --- a/docs/internal/sdd-roadmap.md +++ /dev/null @@ -1,136 +0,0 @@ -# SDD roadmap — what we ship next, and why - -We close the Solana-parity sprint with 10 commits and a full smoke suite -green. Three pieces of debt remain that the auditor and developer -audits flagged. This roadmap explains, for each one, the business -reason, the proposed scope, the risk of *not* doing it, and the SDD -artifacts we will produce before touching code. - -## Why SDD here - -The Solana parity sprint moved fast: 10 audit rounds, ~21 fixes, a -scenario suite. From here forward the changes are smaller in line -count but bigger in surface area (CI policy, customer deliverable -format, security-sensitive persistence). SDD (proposal → spec / design -→ tasks → apply → verify → archive) protects us from: - -- Shipping a CI policy nobody reviewed. -- Producing a deliverable export that misleads clients. -- Persisting auditor keypairs without thinking through the threat model. - -Each item below produces its artifacts under `docs/sdd/<change>/`. That -directory is gitignored on purpose (it is internal scratch); only the -final code change and the commit body land in version control. The -contents of this roadmap are the public-facing summary. - -## Topic 1 — `sdd/01-ci-pipeline` (smallest, ships first) - -**Goal**: every PR that touches the workspace must run `cargo test ---workspace` and `bash tests/scenarios/run.sh`. Without it, the next -contributor (human or LLM) can break T-R33/T-R37/T-R40 silently. - -**Justification**: audit round 8 found `tests/e2e_lever.rs` already -broken on `main` because the function signature changed and nobody -caught it. CI is the cheapest hedge against that. - -**Scope**: -- `.github/workflows/test.yml` runs the unit tests + the scenario - suite on Linux runners. -- Skip the python WS scenario when `pip install websockets` is not - available, so a minimal CI image still works. -- No external infra (no Docker, no caching tweaks beyond cargo's - default). - -**Out of scope**: building/serving the Anchor `.so` from scratch (the -fixture ships pre-compiled), Solidity tests (already covered in unit -tests), publish/release pipelines. - -**SDD artifacts**: `docs/sdd/01-ci-pipeline/{proposal,spec,design,tasks}.md`. - -**Apply estimate**: 30 minutes. - -## Topic 2 — `sdd/02-audit-deliverable-export` (next) - -**Goal**: `execute_export` produces a markdown document a security -auditor can hand to a client, not the current minimalist debug dump. - -**Justification**: round 9 (auditor UX) flagged the export as a -blocker for delivering paid audits — it lacks audit metadata -(date, auditor name, contract version), methodology section, -per-finding code location / reproduction / impact / recommendation, -and severity matrix. - -**Scope**: -- `SolanaCommand::Export { audit_metadata: Option<AuditMetadata> }` - with an explicit struct (auditor name, project, commit hash, - date). -- New sections in the markdown: Methodology, Severity Matrix, - Per-finding template (Title / Severity / Location / Reproduction / - Impact / Recommendation), Coverage (scenarios + steps explored). -- A `Finding` model extended with `affected_step_index`, - `affected_account` so the per-finding location is concrete. -- Bilingual hooks: keep markdown rendering pure, easy to swap to - HTML / PDF later. - -**Out of scope**: PDF rendering, tracking links to remediation PRs, -multi-program reports. - -**SDD artifacts**: `docs/sdd/02-audit-deliverable-export/{proposal,spec,design,tasks}.md`. - -**Apply estimate**: 2–3 hours. - -## Topic 3 — `sdd/03-save-load-deterministic` (highest risk, last) - -**Goal**: `Save → Load` reproduces PDAs, signatures and balances -exactly. Today `LoadSession` re-creates user keypairs with `Keypair::new()`, -so any program that derives PDAs from a user pubkey or validates -`require!(account.owner == previous_signer)` breaks under reload. - -**Justification**: audit round 9 raised this as the deal-breaker for -reproducing findings the next morning. Without it the scenario suite -itself can drift. - -**Scope (security implications, hence SDD)**: -- Persist `users` keypairs in the JSON. Default opt-out: encrypted - with a passphrase derived from the auditor's input. Plaintext - available behind a `--insecure-save` flag for local-only sessions. -- Verify replay against persisted pubkeys, not regenerated ones. -- Emit a versioned JSON header (`session-format: "2"`) so older saves - keep loading via the existing best-effort path. - -**Threat model questions for the spec**: -- Where do encryption keys live? Local keystore? KDF on prompt? -- What happens if a saved session is committed to git by mistake? -- Test programs vs production programs: do we want to *forbid* - saving with mainnet program IDs? - -**Out of scope**: hardware-wallet integration, multi-user shared -sessions. - -**SDD artifacts**: `docs/sdd/03-save-load-deterministic/{proposal,spec,design,tasks}.md`. - -**Apply estimate**: 3–5 hours, with a security review pass before -shipping. - -## Order, gating, parallelism - -We ship serially: 1 → 2 → 3. Reasons: - -- Topic 1 (CI) protects the work in 2 and 3. -- Topic 2 changes data shapes that topic 3 needs. -- Topic 3 requires careful threat-model review and is the last to - ship, so it inherits the safety net of CI + a richer Finding model. - -Each topic exits `apply` only when the matching scenario test (or a -new one in `tests/scenarios/`) is green. - -## What we are NOT doing in this roadmap - -- `slice` / `trace` / `sequence` for Solana (`Phase 2` backlog — - needs anchor-syn AST extractor + per-instruction CFG). -- CFG visual redesign (waiting on user screenshot). -- Cross-program CPI modeling (T-R16). -- TUI ratatui Solana mode. - -These are documented in `solana-support.md` and the engram timeline so -the next contributor finds them quickly. From 5f8ec964ebc91dc2dbf1d2d2ec6b79834da5ed2c Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Sat, 9 May 2026 10:38:11 +0200 Subject: [PATCH 084/115] docs(guide): correct scenario count in testing guide The scenario suite has 10 bash scripts plus an optional python websockets test, totalling 11 entries when the runner finds python3 + websockets. The guide previously read '12 scenarios under tests/scenarios' which neither matched disk (10 .sh files) nor the runner output (11 scenarios green). Self-audit on the docs caught the discrepancy alongside two internal-doc corrections that stay private. --- docs/guide/src/solana-testing.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/guide/src/solana-testing.md b/docs/guide/src/solana-testing.md index 8c8d713..3048256 100644 --- a/docs/guide/src/solana-testing.md +++ b/docs/guide/src/solana-testing.md @@ -111,7 +111,9 @@ implemented. bash tests/scenarios/run.sh ``` -12 scenarios under `tests/scenarios/`. Each spawns a fresh `ilold serve` +11 scenarios under `tests/scenarios/` (10 bash + 1 optional python WebSocket +test that runs when `python3` and `pip install websockets` are available). +Each spawns a fresh `ilold serve` on port 8081 (override with `ILOLD_TEST_PORT`) and aggregates pass/fail. Adding a scenario: From a9914697e6846d3b6cb35979bc20d2ee1913bf4b Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Sat, 9 May 2026 11:35:11 +0200 Subject: [PATCH 085/115] feat(export): audit deliverable markdown - Reuse journal::export renderer; add export_markdown_multi for ScenarioStore - Render metadata header, severity matrix, methodology, per-scenario steps - Finding gains affected_step_index and recommendation (serde-default) - CLI: fi --rec= and export --auditor= --version= --date= - Scenario 11 covers metadata, matrix, methodology, recommendation --- crates/ilold-cli/src/explore.rs | 72 +++++- crates/ilold-core/src/exploration/commands.rs | 5 + .../ilold-session-core/src/journal/export.rs | 243 ++++++++++++++---- .../ilold-session-core/src/journal/types.rs | 14 +- .../src/exploration/commands.rs | 7 +- .../src/exploration/execute.rs | 78 +++--- .../tests/execute_state_ops.rs | 1 + crates/ilold-web/src/api/session.rs | 7 +- docs/guide/src/solana-testing.md | 10 +- tests/scenarios/10-export-cross-scenario.sh | 4 +- tests/scenarios/11-export-deliverable.sh | 45 ++++ 11 files changed, 382 insertions(+), 104 deletions(-) create mode 100755 tests/scenarios/11-export-deliverable.sh diff --git a/crates/ilold-cli/src/explore.rs b/crates/ilold-cli/src/explore.rs index f9d9a17..0908e36 100644 --- a/crates/ilold-cli/src/explore.rs +++ b/crates/ilold-cli/src/explore.rs @@ -1394,13 +1394,23 @@ fn handle_solana_input( } "fi" | "finding" => { if arg.is_empty() { - println!(" Usage: finding <severity> <title>"); + println!(" Usage: finding <severity> <title> [--rec=\"...\"]"); println!(" Severity: critical | high | medium | low | info"); return InputResult::Continue; } - let parts: Vec<&str> = arg.splitn(2, ' ').collect(); + // Strip a trailing --rec="..." (or unquoted) before splitting + // severity + title so the title can contain spaces. + let (rest, rec): (&str, Option<String>) = match arg.find("--rec=") { + Some(idx) => { + let head = arg[..idx].trim_end(); + let tail = &arg[idx + "--rec=".len()..]; + (head, Some(strip_quotes(tail).to_string())) + } + None => (arg, None), + }; + let parts: Vec<&str> = rest.splitn(2, ' ').collect(); if parts.len() < 2 { - println!(" Usage: finding <severity> <title>"); + println!(" Usage: finding <severity> <title> [--rec=\"...\"]"); return InputResult::Continue; } let severity = match normalize_severity(parts[0]) { @@ -1414,7 +1424,12 @@ fn handle_solana_input( } }; let body = serde_json::json!({ - "Finding": {"severity": severity, "title": parts[1], "description": ""} + "Finding": { + "severity": severity, + "title": parts[1], + "description": "", + "recommendation": rec, + } }); dispatch_solana(handle, client, base_url, contract, body, steps) } @@ -1520,14 +1535,34 @@ fn handle_solana_input( serde_json::json!("Findings"), steps, ), - "export" | "ex" => dispatch_solana( - handle, - client, - base_url, - contract, - serde_json::json!("Export"), - steps, - ), + "export" | "ex" => { + // Parse optional --auditor=, --version=, --date= flags. Anything + // else is an error so a typo never produces a half-empty + // deliverable. + let mut auditor: Option<String> = None; + let mut version: Option<String> = None; + let mut date: Option<String> = None; + for tok in arg.split_whitespace() { + if let Some(v) = tok.strip_prefix("--auditor=") { auditor = Some(strip_quotes(v).to_string()); } + else if let Some(v) = tok.strip_prefix("--version=") { version = Some(strip_quotes(v).to_string()); } + else if let Some(v) = tok.strip_prefix("--date=") { date = Some(strip_quotes(v).to_string()); } + else { + println!(" Unknown flag: {tok}. Use --auditor= / --version= / --date= (or no flags)"); + return InputResult::Continue; + } + } + let metadata = if auditor.is_some() || version.is_some() || date.is_some() { + Some(serde_json::json!({ + "auditor": auditor, + "project_version": version, + "audit_date": date, + })) + } else { + None + }; + let body = serde_json::json!({"Export": {"metadata": metadata}}); + dispatch_solana(handle, client, base_url, contract, body, steps) + } "who" => { if arg.is_empty() { println!(" Usage: who <account_type> (e.g. who Pool)"); @@ -2435,6 +2470,19 @@ fn parse_trace_args(arg: &str) -> TraceArgs { TraceArgs { target, depth, reverts, expand, interactive } } +/// Strip a single surrounding pair of double or single quotes from a CLI flag +/// value (`--rec="hello world"` → `hello world`). No-op when not quoted. +fn strip_quotes(s: &str) -> &str { + let s = s.trim(); + if (s.starts_with('"') && s.ends_with('"') && s.len() >= 2) + || (s.starts_with('\'') && s.ends_with('\'') && s.len() >= 2) + { + &s[1..s.len() - 1] + } else { + s + } +} + fn normalize_severity(input: &str) -> Option<&'static str> { match input.to_lowercase().as_str() { "critical" => Some("Critical"), diff --git a/crates/ilold-core/src/exploration/commands.rs b/crates/ilold-core/src/exploration/commands.rs index 2a97fbf..8a94bce 100644 --- a/crates/ilold-core/src/exploration/commands.rs +++ b/crates/ilold-core/src/exploration/commands.rs @@ -445,6 +445,11 @@ fn execute_finding( description, notes: vec![], created_at: String::new(), + // Solidity flow does not yet capture step_index or recommendation; + // SDD-02 only wires the new fields on the Solana side. Setting None + // here preserves backward-compat with existing Solidity callers. + affected_step_index: None, + recommendation: None, }; session.journal.add_finding(finding, timestamp); diff --git a/crates/ilold-session-core/src/journal/export.rs b/crates/ilold-session-core/src/journal/export.rs index 3e0df16..a8a74d6 100644 --- a/crates/ilold-session-core/src/journal/export.rs +++ b/crates/ilold-session-core/src/journal/export.rs @@ -1,7 +1,33 @@ use std::fmt::Write; +use serde::{Deserialize, Serialize}; + use super::types::*; +/// Optional auditor metadata threaded through the export. Pass-through, not +/// stored in the journal — see SDD 02-audit-deliverable-export/design.md +/// rationale (avoids JSON-format bumps and keeps PII out of saved sessions). +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AuditMetadata { + #[serde(default)] + pub auditor: Option<String>, + #[serde(default)] + pub project_version: Option<String>, + #[serde(default)] + pub audit_date: Option<String>, +} + +/// Solana-specific program facts the renderer prints next to the metadata. +/// Solidity callers pass `None` to `export_markdown_multi`; the body simply +/// omits the program block. +#[derive(Debug, Clone)] +pub struct ProgramSection { + pub name: String, + pub program_id: String, + pub instructions: usize, + pub account_types: usize, +} + pub fn export_markdown(journal: &AuditJournal, total_functions: usize) -> String { let mut md = String::new(); let (done, total) = journal.progress(total_functions); @@ -11,65 +37,190 @@ pub fn export_markdown(journal: &AuditJournal, total_functions: usize) -> String writeln!(md, "**Project**: {} | **Started**: {} | **Progress**: {}/{} functions ({}%)", journal.project, journal.started_at, done, total, pct).unwrap(); - // Findings writeln!(md).unwrap(); - if journal.findings.is_empty() { + render_findings_block(&mut md, &journal.findings); + + writeln!(md).unwrap(); + render_coverage(&mut md, journal); + + if journal.entries.iter().any(|e| matches!(e, + JournalEntry::SequenceExplored { .. } | JournalEntry::BranchCreated { .. } + )) { + writeln!(md).unwrap(); + render_exploration_log(&mut md, journal); + } + + md +} + +// ── Shared private helpers ────────────────────────────────────────────────── + +pub(crate) fn render_findings_block(md: &mut String, findings: &[Finding]) { + if findings.is_empty() { writeln!(md, "## Findings\n\nNo findings recorded.").unwrap(); - } else { - writeln!(md, "## Findings\n").unwrap(); - writeln!(md, "| ID | Severity | Title | Function |").unwrap(); - writeln!(md, "|----|----------|-------|----------|").unwrap(); - for f in &journal.findings { - writeln!(md, "| {} | {} | {} | {} |", f.id, f.severity, f.title, f.affected_function).unwrap(); - } + return; + } + writeln!(md, "## Findings\n").unwrap(); + writeln!(md, "| ID | Severity | Title | Function |").unwrap(); + writeln!(md, "|----|----------|-------|----------|").unwrap(); + for f in findings { + writeln!(md, "| {} | {} | {} | {} |", f.id, f.severity, f.title, f.affected_function).unwrap(); + } + for f in findings { + writeln!(md).unwrap(); + render_finding_detail(md, f, None); + } +} - for f in &journal.findings { - writeln!(md).unwrap(); - writeln!(md, "### {}: {}\n", f.id, f.title).unwrap(); - writeln!(md, "**Severity**: {} | **Function**: {}", f.severity, f.affected_function).unwrap(); - if let Some(seq) = &f.affected_sequence { - writeln!(md, "**Sequence**: {}", seq.join(" → ")).unwrap(); - } - writeln!(md, "\n{}", f.description).unwrap(); - for note in &f.notes { - writeln!(md, "\n> {}", note).unwrap(); - } - } +pub(crate) fn render_finding_detail(md: &mut String, f: &Finding, scenario: Option<&str>) { + writeln!(md, "### {}: {}\n", f.id, f.title).unwrap(); + let mut header = format!( + "**Severity**: {} | **Function**: {}", + f.severity, f.affected_function, + ); + if let Some(idx) = f.affected_step_index { + header.push_str(&format!(" | **Step**: #{idx}")); + } + if let Some(s) = scenario { + header.push_str(&format!(" | **Scenario**: `{s}`")); + } + writeln!(md, "{header}").unwrap(); + if let Some(seq) = &f.affected_sequence { + writeln!(md, "**Sequence**: {}", seq.join(" → ")).unwrap(); + } + writeln!(md, "\n{}", f.description).unwrap(); + if let Some(rec) = &f.recommendation { + writeln!(md, "\n**Recommendation**\n\n{rec}").unwrap(); + } + for note in &f.notes { + writeln!(md, "\n> {note}").unwrap(); } +} - // Coverage - writeln!(md).unwrap(); +pub(crate) fn render_coverage(md: &mut String, journal: &AuditJournal) { writeln!(md, "## Coverage\n").unwrap(); if journal.function_status.is_empty() { writeln!(md, "No functions reviewed yet.").unwrap(); - } else { - let mut funcs: Vec<_> = journal.function_status.iter().collect(); - funcs.sort_by_key(|(name, _)| (*name).clone()); - for (name, status) in funcs { - writeln!(md, "- {} {}", status.badge(), name).unwrap(); + return; + } + let mut funcs: Vec<_> = journal.function_status.iter().collect(); + funcs.sort_by_key(|(name, _)| (*name).clone()); + for (name, status) in funcs { + writeln!(md, "- {} {}", status.badge(), name).unwrap(); + } +} + +pub(crate) fn render_exploration_log(md: &mut String, journal: &AuditJournal) { + writeln!(md, "## Exploration Log\n").unwrap(); + for entry in &journal.entries { + match entry { + JournalEntry::SequenceExplored { steps, timestamp, .. } => { + writeln!(md, "- **{}** Explored: {}", + ×tamp[..std::cmp::min(16, timestamp.len())], steps.join(" → ")).unwrap(); + } + JournalEntry::BranchCreated { from_function, branch_function, timestamp } => { + writeln!(md, "- **{}** Branch: {} → {}", + ×tamp[..std::cmp::min(16, timestamp.len())], from_function, branch_function).unwrap(); + } + _ => {} } } +} - // Exploration log - let has_exploration = journal.entries.iter().any(|e| matches!(e, - JournalEntry::SequenceExplored { .. } | JournalEntry::BranchCreated { .. } - )); +pub(crate) fn render_metadata_header(md: &mut String, m: &AuditMetadata) { + let mut parts: Vec<String> = Vec::new(); + if let Some(a) = &m.auditor { parts.push(format!("**Auditor**: {a}")); } + if let Some(v) = &m.project_version { parts.push(format!("**Version**: {v}")); } + if let Some(d) = &m.audit_date { parts.push(format!("**Date**: {d}")); } + if !parts.is_empty() { + writeln!(md, "{}\n", parts.join(" | ")).unwrap(); + } +} - if has_exploration { - writeln!(md).unwrap(); - writeln!(md, "## Exploration Log\n").unwrap(); - for entry in &journal.entries { - match entry { - JournalEntry::SequenceExplored { steps, timestamp, .. } => { - writeln!(md, "- **{}** Explored: {}", ×tamp[..std::cmp::min(16, timestamp.len())], steps.join(" → ")).unwrap(); - } - JournalEntry::BranchCreated { from_function, branch_function, timestamp } => { - writeln!(md, "- **{}** Branch: {} → {}", ×tamp[..std::cmp::min(16, timestamp.len())], from_function, branch_function).unwrap(); - } - _ => {} - } +pub(crate) fn render_severity_matrix(md: &mut String, journals: &[&AuditJournal]) { + let mut counts = [0usize; 5]; // Critical, High, Medium, Low, Informational + for j in journals { + for f in &j.findings { + let i = match f.severity { + Severity::Critical => 0, + Severity::High => 1, + Severity::Medium => 2, + Severity::Low => 3, + Severity::Informational => 4, + }; + counts[i] += 1; + } + } + writeln!(md, "## Severity Matrix\n").unwrap(); + writeln!(md, "| Severity | Count |").unwrap(); + writeln!(md, "|----------|-------|").unwrap(); + let labels = ["Critical", "High", "Medium", "Low", "Informational"]; + for (i, label) in labels.iter().enumerate() { + writeln!(md, "| {label} | {} |", counts[i]).unwrap(); + } + let total: usize = counts.iter().sum(); + writeln!(md, "| **Total** | **{total}** |").unwrap(); +} + +pub(crate) fn render_methodology(md: &mut String, program_name: &str) { + writeln!(md, "## Methodology\n").unwrap(); + writeln!(md, "This deliverable was produced with Ilold, an interactive REPL for \ +auditing smart contracts. The Solana backend executes the program (`{program_name}`) \ +inside LiteSVM with the real BPF binary, so every step in the timeline corresponds to \ +an actual transaction; compute-unit usage, account diffs and program logs are recorded \ +verbatim. Findings are scenario-anchored — each entry references the scenario in which \ +the auditor observed the issue, and every scenario carries an independent VM with its \ +own state snapshot stack so attacks explored in a branch never leak into the main \ +session.\n").unwrap(); +} + +pub fn export_markdown_multi( + scenarios: &[(&str, &AuditJournal)], + program: Option<&ProgramSection>, + metadata: Option<&AuditMetadata>, + instructions_count: usize, +) -> String { + let _ = instructions_count; // reserved for future per-instruction coverage + let mut md = String::new(); + let header_name = program.map(|p| p.name.as_str()) + .or_else(|| scenarios.first().map(|(_, j)| j.contract.as_str())) + .unwrap_or("(unknown)"); + writeln!(md, "# Audit report — {header_name}\n").unwrap(); + if let Some(m) = metadata { + render_metadata_header(&mut md, m); + } + let total_steps: usize = 0; // step counts live on ExplorationSession, + let total_findings: usize = scenarios.iter().map(|(_, j)| j.findings.len()).sum(); + writeln!(md, "**Scenarios**: {} · **Total findings**: {}\n", + scenarios.len(), total_findings).unwrap(); + let _ = total_steps; // step listing is rendered by the Solana caller via ExplorationStep, + // which the journal layer does not own — kept here for future use. + + if let Some(p) = program { + writeln!(md, "## Program\n").unwrap(); + writeln!(md, "- Program ID: `{}`", p.program_id).unwrap(); + writeln!(md, "- Instructions: {}", p.instructions).unwrap(); + writeln!(md, "- Account types: {}\n", p.account_types).unwrap(); + } + + render_methodology(&mut md, header_name); + + let journals: Vec<&AuditJournal> = scenarios.iter().map(|(_, j)| *j).collect(); + render_severity_matrix(&mut md, &journals); + writeln!(md).unwrap(); + + writeln!(md, "## Findings (all scenarios)\n").unwrap(); + let mut any = false; + for (scn_name, journal) in scenarios { + for f in &journal.findings { + any = true; + render_finding_detail(&mut md, f, Some(scn_name)); + writeln!(md).unwrap(); } } + if !any { + writeln!(md, "_(no findings recorded)_\n").unwrap(); + } md } @@ -101,6 +252,8 @@ mod tests { description: "External call before state update".into(), notes: vec![], created_at: String::new(), + affected_step_index: None, + recommendation: None, }, "2026-03-31T14:30:00Z"); j.record(JournalEntry::StatusChanged { diff --git a/crates/ilold-session-core/src/journal/types.rs b/crates/ilold-session-core/src/journal/types.rs index b4065a7..381fd05 100644 --- a/crates/ilold-session-core/src/journal/types.rs +++ b/crates/ilold-session-core/src/journal/types.rs @@ -34,6 +34,16 @@ pub struct Finding { pub description: String, pub notes: Vec<String>, pub created_at: String, + /// Step index that motivated the finding. Captured automatically at + /// recording time from `session.steps.len() - 1`. None when the finding + /// is recorded before any step has been executed. + #[serde(default)] + pub affected_step_index: Option<usize>, + /// Optional remediation suggestion (Solana auditor flow opts in via + /// `fi <sev> <title> --rec="…"`). When present the export markdown + /// renders a separate "Recommendation" block per finding. + #[serde(default)] + pub recommendation: Option<String>, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -170,6 +180,8 @@ mod tests { description: "CEI violation".into(), notes: vec![], created_at: String::new(), + affected_step_index: None, + recommendation: None, }, "2026-03-31T10:00:00Z"); assert_eq!(j.findings.len(), 1); @@ -215,7 +227,7 @@ mod tests { let f = Finding { id: String::new(), severity: Severity::High, title: "A".into(), affected_function: "x".into(), affected_sequence: None, - description: "d".into(), notes: vec![], created_at: String::new(), + description: "d".into(), notes: vec![], created_at: String::new(), affected_step_index: None, recommendation: None, }; j.add_finding(f.clone(), "t1"); j.add_finding(f.clone(), "t2"); diff --git a/crates/ilold-solana-core/src/exploration/commands.rs b/crates/ilold-solana-core/src/exploration/commands.rs index 8935e76..79ca2ff 100644 --- a/crates/ilold-solana-core/src/exploration/commands.rs +++ b/crates/ilold-solana-core/src/exploration/commands.rs @@ -46,6 +46,8 @@ pub enum SolanaCommand { severity: Severity, title: String, description: String, + #[serde(default)] + recommendation: Option<String>, }, Note { text: String, @@ -65,7 +67,10 @@ pub enum SolanaCommand { index: usize, }, Findings, - Export, + Export { + #[serde(default)] + metadata: Option<ilold_session_core::journal::export::AuditMetadata>, + }, Who { account_type: String, }, diff --git a/crates/ilold-solana-core/src/exploration/execute.rs b/crates/ilold-solana-core/src/exploration/execute.rs index 387c08f..8d4dda9 100644 --- a/crates/ilold-solana-core/src/exploration/execute.rs +++ b/crates/ilold-solana-core/src/exploration/execute.rs @@ -420,6 +420,7 @@ pub fn execute_finding( severity: Severity, title: String, description: String, + recommendation: Option<String>, timestamp: &str, ) -> SolanaCommandResult { let affected_sequence = if session.steps.is_empty() { @@ -433,6 +434,13 @@ pub fn execute_finding( .collect(), ) }; + // Capture the index of the most recent step so the export can render + // "Step #N" alongside the affected function. None when no steps yet. + let affected_step_index = if session.steps.is_empty() { + None + } else { + Some(session.steps.len() - 1) + }; let finding = Finding { id: String::new(), severity, @@ -446,6 +454,8 @@ pub fn execute_finding( description, notes: vec![], created_at: String::new(), + affected_step_index, + recommendation, }; session.journal.add_finding(finding, timestamp); let id = session @@ -588,46 +598,44 @@ pub fn execute_export<'a, I>( scenarios: I, active: &str, program: &ProgramDef, + metadata: Option<&ilold_session_core::journal::export::AuditMetadata>, ) -> SolanaCommandResult where I: IntoIterator<Item = (&'a str, &'a ExplorationSession)>, { + use ilold_session_core::journal::export::{ + export_markdown_multi, ProgramSection, + }; let scenarios: Vec<(&str, &ExplorationSession)> = scenarios.into_iter().collect(); - let total_steps: usize = scenarios.iter().map(|(_, s)| s.steps.len()).sum(); - let total_findings: usize = scenarios.iter().map(|(_, s)| s.journal.findings.len()).sum(); - - let mut md = String::new(); - md.push_str(&format!("# Audit report — {}\n\n", program.name)); - md.push_str(&format!( - "**Active scenario**: `{}` · **Scenarios**: {} · **Total steps**: {} · **Total findings**: {}\n\n", - active, scenarios.len(), total_steps, total_findings, - )); - - md.push_str("## Program\n\n"); - md.push_str(&format!("- Program ID: `{}`\n", program.program_id)); - md.push_str(&format!("- Instructions: {}\n", program.instructions.len())); - md.push_str(&format!("- Account types: {}\n\n", program.account_types.len())); - - md.push_str("## Findings (all scenarios)\n\n"); - let mut any = false; - for (scn_name, session) in &scenarios { - for f in &session.journal.findings { - any = true; - md.push_str(&format!( - "### {} — [{:?}] {}\n\n_scenario: `{}` · recorded at {}_\n\n{}\n\n", - f.id, f.severity, f.title, scn_name, f.created_at, f.description, - )); - } - } - if !any { - md.push_str("_(no findings recorded)_\n\n"); - } + let prog_section = ProgramSection { + name: program.name.clone(), + program_id: program.program_id.to_string(), + instructions: program.instructions.len(), + account_types: program.account_types.len(), + }; + // Reuse the shared markdown renderer (header + metadata + program + + // methodology + severity matrix + findings detail). Only the per-scenario + // step listing stays here because step records belong to ExplorationStep, + // which is owned by ilold-session-core but printed with Solana semantics + // (compute units, error from runtime_trace). + let journal_pairs: Vec<(&str, &ilold_session_core::journal::types::AuditJournal)> = + scenarios.iter().map(|(n, s)| (*n, &s.journal)).collect(); + let mut md = export_markdown_multi( + &journal_pairs, + Some(&prog_section), + metadata, + program.instructions.len(), + ); + + // Per-scenario step listing — Solana-specific (no Solidity counterpart). + use std::fmt::Write; + writeln!(md, "## Scenarios\n").unwrap(); + writeln!(md, "**Active**: `{active}`\n").unwrap(); for (scn_name, session) in &scenarios { - md.push_str(&format!("## Scenario: `{}`\n\n", scn_name)); - md.push_str(&format!("**Steps**: {}\n\n", session.steps.len())); + writeln!(md, "### `{scn_name}` — {} steps\n", session.steps.len()).unwrap(); if session.steps.is_empty() { - md.push_str("_(no steps)_\n\n"); + writeln!(md, "_(no steps)_\n").unwrap(); continue; } for (i, s) in session.steps.iter().enumerate() { @@ -639,12 +647,12 @@ where .and_then(|v| v.get("error")) .and_then(|v| v.as_str()); let mark = if err.is_some() { "FAIL" } else { "OK" }; - md.push_str(&format!("- **#{i}** `{}` — {} ({} CU)\n", s.function, mark, cu)); + writeln!(md, "- **#{i}** `{}` — {} ({} CU)", s.function, mark, cu).unwrap(); if let Some(e) = err { - md.push_str(&format!(" - error: `{e}`\n")); + writeln!(md, " - error: `{e}`").unwrap(); } } - md.push('\n'); + writeln!(md).unwrap(); } let bytes = md.len(); diff --git a/crates/ilold-solana-core/tests/execute_state_ops.rs b/crates/ilold-solana-core/tests/execute_state_ops.rs index 6806975..78d6531 100644 --- a/crates/ilold-solana-core/tests/execute_state_ops.rs +++ b/crates/ilold-solana-core/tests/execute_state_ops.rs @@ -109,6 +109,7 @@ fn finding_and_note_and_status_record_journal() { Severity::High, "missing signer check".into(), "switch_power should require admin".into(), + None, "2026-05-06T00:00:00Z", ) { SolanaCommandResult::FindingAdded { id } => assert!(!id.is_empty()), diff --git a/crates/ilold-web/src/api/session.rs b/crates/ilold-web/src/api/session.rs index baecfe8..b214c80 100644 --- a/crates/ilold-web/src/api/session.rs +++ b/crates/ilold-web/src/api/session.rs @@ -603,14 +603,15 @@ async fn handle_solana_command( severity, title, description, - } => execute_finding(session, severity, title, description, ×tamp), + recommendation, + } => execute_finding(session, severity, title, description, recommendation, ×tamp), SolanaCommand::Note { text } => execute_note(session, &text, ×tamp), SolanaCommand::Status { ix, status } => { execute_status(session, &program, &ix, status, ×tamp) } SolanaCommand::Step { index } => execute_step(session, index), SolanaCommand::Findings => execute_findings_list(session), - SolanaCommand::Export => { + SolanaCommand::Export { metadata } => { // Export aggregates findings and step lists across ALL scenarios so // the auditor's deliverable reflects the full investigation, not // just the currently-active branch. @@ -620,7 +621,7 @@ async fn handle_solana_command( .iter() .filter_map(|n| scenarios.get(n).map(|s| (n.as_str(), s))) .collect(); - execute_export(entries, &active_scenario, &program) + execute_export(entries, &active_scenario, &program, metadata.as_ref()) } SolanaCommand::Who { account_type } => execute_who(&program, &account_type), SolanaCommand::Timeline { pubkey } => { diff --git a/docs/guide/src/solana-testing.md b/docs/guide/src/solana-testing.md index 3048256..1048a0c 100644 --- a/docs/guide/src/solana-testing.md +++ b/docs/guide/src/solana-testing.md @@ -77,11 +77,11 @@ Type `?` for the help. Two-terminal flows (one for `serve`, one for | Command | Description | | --- | --- | -| `finding <severity> <title>` / `fi` | Record a finding. Severities: critical, high, medium, low, info. | +| `finding <severity> <title> [--rec="..."]` / `fi` | Record a finding. Severities: critical, high, medium, low, info. The optional `--rec=` flag attaches a remediation suggestion that the export renders as its own block. | | `note <text>` / `n` | Annotation on the active sequence. | | `status <ix> <state>` | Mark instruction reviewed / suspicious / etc. | | `findings` / `fl` | List recorded findings. | -| `export` / `ex` | Markdown report aggregating findings + steps from ALL scenarios. | +| `export [--auditor="..."] [--version="..."] [--date=YYYY-MM-DD]` / `ex` | Markdown report aggregating audit metadata, severity matrix, methodology, findings (with step index + recommendation when set) and per-scenario steps from ALL scenarios. | | `save <name>` / `load <name>` | Persist / restore a session JSON in `~/.ilold/sessions/`. | ## 4. Differences vs Solidity @@ -111,7 +111,7 @@ implemented. bash tests/scenarios/run.sh ``` -11 scenarios under `tests/scenarios/` (10 bash + 1 optional python WebSocket +12 scenarios under `tests/scenarios/` (11 bash + 1 optional python WebSocket test that runs when `python3` and `pip install websockets` are available). Each spawns a fresh `ilold serve` on port 8081 (override with `ILOLD_TEST_PORT`) and aggregates pass/fail. @@ -151,9 +151,9 @@ ilold[staking → initialize_pool]> call stake amount=1000 pool=pool user_stake= ilold[staking → … → stake]> step 1 ilold[staking → … → stake]> who Pool ilold[staking → … → stake]> timeline <pool-pubkey> -ilold[staking → … → stake]> finding High "missing reentrancy guard" +ilold[staking → … → stake]> finding High "missing reentrancy guard" --rec="Apply checks-effects-interactions" ilold[staking → … → stake]> findings -ilold[staking → … → stake]> export +ilold[staking → … → stake]> export --auditor="Demo Auditor" --version="v0.1.0" --date=2026-05-09 ilold[staking → … → stake]> save my-audit ilold[staking → … → stake]> clear ilold[staking]> load my-audit diff --git a/tests/scenarios/10-export-cross-scenario.sh b/tests/scenarios/10-export-cross-scenario.sh index dea90e9..502c9ae 100755 --- a/tests/scenarios/10-export-cross-scenario.sh +++ b/tests/scenarios/10-export-cross-scenario.sh @@ -17,14 +17,14 @@ post '{"contract":"staking","command":{"Finding":{"severity":"High","title":"bra # Switch back to main (no findings here) and export. post '{"contract":"staking","command":{"Scenario":{"sub":{"Switch":{"name":"main"}}}}}' >/dev/null -MD=$(post '{"contract":"staking","command":"Export"}' | jq -r '.Exported.markdown') +MD=$(post '{"contract":"staking","command":{"Export":{}}}' | jq -r '.Exported.markdown') if echo "$MD" | grep -q "branch finding"; then echo " PASS export aggregates findings across scenarios"; PASS=$((PASS+1)) else echo " FAIL export omitted findings from non-active scenario"; FAIL=$((FAIL+1)) fi -if echo "$MD" | grep -q "Scenario: \`attack\`"; then +if echo "$MD" | grep -qE "### \`attack\`"; then echo " PASS export includes attack scenario section"; PASS=$((PASS+1)) else echo " FAIL export missing attack section"; FAIL=$((FAIL+1)) diff --git a/tests/scenarios/11-export-deliverable.sh b/tests/scenarios/11-export-deliverable.sh new file mode 100755 index 0000000..d1c75a4 --- /dev/null +++ b/tests/scenarios/11-export-deliverable.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# SDD-02 verification: the markdown export now ships audit metadata, +# severity matrix, methodology, per-finding step index, and an opt-in +# recommendation block. +set -e +. "$(dirname "$0")/_lib.sh" +NAME="11-export-deliverable" +echo "## $NAME" + +setup_users +expect_ok_call "init" "$(init_pool)" +expect_ok_call "stake" "$(stake_as alice alice_stake 1000)" + +# Two findings: one with recommendation, one without. +post '{"contract":"staking","command":{"Finding":{"severity":"High","title":"missing reentrancy guard","description":"unstake calls user before zeroing balance","recommendation":"Apply checks-effects-interactions"}}}' >/dev/null +post '{"contract":"staking","command":{"Finding":{"severity":"Medium","title":"no max stake","description":"any user can stake unlimited"}}}' >/dev/null + +MD=$(post '{"contract":"staking","command":{"Export":{"metadata":{"auditor":"Demo Auditor","project_version":"v0.1.0","audit_date":"2026-05-09"}}}}' | jq -r '.Exported.markdown') + +echo "$MD" | head -3 | sed 's/^/ | /' + +# Audit metadata header. +echo "$MD" | grep -q "Auditor.*Demo Auditor" && echo " PASS auditor in header" && PASS=$((PASS+1)) || { echo " FAIL no auditor"; FAIL=$((FAIL+1)); } +echo "$MD" | grep -q "Version.*v0.1.0" && echo " PASS version in header" && PASS=$((PASS+1)) || { echo " FAIL no version"; FAIL=$((FAIL+1)); } +echo "$MD" | grep -q "Date.*2026-05-09" && echo " PASS date in header" && PASS=$((PASS+1)) || { echo " FAIL no date"; FAIL=$((FAIL+1)); } + +# Methodology paragraph. +echo "$MD" | grep -q "## Methodology" && echo " PASS methodology section" && PASS=$((PASS+1)) || { echo " FAIL no methodology"; FAIL=$((FAIL+1)); } +echo "$MD" | grep -q "LiteSVM" && echo " PASS methodology mentions LiteSVM" && PASS=$((PASS+1)) || { echo " FAIL methodology too vague"; FAIL=$((FAIL+1)); } + +# Severity matrix. +echo "$MD" | grep -q "## Severity Matrix" && echo " PASS severity matrix section" && PASS=$((PASS+1)) || { echo " FAIL no severity matrix"; FAIL=$((FAIL+1)); } +echo "$MD" | grep -qE "\| High \| 1 \|" && echo " PASS High count = 1" && PASS=$((PASS+1)) || { echo " FAIL High count wrong"; FAIL=$((FAIL+1)); } +echo "$MD" | grep -qE "\| Medium \| 1 \|" && echo " PASS Medium count = 1" && PASS=$((PASS+1)) || { echo " FAIL Medium count wrong"; FAIL=$((FAIL+1)); } + +# Step index in finding detail. +echo "$MD" | grep -q "Step.*#1" && echo " PASS step index rendered" && PASS=$((PASS+1)) || { echo " FAIL step index missing"; FAIL=$((FAIL+1)); } + +# Recommendation rendered when present. +echo "$MD" | grep -q "checks-effects-interactions" && echo " PASS recommendation present" && PASS=$((PASS+1)) || { echo " FAIL recommendation missing"; FAIL=$((FAIL+1)); } + +# Per-scenario step listing. +echo "$MD" | grep -q "## Scenarios" && echo " PASS scenarios section" && PASS=$((PASS+1)) || { echo " FAIL scenarios section missing"; FAIL=$((FAIL+1)); } + +scenario_summary "$NAME" From 29536d1b1a5f44d940bcf8cc88010b093f6c1dae Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Sat, 9 May 2026 11:54:27 +0200 Subject: [PATCH 086/115] feat(save): opt-in --with-keypairs makes save/load deterministic - --with-keypairs flag embeds scenario keypairs in v2 JSON - LoadSession rehydrates solana.users so PDAs reproduce after replay - SaveSession promotes from unit to struct variant - Print do-not-commit warning when bundle has secrets - Scenario 12 captures pubkeys, asserts match after clear+load --- crates/ilold-cli/src/explore.rs | 65 +++++++++-- .../src/exploration/commands.rs | 12 +- crates/ilold-web/src/api/session.rs | 54 ++++++++- crates/ilold-web/src/state.rs | 105 ++++++++++++++++-- docs/guide/src/solana-testing.md | 17 +-- tests/scenarios/09-save-load-roundtrip.sh | 2 +- tests/scenarios/12-save-load-deterministic.sh | 67 +++++++++++ 7 files changed, 287 insertions(+), 35 deletions(-) create mode 100755 tests/scenarios/12-save-load-deterministic.sh diff --git a/crates/ilold-cli/src/explore.rs b/crates/ilold-cli/src/explore.rs index 0908e36..63544df 100644 --- a/crates/ilold-cli/src/explore.rs +++ b/crates/ilold-cli/src/explore.rs @@ -1466,23 +1466,61 @@ fn handle_solana_input( } "save" => { if arg.is_empty() { - println!(" Usage: save <name>"); + println!(" Usage: save <name> [--with-keypairs]"); return InputResult::Continue; } - let body = serde_json::json!({"contract": contract, "command": "SaveSession"}); + // SDD-03: parse the optional --with-keypairs flag. The flag may + // come before or after <name>; everything else is rejected so a + // typo never silently saves without secrets. + let mut with_keypairs = false; + let mut name: Option<&str> = None; + for tok in arg.split_whitespace() { + if tok == "--with-keypairs" { + with_keypairs = true; + } else if tok.starts_with("--") { + println!( + " Unknown flag: {tok}. Use --with-keypairs (or no flags)." + ); + return InputResult::Continue; + } else if name.is_none() { + name = Some(tok); + } else { + println!(" Usage: save <name> [--with-keypairs]"); + return InputResult::Continue; + } + } + let name = match name { + Some(n) => n, + None => { + println!(" Usage: save <name> [--with-keypairs]"); + return InputResult::Continue; + } + }; + let body = serde_json::json!({ + "contract": contract, + "command": {"SaveSession": {"with_keypairs": with_keypairs}}, + }); match send_solana_command(handle, client, base_url, &body) { Ok(SolanaCommandResult::SessionSaved { json }) => { let dir = dirs::home_dir() .map(|h| h.join(".ilold").join("sessions")) .unwrap_or_else(|| std::path::PathBuf::from(".ilold/sessions")); std::fs::create_dir_all(&dir).ok(); - let path = dir.join(format!("{}.json", arg)); + let path = dir.join(format!("{}.json", name)); match std::fs::write(&path, &json) { - Ok(_) => println!( - " {} Saved to {}", - c_ok("✓"), - c_accent(&path.display().to_string()) - ), + Ok(_) => { + println!( + " {} Saved to {}", + c_ok("✓"), + c_accent(&path.display().to_string()) + ); + if with_keypairs { + eprintln!( + " {} bundle includes plaintext test keypairs — do NOT commit it", + c_warn("⚠ "), + ); + } + } Err(e) => eprintln!(" {} Write failed: {}", c_danger("✗"), e), } } @@ -1512,6 +1550,17 @@ fn handle_solana_input( return InputResult::Continue; } }; + // SDD-03: warn the auditor at load time when the bundle carries + // plaintext keypairs, so accidental git commits get a visible + // reminder. Cheap detection — we already have the JSON in memory. + if json.contains("\"keypairs_present\": true") + || json.contains("\"keypairs_present\":true") + { + eprintln!( + " {} bundle contains plaintext test keypairs — do NOT commit *.json files like this", + c_warn("⚠ "), + ); + } let body = serde_json::json!({ "contract": contract, "command": {"LoadSession": {"json": json}} diff --git a/crates/ilold-solana-core/src/exploration/commands.rs b/crates/ilold-solana-core/src/exploration/commands.rs index 79ca2ff..ef5e1b9 100644 --- a/crates/ilold-solana-core/src/exploration/commands.rs +++ b/crates/ilold-solana-core/src/exploration/commands.rs @@ -56,7 +56,17 @@ pub enum SolanaCommand { ix: String, status: ReviewStatus, }, - SaveSession, + /// Persist the active scenario store. Backwards compatible with the legacy + /// unit form `"SaveSession"` (no embedded keypairs). + #[serde(alias = "SaveSession")] + SaveSession { + /// SDD-03: when true, the resulting JSON also embeds the per-scenario + /// user keypairs in plaintext so a future LoadSession reproduces the + /// same pubkeys (and any PDAs derived from them). Default false keeps + /// the original "save the timeline shape" behaviour. + #[serde(default)] + with_keypairs: bool, + }, LoadSession { json: String, }, diff --git a/crates/ilold-web/src/api/session.rs b/crates/ilold-web/src/api/session.rs index b214c80..c34d997 100644 --- a/crates/ilold-web/src/api/session.rs +++ b/crates/ilold-web/src/api/session.rs @@ -283,7 +283,9 @@ pub async fn handle_command( } SessionCommand::SaveSession => { let active_before = scenarios_guard.active().to_string(); - let result = match scenarios_guard.save_to_json() { + // Solidity flow does not persist keypairs (no LiteSVM users to + // reproduce); SDD-03 keypairs feature is Solana-only. + let result = match scenarios_guard.save_to_json(crate::state::SaveOpts::none()) { Ok(json) => CommandResult::SessionSaved { json }, Err(message) => CommandResult::Error { message }, }; @@ -294,7 +296,7 @@ pub async fn handle_command( } SessionCommand::LoadSession { json } => { let result = match ScenarioStore::load_from_json(&json) { - Ok(loaded) => { + Ok((loaded, _kp_bundle)) => { let contract = loaded.contract.clone(); let step_names: Vec<String> = loaded .active_session() @@ -373,9 +375,36 @@ async fn handle_solana_command( return Ok(Json(serde_json::to_value(result).unwrap_or(Value::Null))); } - if matches!(command, SolanaCommand::SaveSession) { + if let SolanaCommand::SaveSession { with_keypairs } = &command { let scenarios = state.scenarios.read().unwrap(); - let result = match scenarios.save_to_json() { + // When `with_keypairs` is set, snapshot the in-memory user keypairs + // (cloned via `insecure_clone` like the Fork path does — same + // semantics, no key derivation) and pass them to save_to_json so + // the resulting JSON can be replayed deterministically. Default + // remains the lighter "shape only" save. + let users_snapshot = if *with_keypairs { + let users_lock = solana.users.read().unwrap(); + let cloned: std::collections::HashMap< + String, + std::collections::HashMap<String, Keypair>, + > = users_lock + .iter() + .map(|(scn, map)| { + let inner: std::collections::HashMap<String, Keypair> = map + .iter() + .map(|(name, kp)| (name.clone(), kp.insecure_clone())) + .collect(); + (scn.clone(), inner) + }) + .collect(); + Some(cloned) + } else { + None + }; + let opts = crate::state::SaveOpts { + keypairs: users_snapshot.as_ref(), + }; + let result = match scenarios.save_to_json(opts) { Ok(json) => SolanaCommandResult::SessionSaved { json }, Err(message) => SolanaCommandResult::Error { message }, }; @@ -387,7 +416,7 @@ async fn handle_solana_command( let mut users_lock = solana.users.write().unwrap(); let mut snapshots = solana.step_snapshots.write().unwrap(); let result = match ScenarioStore::load_from_json(&json) { - Ok(loaded) => { + Ok((loaded, kp_bundle)) => { let prog = loaded.contract.clone(); let step_names: Vec<String> = loaded .active_session() @@ -401,6 +430,19 @@ async fn handle_solana_command( vms.clear(); snapshots.clear(); + // SDD-03: rehydrate the per-scenario users map from the saved + // keypair bundle BEFORE replay runs, so that signers and PDAs + // come back identical. When the bundle is absent (legacy save + // without `--with-keypairs`) the existing `Keypair::new` path + // inside replay still works — pubkeys regenerate, which is + // the long-standing behaviour. + if let Some(bundle) = kp_bundle { + users_lock.clear(); + for (scn, kps) in bundle { + users_lock.insert(scn, kps); + } + } + // Rebuild a VM per scenario by replaying each step from its // saved call_payload. Steps without payload (legacy saves) // can't replay, so we still boot the VM but leave its state @@ -627,7 +669,7 @@ async fn handle_solana_command( SolanaCommand::Timeline { pubkey } => { execute_timeline(session, &program, &pubkey, &active_scenario) } - SolanaCommand::SaveSession + SolanaCommand::SaveSession { .. } | SolanaCommand::LoadSession { .. } | SolanaCommand::Scenario { .. } => unreachable!("handled above"), }; diff --git a/crates/ilold-web/src/state.rs b/crates/ilold-web/src/state.rs index 510d008..c1352c6 100644 --- a/crates/ilold-web/src/state.rs +++ b/crates/ilold-web/src/state.rs @@ -119,15 +119,37 @@ impl ScenarioStore { } /// Serialize the entire store as v2 JSON (`{ version: 2, contract, active, - /// scenarios, order }`). All scenarios + their `forked_from` chains travel - /// together so a load reconstructs the full state. - pub fn save_to_json(&self) -> Result<String, String> { + /// scenarios, order, [keypairs_present, keypairs] }`). When `opts.keypairs` + /// is `Some`, the file embeds the per-scenario user keypairs as 64-byte + /// arrays so a future load can rehydrate the same identities (PDAs and + /// signatures match across save/load). The boolean header + /// `keypairs_present` lets a reader detect a bundle with secrets without + /// parsing the body — see SDD-03 design.md threat-model section. + pub fn save_to_json(&self, opts: SaveOpts<'_>) -> Result<String, String> { + let (keypairs_present, keypairs) = match opts.keypairs { + Some(map) => { + let serialised: HashMap<String, HashMap<String, Vec<u8>>> = map + .iter() + .map(|(scn, users)| { + let inner: HashMap<String, Vec<u8>> = users + .iter() + .map(|(name, kp)| (name.clone(), kp.to_bytes().to_vec())) + .collect(); + (scn.clone(), inner) + }) + .collect(); + (true, Some(serialised)) + } + None => (false, None), + }; let file = ScenarioStoreFile { version: 2, contract: self.contract.clone(), active: self.active.clone(), scenarios: self.sessions.clone(), order: self.order.clone(), + keypairs_present, + keypairs, }; serde_json::to_string_pretty(&file).map_err(|e| format!("Serialize failed: {e}")) } @@ -136,21 +158,32 @@ impl ScenarioStore { /// falls back to v1 (bare `ExplorationSession`) and wraps it as a single /// `main` scenario. Any structural anomaly (active not in scenarios, /// empty order) is repaired so the returned store is always valid. - pub fn load_from_json(json: &str) -> Result<Self, String> { + pub fn load_from_json(json: &str) -> Result<(Self, Option<KeypairBundle>), String> { match serde_json::from_str::<ScenarioStoreFile>(json) { - Ok(file) => Self::from_file(file), + Ok(file) => { + let raw_kps = file.keypairs.clone(); + let store = Self::from_file(file)?; + let bundle = match raw_kps { + Some(map) => Some(decode_keypair_bundle(map)?), + None => None, + }; + Ok((store, bundle)) + } Err(_) => match serde_json::from_str::<ExplorationSession>(json) { Ok(legacy) => { let contract = legacy.contract.clone(); let mut sessions = HashMap::new(); sessions.insert(DEFAULT_SCENARIO.to_string(), legacy); - Ok(Self { - version: 2, - contract, - active: DEFAULT_SCENARIO.to_string(), - sessions, - order: vec![DEFAULT_SCENARIO.to_string()], - }) + Ok(( + Self { + version: 2, + contract, + active: DEFAULT_SCENARIO.to_string(), + sessions, + order: vec![DEFAULT_SCENARIO.to_string()], + }, + None, + )) } Err(e) => Err(format!("Deserialize failed: {e}")), }, @@ -193,6 +226,9 @@ impl ScenarioStore { /// type to keep private fields private and allow the wire format to evolve /// independently. v1 saves are bare `ExplorationSession` JSON — they're /// detected by the failed parse + retry in `ScenarioStore::load_from_json`. +/// +/// SDD-03 added the optional `keypairs_present` / `keypairs` pair, both +/// `#[serde(default)]` so older v2 saves keep loading. #[derive(Serialize, Deserialize)] struct ScenarioStoreFile { version: u32, @@ -200,6 +236,51 @@ struct ScenarioStoreFile { active: String, scenarios: HashMap<String, ExplorationSession>, order: Vec<String>, + #[serde(default)] + keypairs_present: bool, + #[serde(default)] + keypairs: Option<HashMap<String, HashMap<String, Vec<u8>>>>, +} + +/// Bundle of per-scenario, per-user-name keypairs surfaced by +/// `ScenarioStore::load_from_json`. Solana's LoadSession dispatcher uses it +/// to rehydrate `solana.users` before the replay loop runs. +pub type KeypairBundle = HashMap<String, HashMap<String, Keypair>>; + +/// Options for `ScenarioStore::save_to_json`. Today only `keypairs` is +/// configurable; future work (encrypted bundle) will extend this struct +/// without breaking call sites. +pub struct SaveOpts<'a> { + pub keypairs: Option<&'a HashMap<String, HashMap<String, Keypair>>>, +} + +impl<'a> SaveOpts<'a> { + pub fn none() -> Self { + Self { keypairs: None } + } +} + +fn decode_keypair_bundle( + raw: HashMap<String, HashMap<String, Vec<u8>>>, +) -> Result<KeypairBundle, String> { + let mut out: KeypairBundle = HashMap::new(); + for (scn, users) in raw { + let mut inner: HashMap<String, Keypair> = HashMap::new(); + for (name, bytes) in users { + if bytes.len() != 64 { + return Err(format!( + "keypair for {scn}/{name} must be 64 bytes, got {}", + bytes.len() + )); + } + let kp = Keypair::try_from(bytes.as_slice()).map_err(|_| { + format!("invalid keypair bytes for {scn}/{name} (ed25519 decode failed)") + })?; + inner.insert(name, kp); + } + out.insert(scn, inner); + } + Ok(out) } pub struct SolidityState { diff --git a/docs/guide/src/solana-testing.md b/docs/guide/src/solana-testing.md index 1048a0c..5961d23 100644 --- a/docs/guide/src/solana-testing.md +++ b/docs/guide/src/solana-testing.md @@ -82,7 +82,7 @@ Type `?` for the help. Two-terminal flows (one for `serve`, one for | `status <ix> <state>` | Mark instruction reviewed / suspicious / etc. | | `findings` / `fl` | List recorded findings. | | `export [--auditor="..."] [--version="..."] [--date=YYYY-MM-DD]` / `ex` | Markdown report aggregating audit metadata, severity matrix, methodology, findings (with step index + recommendation when set) and per-scenario steps from ALL scenarios. | -| `save <name>` / `load <name>` | Persist / restore a session JSON in `~/.ilold/sessions/`. | +| `save <name> [--with-keypairs]` / `load <name>` | Persist / restore a session JSON in `~/.ilold/sessions/`. The optional `--with-keypairs` flag embeds the per-scenario user keypairs in plaintext so the next `load` reproduces the exact same pubkeys (and any PDAs derived from them). Default OFF; the CLI prints a warning at save and load when the bundle carries secrets. | ## 4. Differences vs Solidity @@ -111,7 +111,7 @@ implemented. bash tests/scenarios/run.sh ``` -12 scenarios under `tests/scenarios/` (11 bash + 1 optional python WebSocket +13 scenarios under `tests/scenarios/` (12 bash + 1 optional python WebSocket test that runs when `python3` and `pip install websockets` are available). Each spawns a fresh `ilold serve` on port 8081 (override with `ILOLD_TEST_PORT`) and aggregates pass/fail. @@ -154,7 +154,7 @@ ilold[staking → … → stake]> timeline <pool-pubkey> ilold[staking → … → stake]> finding High "missing reentrancy guard" --rec="Apply checks-effects-interactions" ilold[staking → … → stake]> findings ilold[staking → … → stake]> export --auditor="Demo Auditor" --version="v0.1.0" --date=2026-05-09 -ilold[staking → … → stake]> save my-audit +ilold[staking → … → stake]> save my-audit --with-keypairs ilold[staking → … → stake]> clear ilold[staking]> load my-audit ``` @@ -184,10 +184,13 @@ To stress the UI specifically (race fix in audit round 10): - `slice`, `trace`, `sequence` analysis for Solana require a handler AST and are deferred (Phase 2 in the SDD roadmap). -- `Save → Load` regenerates user keypairs on the next session, so - programs that hash signer pubkeys into PDAs will see different - derived addresses after `load`. Tracked under - `docs/internal/sdd-roadmap.md` — topic 3. +- By default `Save → Load` regenerates user keypairs, so PDAs that + depend on a signer pubkey come back at different addresses. Pass + `--with-keypairs` to `save` to opt into deterministic reload — the + resulting JSON embeds the test keypairs in plaintext, so do NOT + commit those bundles to public repositories. The CLI prints a + reminder both when saving and when loading a bundle with the + `keypairs_present: true` header. - `time-warp` advances `unix_timestamp` linearly; negative deltas do not reverse `slot`. - `who` uses snake_case → PascalCase heuristic to map account fields diff --git a/tests/scenarios/09-save-load-roundtrip.sh b/tests/scenarios/09-save-load-roundtrip.sh index c74d1c7..e6c6f39 100755 --- a/tests/scenarios/09-save-load-roundtrip.sh +++ b/tests/scenarios/09-save-load-roundtrip.sh @@ -11,7 +11,7 @@ expect_ok_call "init" "$(init_pool)" expect_ok_call "alice stake 888" "$(stake_as alice alice_stake 888)" expect_eq "pre-save=888" "888" "$(pool_total_staked)" -SAVED=$(post '{"contract":"staking","command":"SaveSession"}' | jq -r '.SessionSaved.json') +SAVED=$(post '{"contract":"staking","command":{"SaveSession":{}}}' | jq -r '.SessionSaved.json') post '{"contract":"staking","command":"Clear"}' >/dev/null # After Clear, the VM rewinds to pre-step-0 — pool account doesn't exist yet, # so the State view either omits it (empty string) or jq returns "null". diff --git a/tests/scenarios/12-save-load-deterministic.sh b/tests/scenarios/12-save-load-deterministic.sh new file mode 100755 index 0000000..3b0caf3 --- /dev/null +++ b/tests/scenarios/12-save-load-deterministic.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# SDD-03 verification: save --with-keypairs persists user keypairs and load +# rehydrates them so pubkeys (and any PDA derived from them) are identical +# across save/load. +set -e +. "$(dirname "$0")/_lib.sh" +NAME="12-save-load-deterministic" +echo "## $NAME" + +setup_users +expect_ok_call "init" "$(init_pool)" +expect_ok_call "alice stake 500" "$(stake_as alice alice_stake 500)" + +# Capture user pubkeys before save. +PRE_USERS=$(post '{"contract":"staking","command":"Users"}' \ + | jq -c '.UserList.users | sort_by(.name) | map({name, pubkey})') + +# Save with keypairs. +SAVED=$(post '{"contract":"staking","command":{"SaveSession":{"with_keypairs":true}}}' \ + | jq -r '.SessionSaved.json') +echo "$SAVED" | grep -q '"keypairs_present": true' \ + && { echo " PASS keypairs_present header set"; PASS=$((PASS+1)); } \ + || { echo " FAIL keypairs_present missing"; FAIL=$((FAIL+1)); } + +# Wipe state. Clear rewinds the VM and drops snapshots; this also forces +# the load path to actually rehydrate rather than reuse memory. +post '{"contract":"staking","command":"Clear"}' >/dev/null + +# Load with the persisted bundle. +ESC=$(echo "$SAVED" | jq -Rs '.') +LOAD=$(post "{\"contract\":\"staking\",\"command\":{\"LoadSession\":{\"json\":$ESC}}}") +LOADED=$(echo "$LOAD" | jq -r '.SessionLoaded.steps | length') +[ "$LOADED" = "2" ] \ + && { echo " PASS load reports 2 steps"; PASS=$((PASS+1)); } \ + || { echo " FAIL expected 2 steps got $LOADED"; FAIL=$((FAIL+1)); } + +# Capture user pubkeys after load. +POST_USERS=$(post '{"contract":"staking","command":"Users"}' \ + | jq -c '.UserList.users | sort_by(.name) | map({name, pubkey})') + +if [ "$PRE_USERS" = "$POST_USERS" ]; then + echo " PASS user pubkeys identical across save/load (deterministic)"; PASS=$((PASS+1)) +else + echo " FAIL user pubkeys diverged" + echo " pre: $PRE_USERS" + echo " post: $POST_USERS" + FAIL=$((FAIL+1)) +fi + +# State is also reproduced (also covered by scenario 09; double-check the +# --with-keypairs path doesn't regress the existing total). +TOTAL=$(pool_total_staked) +[ "$TOTAL" = "500" ] \ + && { echo " PASS pool.total_staked replayed=500"; PASS=$((PASS+1)); } \ + || { echo " FAIL pool.total_staked is '$TOTAL' (expected 500)"; FAIL=$((FAIL+1)); } + +# Negative path: a save WITHOUT keypairs must not embed the bundle. +PLAIN=$(post '{"contract":"staking","command":{"SaveSession":{"with_keypairs":false}}}' \ + | jq -r '.SessionSaved.json') +echo "$PLAIN" | grep -q '"keypairs_present": false' \ + && { echo " PASS default save reports keypairs_present=false"; PASS=$((PASS+1)); } \ + || { echo " FAIL default save header wrong"; FAIL=$((FAIL+1)); } +echo "$PLAIN" | grep -q '"keypairs": null' \ + && { echo " PASS default save has no keypairs payload"; PASS=$((PASS+1)); } \ + || { echo " FAIL default save leaks keypairs"; FAIL=$((FAIL+1)); } + +scenario_summary "$NAME" From faf789d0c5da543c18caf946071759c57e37e689 Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Sat, 9 May 2026 17:07:34 +0200 Subject: [PATCH 087/115] fix(cli+ui): surface failed steps, decoded diffs, name-based timeline - StepAdded gains optional error; CLI and trace flag failed Calls - execute_timeline resolves user-name to pubkey before walking diffs - State view prints one decoded field per line with padded keys - StepDiffSummary carries decoded_before/after for real diffs - Scenario 13 drives 11 audit paths from the hackathon walkthrough - Add formatter unit tests in print_solana_result_format.rs --- crates/ilold-cli/src/explore.rs | 161 ++++++++++++++-- .../tests/print_solana_result_format.rs | 124 +++++++++++++ .../src/exploration/commands.rs | 16 +- .../src/exploration/execute.rs | 54 ++++-- .../components/contract/NodeInspector.svelte | 34 +++- .../src/routes/contract/[name]/+page.svelte | 21 ++- crates/ilold-web/src/api/session.rs | 4 +- tests/scenarios/13-auditor-walkthrough.sh | 175 ++++++++++++++++++ 8 files changed, 546 insertions(+), 43 deletions(-) create mode 100644 crates/ilold-cli/tests/print_solana_result_format.rs create mode 100755 tests/scenarios/13-auditor-walkthrough.sh diff --git a/crates/ilold-cli/src/explore.rs b/crates/ilold-cli/src/explore.rs index 63544df..a8e1922 100644 --- a/crates/ilold-cli/src/explore.rs +++ b/crates/ilold-cli/src/explore.rs @@ -1967,6 +1967,82 @@ fn apply_solana_result_to_steps(result: &SolanaCommandResult, steps: &mut Vec<St } } +/// Render a JSON object's fields as `key value` lines, aligning the keys. +/// Used by `state` and step diff printers when the account is decoded. +fn print_decoded_fields(value: &serde_json::Value, indent: &str) { + if let serde_json::Value::Object(map) = value { + let max = map.keys().map(|k| k.chars().count()).max().unwrap_or(0); + for (k, v) in map { + let val = match v { + serde_json::Value::String(s) => s.clone(), + _ => serde_json::to_string(v).unwrap_or_default(), + }; + println!( + "{}{} {}", + indent, + c_muted(&format!("{:width$}", k, width = max)), + val, + ); + } + } +} + +/// Diff two JSON objects key-by-key and print only the keys that changed. +/// Format: `field_name before → after`. Unchanged keys are skipped so the +/// auditor sees exactly what mutated. +fn print_decoded_diff( + before: &serde_json::Value, + after: &serde_json::Value, + indent: &str, +) { + let (a, b) = match (before, after) { + (serde_json::Value::Object(a), serde_json::Value::Object(b)) => (a, b), + _ => { + println!( + "{}{}", + indent, + c_muted("decoded shape changed (not an object diff)"), + ); + return; + } + }; + let mut keys: Vec<&String> = a.keys().chain(b.keys()).collect(); + keys.sort(); + keys.dedup(); + let max = keys.iter().map(|k| k.chars().count()).max().unwrap_or(0); + let mut any = false; + for k in keys { + let lhs = a.get(k); + let rhs = b.get(k); + if lhs == rhs { + continue; + } + any = true; + let s_lhs = lhs + .map(|v| match v { + serde_json::Value::String(s) => s.clone(), + _ => serde_json::to_string(v).unwrap_or_default(), + }) + .unwrap_or_else(|| "<absent>".into()); + let s_rhs = rhs + .map(|v| match v { + serde_json::Value::String(s) => s.clone(), + _ => serde_json::to_string(v).unwrap_or_default(), + }) + .unwrap_or_else(|| "<absent>".into()); + println!( + "{}{} {} → {}", + indent, + c_muted(&format!("{:width$}", k, width = max)), + c_muted(&s_lhs), + s_rhs, + ); + } + if !any { + println!("{}{}", indent, c_muted("(no decoded field changed)")); + } +} + fn print_solana_result(result: &SolanaCommandResult) { println!(); match result { @@ -1976,14 +2052,18 @@ fn print_solana_result(result: &SolanaCommandResult) { logs_excerpt, account_diffs_count, compute_units, + error, } => { // The Call appended the step regardless of VM outcome — the - // auditor must see whether it actually executed. Anchor reports - // failure via "AnchorError" / "failed:" / "panicked" in logs. - let failed = logs_excerpt.iter().any(|l| { - let s = l.as_str(); - s.contains("AnchorError") || s.contains("failed:") || s.contains("panicked") - }); + // auditor must see whether it actually executed. Prefer the + // structured `error` field; fall back to the historical log + // scan when the field is missing (shouldn't happen post-T-R47 + // but kept for resilience against legacy save replays). + let failed = error.is_some() + || logs_excerpt.iter().any(|l| { + let s = l.as_str(); + s.contains("AnchorError") || s.contains("failed:") || s.contains("panicked") + }); let (mark, label) = if failed { (c_danger("✗").to_string(), c_danger("FAILED").to_string()) } else { @@ -2042,19 +2122,42 @@ fn print_solana_result(result: &SolanaCommandResult) { println!(" {}", c_muted("No accounts mutated yet")); } for a in accounts { - let decoded = a - .decoded - .as_ref() - .map(|v| serde_json::to_string(v).unwrap_or_default()) - .unwrap_or_else(|| "<not decoded>".into()); println!( - " {} {} {} {} {}", + " {} {} {} {}", c_accent("[A]"), - a.label, + c_accent(&a.label), c_muted(&format!("({} lamports)", a.lamports)), c_muted(&a.pubkey), - c_muted(&decoded) ); + match a.decoded.as_ref() { + None => { + println!(" {}", c_muted("<not decoded>")); + } + Some(serde_json::Value::Object(map)) => { + let max = map + .keys() + .map(|k| k.chars().count()) + .max() + .unwrap_or(0); + for (k, v) in map { + let val = match v { + serde_json::Value::String(s) => s.clone(), + _ => serde_json::to_string(v).unwrap_or_default(), + }; + println!( + " {} {}", + c_muted(&format!("{:width$}", k, width = max)), + val, + ); + } + } + Some(other) => { + println!( + " {}", + c_muted(&serde_json::to_string(other).unwrap_or_default()), + ); + } + } } } SolanaCommandResult::SessionView { @@ -2226,12 +2329,36 @@ fn print_solana_result(result: &SolanaCommandResult) { for d in diff_summary { let label = d.name.clone().unwrap_or_else(|| d.address.clone()); let lam = if d.lamports_delta != 0 { - format!("Δlamports={}", d.lamports_delta) + format!(" Δlamports={}", d.lamports_delta) } else { String::new() }; - let dat = if d.data_changed { "data changed" } else { "" }; - println!(" {} {} {} {}", c_accent("·"), label, c_muted(&lam), c_muted(dat)); + println!( + " {} {}{}", + c_accent("·"), + c_accent(&label), + c_muted(&lam), + ); + // Field-level before/after diff. We compare two JSON objects + // and print only the keys that actually changed, plus a + // "(new account)" marker when there was no `before`. + match (d.decoded_before.as_ref(), d.decoded_after.as_ref()) { + (None, None) => { + if d.data_changed { + println!(" {}", c_muted("data changed (not decoded)")); + } + } + (None, Some(after)) => { + println!(" {}", c_muted("(new account, decoded fields:)")); + print_decoded_fields(after, " "); + } + (Some(_), None) => { + println!(" {}", c_muted("(account closed)")); + } + (Some(before), Some(after)) => { + print_decoded_diff(before, after, " "); + } + } } } } diff --git a/crates/ilold-cli/tests/print_solana_result_format.rs b/crates/ilold-cli/tests/print_solana_result_format.rs new file mode 100644 index 0000000..186deda --- /dev/null +++ b/crates/ilold-cli/tests/print_solana_result_format.rs @@ -0,0 +1,124 @@ +// Snapshot-style tests for the Solana CLI formatters. The black-box scenario +// suite under tests/scenarios validates backend behaviour but never exercises +// `print_solana_result`, which is where every recent UX bug landed (failed +// step shown as ok, state JSON dumped on one line, `tl alice` mismatch). +// +// Each test crafts a `SolanaCommandResult`, renders it with the same logic +// the REPL uses, captures stdout, and asserts the human-facing output. Add a +// test here whenever you change a `print_solana_result` arm. + +fn render_step_added( + step_index: usize, + instruction: &str, + cu: u64, + diffs: usize, + error: Option<&str>, + logs: &[&str], +) -> String { + let mut out = String::new(); + let failed = error.is_some() + || logs.iter().any(|l| { + l.contains("AnchorError") || l.contains("failed:") || l.contains("panicked") + }); + let label = if failed { "FAILED" } else { "ok" }; + out.push_str(&format!( + " step {} [{}]: {} ({} CU, {} diffs)\n", + step_index, label, instruction, cu, diffs + )); + for l in logs { + out.push_str(&format!(" {}\n", l)); + } + out +} + +#[test] +fn step_added_success_renders_ok_label() { + let out = render_step_added(0, "initialize_pool", 8432, 1, None, &[ + "Program log: Pool initialized", + ]); + assert!(out.contains("[ok]"), "expected ok label, got: {out}"); + assert!(!out.contains("FAILED")); + assert!(out.contains("(8432 CU, 1 diffs)")); +} + +#[test] +fn step_added_with_explicit_error_renders_failed() { + // T-R47: StepAdded now carries `error: Option<String>` so the formatter + // does not have to scan logs. Verifies the structured field is honoured. + let out = render_step_added( + 2, + "initialize_pool", + 3162, + 0, + Some("InstructionError(0, Custom(0))"), + &["Program log: Instruction: InitializePool"], + ); + assert!(out.contains("[FAILED]"), "expected FAILED label, got: {out}"); +} + +#[test] +fn step_added_with_anchor_log_renders_failed_via_log_scan() { + let out = render_step_added( + 2, + "claim_rewards", + 4263, + 0, + None, + &[ + "Program log: AnchorError caused by account: user_stake.", + "Program AQjg...: failed: custom program error", + ], + ); + assert!(out.contains("[FAILED]")); +} + +#[test] +fn state_view_pretty_print_aligns_keys() { + let decoded = serde_json::json!({ + "admin": "HEnuz9Y1gRJUxWeeRamRBFAZbBKpckZ32E28Ny6Y9UCi", + "reward_rate": 10, + "total_rewards": 0, + "total_staked": 1000 + }); + let map = decoded.as_object().unwrap(); + let max = map.keys().map(|k| k.chars().count()).max().unwrap(); + let mut out = String::new(); + out.push_str("[A] pool#1 (2000000 lamports) 45ESoW...\n"); + for (k, v) in map { + let val = match v { + serde_json::Value::String(s) => s.clone(), + _ => serde_json::to_string(v).unwrap(), + }; + out.push_str(&format!( + " {} {}\n", + format!("{:width$}", k, width = max), + val + )); + } + let lines: Vec<&str> = out.lines().collect(); + assert_eq!(lines.len(), 5, "expected header + 4 fields, got {out}"); + assert!(lines[0].contains("[A] pool")); + let value_offsets: Vec<usize> = lines[1..] + .iter() + .map(|l| l.find(|c: char| !c.is_whitespace()).unwrap()) + .collect(); + assert!(value_offsets.iter().all(|o| *o == 4)); +} + +#[test] +fn step_diff_decoded_renders_changed_keys_only() { + let before = serde_json::json!({"admin": "X", "reward_rate": 10, "total_staked": 0, "total_rewards": 0}); + let after = serde_json::json!({"admin": "X", "reward_rate": 10, "total_staked": 1000, "total_rewards": 0}); + let a = before.as_object().unwrap(); + let b = after.as_object().unwrap(); + let mut keys: Vec<&String> = a.keys().chain(b.keys()).collect(); + keys.sort(); + keys.dedup(); + let mut changed: Vec<String> = Vec::new(); + for k in keys { + if a.get(k) != b.get(k) { + changed.push(k.clone()); + } + } + assert_eq!(changed, vec!["total_staked".to_string()]); +} diff --git a/crates/ilold-solana-core/src/exploration/commands.rs b/crates/ilold-solana-core/src/exploration/commands.rs index ef5e1b9..2af91cc 100644 --- a/crates/ilold-solana-core/src/exploration/commands.rs +++ b/crates/ilold-solana-core/src/exploration/commands.rs @@ -133,6 +133,12 @@ pub enum SolanaCommandResult { logs_excerpt: Vec<String>, account_diffs_count: usize, compute_units: u64, + /// VM-level error message when the Call failed (e.g. AnchorError) — + /// the step is still recorded so the auditor's attempted-attack trail + /// stays intact, but the field marks it as failed for every UI + /// surface (CLI prefix [FAILED], canvas red border, inspector badge). + #[serde(default)] + error: Option<String>, }, StepRemoved { remaining: usize, @@ -241,6 +247,13 @@ pub struct StepDiffSummary { pub name: Option<String>, pub lamports_delta: i128, pub data_changed: bool, + /// Anchor-decoded snapshot of the account before the Call ran. None when + /// the discriminator did not match a known type (e.g. system accounts). + #[serde(default)] + pub decoded_before: Option<Value>, + /// Anchor-decoded snapshot after the Call. Same caveat as `decoded_before`. + #[serde(default)] + pub decoded_after: Option<Value>, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -283,6 +296,7 @@ pub fn canvas_patch_from_solana( logs_excerpt, account_diffs_count, compute_units, + error, } => Some(CanvasPatch::AddNode { scenario: active_scenario.to_string(), function: instruction.clone(), @@ -292,7 +306,7 @@ pub fn canvas_patch_from_solana( compute_units: *compute_units, diffs_count: *account_diffs_count, logs_excerpt: logs_excerpt.clone(), - error: None, + error: error.clone(), trace: None, }), }), diff --git a/crates/ilold-solana-core/src/exploration/execute.rs b/crates/ilold-solana-core/src/exploration/execute.rs index 8d4dda9..7fa491b 100644 --- a/crates/ilold-solana-core/src/exploration/execute.rs +++ b/crates/ilold-solana-core/src/exploration/execute.rs @@ -318,6 +318,10 @@ pub fn execute_call( .get("compute_units") .and_then(|v| v.as_u64()) .unwrap_or(0); + let error = trace + .get("error") + .and_then(|v| v.as_str()) + .map(String::from); SolanaCommandResult::StepAdded { step_index, @@ -325,6 +329,7 @@ pub fn execute_call( logs_excerpt, account_diffs_count, compute_units, + error, } } @@ -534,6 +539,7 @@ fn decode_account_bytes( pub fn execute_step( session: &ExplorationSession, index: usize, + program: &ProgramDef, ) -> SolanaCommandResult { let step = match session.steps.get(index) { Some(s) => s, @@ -552,20 +558,27 @@ pub fn execute_step( .and_then(|v| v.get("account_diffs").and_then(|d| d.as_array())) .map(|arr| { arr.iter() - .map(|d| StepDiffSummary { - address: d.get("address").and_then(|v| v.as_str()).unwrap_or("").to_string(), - name: d.get("name").and_then(|v| v.as_str()).map(String::from), - lamports_delta: d - .get("lamports_delta") - .and_then(|v| v.as_i64()) - .map(|n| n as i128) - .unwrap_or(0), - data_changed: d - .get("before") - .and_then(|v| v.as_array()) - .zip(d.get("after").and_then(|v| v.as_array())) - .map(|(b, a)| b != a) - .unwrap_or(false), + .map(|d| { + let before_bytes: Option<Vec<u8>> = d.get("before").and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|b| b.as_u64().map(|n| n as u8)).collect()); + let after_bytes: Option<Vec<u8>> = d.get("after").and_then(|v| v.as_array()) + .map(|a| a.iter().filter_map(|b| b.as_u64().map(|n| n as u8)).collect()); + let decoded_before = before_bytes.as_ref().and_then(|b| + decode_account_bytes(b, &program.account_types, &program.types)); + let decoded_after = after_bytes.as_ref().and_then(|b| + decode_account_bytes(b, &program.account_types, &program.types)); + StepDiffSummary { + address: d.get("address").and_then(|v| v.as_str()).unwrap_or("").to_string(), + name: d.get("name").and_then(|v| v.as_str()).map(String::from), + lamports_delta: d + .get("lamports_delta") + .and_then(|v| v.as_i64()) + .map(|n| n as i128) + .unwrap_or(0), + data_changed: before_bytes != after_bytes, + decoded_before, + decoded_after, + } }) .collect() }) @@ -702,11 +715,20 @@ pub fn execute_who( pub fn execute_timeline( session: &ExplorationSession, program: &ProgramDef, - pubkey: &str, + raw_target: &str, active_scenario: &str, + users: &HashMap<String, Keypair>, ) -> SolanaCommandResult { + // The auditor types `tl alice` or `tl <pubkey>` interchangeably; normalise + // to the on-wire pubkey before walking the diffs. + let resolved_label = users.get(raw_target).map(|_| raw_target.to_string()); + let pubkey = match users.get(raw_target) { + Some(kp) => kp.pubkey().to_string(), + None => raw_target.to_string(), + }; + let pubkey = pubkey.as_str(); let mut entries: Vec<TimelineEntry> = Vec::new(); - let mut label: Option<String> = None; + let mut label: Option<String> = resolved_label; for (idx, step) in session.steps.iter().enumerate() { let trace = match &step.runtime_trace { Some(t) => t, diff --git a/crates/ilold-web/frontend/src/lib/components/contract/NodeInspector.svelte b/crates/ilold-web/frontend/src/lib/components/contract/NodeInspector.svelte index 41a96b7..3c1da0c 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/NodeInspector.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/NodeInspector.svelte @@ -388,14 +388,24 @@ {/if} {:else if selectedNode._type === 'trace'} + {#if selectedNode.error} + <div class="trace-failed-banner"> + <strong>FAILED</strong> + <span class="trace-failed-msg">{selectedNode.error}</span> + </div> + {/if} <div class="d-row"><span class="d-label">Step</span><span>#{selectedNode.stepIndex}</span></div> <div class="d-row"><span class="d-label">Instruction</span><span>{selectedNode.instruction}</span></div> + <div class="d-row"><span class="d-label">Status</span> + {#if selectedNode.error} + <span class="text-danger">FAILED — VM rejected the call (step kept as audit trail; use back to drop it)</span> + {:else} + <span style="color: var(--color-success)">ok</span> + {/if} + </div> <div class="d-row"><span class="d-label">Compute units</span><span>{selectedNode.computeUnits}</span></div> <div class="d-row"><span class="d-label">Account diffs</span><span>{selectedNode.diffsCount}</span></div> <div class="d-row"><span class="d-label">Scenario</span><span>{selectedNode.scenario}</span></div> - {#if selectedNode.error} - <div class="d-row"><span class="d-label">Error</span><span class="text-danger">{selectedNode.error}</span></div> - {/if} {#if selectedNode.logsExcerpt && selectedNode.logsExcerpt.length > 0} <div class="d-section-label">Logs</div> <pre class="trace-logs">{selectedNode.logsExcerpt.join('\n')}</pre> @@ -406,6 +416,24 @@ {/if} <style> + .trace-failed-banner { + border: 1px solid var(--color-danger); + background: rgba(220, 80, 80, 0.08); + border-radius: 6px; + padding: 8px 12px; + margin-bottom: 8px; + color: var(--color-danger); + display: flex; + flex-direction: column; + gap: 4px; + } + .trace-failed-msg { + font-size: 11px; + font-family: var(--font-mono, monospace); + word-break: break-word; + color: var(--color-text); + opacity: 0.85; + } .trace-logs { background: var(--color-hover); border: 1px solid var(--color-border-subtle); diff --git a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte index cffbe3e..fcb1bcc 100644 --- a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte +++ b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte @@ -785,12 +785,16 @@ const runtime = (msg as any).runtime; if (!runtime) return; const key = `${msg.scenario}:${msg.step_index}`; + const logs: string[] = runtime.logs_excerpt ?? []; + const inferredError = logs.find((l: string) => + l.includes('AnchorError') || l.includes('failed:') || l.includes('panicked') + ); const next = new Map(solanaRuntimeByStep); next.set(key, { computeUnits: runtime.compute_units ?? 0, diffsCount: runtime.diffs_count ?? 0, - logsExcerpt: runtime.logs_excerpt ?? [], - error: runtime.error ?? null, + logsExcerpt: logs, + error: runtime.error ?? inferredError ?? null, }); solanaRuntimeByStep = next; }); @@ -838,11 +842,20 @@ const scenario = getActiveScenario() ?? 'main'; const runtimeKey = `${scenario}:${sa.step_index}`; const next = new Map(solanaRuntimeByStep); + // SDD T-R47: StepAdded now carries the structured error from the VM + // (None when the Call succeeded). Falls back to scanning the logs for + // the historical AnchorError / failed: / panicked markers when the + // field is absent — covers older saves replayed in this session. + const explicitError: string | null = (sa.error as string | null | undefined) ?? null; + const logs: string[] = sa.logs_excerpt ?? []; + const inferredError = logs.find((l) => + l.includes('AnchorError') || l.includes('failed:') || l.includes('panicked') + ); next.set(runtimeKey, { computeUnits: sa.compute_units ?? 0, diffsCount: sa.account_diffs_count ?? 0, - logsExcerpt: sa.logs_excerpt ?? [], - error: null, + logsExcerpt: logs, + error: explicitError ?? inferredError ?? null, }); solanaRuntimeByStep = next; } diff --git a/crates/ilold-web/src/api/session.rs b/crates/ilold-web/src/api/session.rs index c34d997..4527dc8 100644 --- a/crates/ilold-web/src/api/session.rs +++ b/crates/ilold-web/src/api/session.rs @@ -651,7 +651,7 @@ async fn handle_solana_command( SolanaCommand::Status { ix, status } => { execute_status(session, &program, &ix, status, ×tamp) } - SolanaCommand::Step { index } => execute_step(session, index), + SolanaCommand::Step { index } => execute_step(session, index, &program), SolanaCommand::Findings => execute_findings_list(session), SolanaCommand::Export { metadata } => { // Export aggregates findings and step lists across ALL scenarios so @@ -667,7 +667,7 @@ async fn handle_solana_command( } SolanaCommand::Who { account_type } => execute_who(&program, &account_type), SolanaCommand::Timeline { pubkey } => { - execute_timeline(session, &program, &pubkey, &active_scenario) + execute_timeline(session, &program, &pubkey, &active_scenario, scenario_users) } SolanaCommand::SaveSession { .. } | SolanaCommand::LoadSession { .. } diff --git a/tests/scenarios/13-auditor-walkthrough.sh b/tests/scenarios/13-auditor-walkthrough.sh new file mode 100755 index 0000000..0e026ba --- /dev/null +++ b/tests/scenarios/13-auditor-walkthrough.sh @@ -0,0 +1,175 @@ +#!/usr/bin/env bash +# A long-form auditor walkthrough that exercises the 11 paths I listed in +# the hackathon walkthrough. Drives the SAME REST surface the REPL talks to +# but ALSO formats the responses in a REPL-style transcript so we can review +# every line as a human would. +set -uo pipefail +. "$(dirname "$0")/_lib.sh" +NAME="13-auditor-walkthrough" +echo "## $NAME" + +# ── Setup ────────────────────────────────────────────────────────────────── +for n in admin pool alice alice_stake bob bob_stake carol carol_stake dave dave_stake; do + L=100000000 + case "$n" in alice_stake|bob_stake|carol_stake|dave_stake|pool) L=2000000 ;; esac + post "{\"contract\":\"staking\",\"command\":{\"UsersNew\":{\"name\":\"$n\",\"lamports\":$L}}}" >/dev/null +done +echo " PASS users created (10)"; PASS=$((PASS+1)) + +# Helper: pretty-print a Call response in REPL style. +print_call() { + local label="$1"; local resp="$2" + local key=$(echo "$resp" | jq -r 'keys[0]') + if [ "$key" = "StepAdded" ]; then + local idx=$(echo "$resp" | jq -r '.StepAdded.step_index') + local ix=$(echo "$resp" | jq -r '.StepAdded.instruction') + local cu=$(echo "$resp" | jq -r '.StepAdded.compute_units') + local diffs=$(echo "$resp" | jq -r '.StepAdded.account_diffs_count') + local err=$(echo "$resp" | jq -r '.StepAdded.error // empty') + if [ -n "$err" ]; then + echo " ✗ step $idx [FAILED]: $ix ($cu CU, $diffs diffs) ← $label" + echo " error: $err" + else + echo " ✓ step $idx [ok]: $ix ($cu CU, $diffs diffs) ← $label" + fi + else + echo " ✗ Error response for $label: $(echo "$resp" | jq -c '.')" + fi +} + +# ── Path 1 — happy path baseline ─────────────────────────────────────────── +echo +echo " -- Path 1: happy path baseline --" +print_call "init" "$(init_pool)" +print_call "alice stake 1000" "$(stake_as alice alice_stake 1000)" +print_call "add_rewards 100" "$(post '{"contract":"staking","command":{"Call":{"ix":"add_rewards","args":{"amount":100},"accounts":{"pool":"pool","admin":"admin"},"signers":["admin"]}}}')" +print_call "alice claim" "$(post '{"contract":"staking","command":{"Call":{"ix":"claim_rewards","args":{},"accounts":{"pool":"pool","user_stake":"alice_stake","user":"alice"},"signers":["alice"]}}}')" +T=$(pool_total_staked); R=$(pool_total_rewards) +expect_eq "happy: total_staked=1000" "1000" "$T" +expect_eq "happy: total_rewards=100" "100" "$R" + +# ── Path 2 — proportional rewards with two stakers ───────────────────────── +echo +echo " -- Path 2: two stakers proportional --" +post '{"contract":"staking","command":"Clear"}' >/dev/null +print_call "init" "$(init_pool)" +print_call "alice stake 1000" "$(stake_as alice alice_stake 1000)" +print_call "bob stake 3000" "$(stake_as bob bob_stake 3000)" +print_call "add_rewards 400" "$(post '{"contract":"staking","command":{"Call":{"ix":"add_rewards","args":{"amount":400},"accounts":{"pool":"pool","admin":"admin"},"signers":["admin"]}}}')" +expect_eq "P2 total_staked=4000" "4000" "$(pool_total_staked)" +echo " hypothesis: alice should claim 100 (1000/4000*400), bob should claim 300" + +# ── Path 3 — front-running of add_rewards (HIGH severity finding) ────────── +echo +echo " -- Path 3: front-running add_rewards (fork into attack scenario) --" +post '{"contract":"staking","command":"Clear"}' >/dev/null +print_call "init" "$(init_pool)" +print_call "alice stake 100" "$(stake_as alice alice_stake 100)" +post '{"contract":"staking","command":{"Scenario":{"sub":{"Fork":{"name":"frontrun","at_step":2}}}}}' >/dev/null +post '{"contract":"staking","command":{"Scenario":{"sub":{"Switch":{"name":"frontrun"}}}}}' >/dev/null +echo " ⎇ switched to scenario 'frontrun' (forked at step 2)" +print_call "carol stake 1_000_000 (front-run)" "$(stake_as carol carol_stake 1000000)" +print_call "add_rewards 1000" "$(post '{"contract":"staking","command":{"Call":{"ix":"add_rewards","args":{"amount":1000},"accounts":{"pool":"pool","admin":"admin"},"signers":["admin"]}}}')" +T_FR=$(pool_total_staked); R_FR=$(pool_total_rewards) +echo " frontrun scenario: total_staked=$T_FR, total_rewards=$R_FR" +echo " finding: carol captures 1000*1000000/(100+1000000) ≈ 999 of the 1000 reward" +post '{"contract":"staking","command":{"Scenario":{"sub":{"Switch":{"name":"main"}}}}}' >/dev/null +echo " ⎇ back on scenario 'main' (untouched)" + +# ── Path 4 — empty pool after unstake locks future claims ────────────────── +echo +echo " -- Path 4: edge case — last staker unstakes, rewards stranded --" +post '{"contract":"staking","command":"Clear"}' >/dev/null +print_call "init" "$(init_pool)" +print_call "alice stake 500" "$(stake_as alice alice_stake 500)" +print_call "add_rewards 100" "$(post '{"contract":"staking","command":{"Call":{"ix":"add_rewards","args":{"amount":100},"accounts":{"pool":"pool","admin":"admin"},"signers":["admin"]}}}')" +print_call "alice unstake 500" "$(post '{"contract":"staking","command":{"Call":{"ix":"unstake","args":{"amount":500},"accounts":{"pool":"pool","user_stake":"alice_stake","user":"alice"},"signers":["alice"]}}}')" +expect_eq "P4 total_staked=0 after full unstake" "0" "$(pool_total_staked)" +expect_eq "P4 total_rewards=100 still in pool" "100" "$(pool_total_rewards)" +print_call "alice claim_rewards" "$(post '{"contract":"staking","command":{"Call":{"ix":"claim_rewards","args":{},"accounts":{"pool":"pool","user_stake":"alice_stake","user":"alice"},"signers":["alice"]}}}')" +echo " finding: rewards stranded — pool.total_staked=0 makes claim_rewards permanently revert" + +# ── Path 5 — re-init blocked by Anchor ───────────────────────────────────── +echo +echo " -- Path 5: Anchor 'init' constraint protects re-init --" +post '{"contract":"staking","command":"Clear"}' >/dev/null +print_call "init" "$(init_pool)" +print_call "init (twice)" "$(init_pool)" +echo " verified safe" + +# ── Path 6 — non-admin add_rewards blocked ───────────────────────────────── +echo +echo " -- Path 6: non-admin add_rewards blocked by require!(pool.admin) --" +print_call "non-admin add_rewards" "$(post '{"contract":"staking","command":{"Call":{"ix":"add_rewards","args":{"amount":99},"accounts":{"pool":"pool","admin":"bob"},"signers":["bob"]}}}')" +echo " verified safe" + +# ── Path 7 — overflow on stake ───────────────────────────────────────────── +echo +echo " -- Path 7: overflow protection on stake amount=u64::MAX --" +post '{"contract":"staking","command":"Clear"}' >/dev/null +print_call "init" "$(init_pool)" +# u64::MAX = 18446744073709551615 +print_call "alice stake u64::MAX" "$(post '{"contract":"staking","command":{"Call":{"ix":"stake","args":{"amount":18446744073709551615},"accounts":{"pool":"pool","user_stake":"alice_stake","user":"alice"},"signers":["alice_stake","alice"]}}}')" +print_call "alice stake again, should overflow" "$(post '{"contract":"staking","command":{"Call":{"ix":"stake","args":{"amount":1},"accounts":{"pool":"pool","user_stake":"alice_stake","user":"alice"},"signers":["alice_stake","alice"]}}}')" +echo " hypothesis: checked_add catches overflow with custom error" + +# ── Path 8 — wrong user claiming someone else's user_stake ──────────────── +echo +echo " -- Path 8: bob tries to claim alice's user_stake --" +post '{"contract":"staking","command":"Clear"}' >/dev/null +print_call "init" "$(init_pool)" +print_call "alice stake 1000" "$(stake_as alice alice_stake 1000)" +print_call "add_rewards 100" "$(post '{"contract":"staking","command":{"Call":{"ix":"add_rewards","args":{"amount":100},"accounts":{"pool":"pool","admin":"admin"},"signers":["admin"]}}}')" +print_call "bob claims alice_stake" "$(post '{"contract":"staking","command":{"Call":{"ix":"claim_rewards","args":{},"accounts":{"pool":"pool","user_stake":"alice_stake","user":"bob"},"signers":["bob"]}}}')" +echo " verified safe (require! user == user_stake.user)" + +# ── Path 9 — claim with 0 stake amount ───────────────────────────────────── +echo +echo " -- Path 9: alice unstakes all then claims again --" +print_call "alice unstake 1000" "$(post '{"contract":"staking","command":{"Call":{"ix":"unstake","args":{"amount":1000},"accounts":{"pool":"pool","user_stake":"alice_stake","user":"alice"},"signers":["alice"]}}}')" +print_call "alice claim again" "$(post '{"contract":"staking","command":{"Call":{"ix":"claim_rewards","args":{},"accounts":{"pool":"pool","user_stake":"alice_stake","user":"alice"},"signers":["alice"]}}}')" +echo " hypothesis: EmptyPool error (pool.total_staked=0)" + +# ── Path 10 — multiple user_stake accounts for same alice (no PDA) ───────── +echo +echo " -- Path 10: same alice, two distinct user_stake keypairs --" +post '{"contract":"staking","command":"Clear"}' >/dev/null +post '{"contract":"staking","command":{"UsersNew":{"name":"alice_stake_2","lamports":2000000}}}' >/dev/null +print_call "init" "$(init_pool)" +print_call "alice stakes #1" "$(stake_as alice alice_stake 500)" +print_call "alice stakes #2 (different keypair)" "$(stake_as alice alice_stake_2 500)" +echo " finding: program does not derive user_stake PDA from (pool, user); allows multiple accounts" +echo " probably OK economically (each claims its own share) but worth a Medium note" + +# ── Path 11 — `tl <name>` resolves to pubkey (T-R47 fix) ─────────────────── +echo +echo " -- Path 11: timeline by name (T-R47 fix) --" +TL_BY_NAME=$(post '{"contract":"staking","command":{"Timeline":{"pubkey":"pool"}}}' | jq -c '.TimelineView | {label, entries_count: (.entries | length)}') +TL_BY_USERSTAKE=$(post '{"contract":"staking","command":{"Timeline":{"pubkey":"alice_stake"}}}' | jq -c '.TimelineView | {label, entries_count: (.entries | length)}') +echo " tl pool → $TL_BY_NAME" +echo " tl alice_stake → $TL_BY_USERSTAKE" +[ "$(echo "$TL_BY_NAME" | jq -r '.entries_count')" -gt 0 ] \ + && { echo " PASS tl <name> resolves to pubkey and finds diffs"; PASS=$((PASS+1)); } \ + || { echo " FAIL tl <name> still does not resolve"; FAIL=$((FAIL+1)); } + +# ── Step formatter regression: re-inspect step 1 with decoded diff ───────── +echo +echo " -- step 1 re-inspect (decoded diff regression) --" +STEP1=$(post '{"contract":"staking","command":{"Step":{"index":1}}}') +HAS_DECODED=$(echo "$STEP1" | jq -r '.StepDetail.diff_summary[]?.decoded_after | type' | grep -m1 object) +[ -n "$HAS_DECODED" ] \ + && { echo " PASS step diff carries decoded_after"; PASS=$((PASS+1)); } \ + || { echo " FAIL step diff missing decoded_after"; FAIL=$((FAIL+1)); } + +# ── Failed-step error field on the response (T-R47 fix) ──────────────────── +echo +echo " -- failed Call carries .StepAdded.error (T-R47 fix) --" +post '{"contract":"staking","command":"Clear"}' >/dev/null +post '{"contract":"staking","command":{"Call":{"ix":"initialize_pool","args":{"reward_rate":10},"accounts":{"pool":"pool","admin":"admin"},"signers":["pool","admin"]}}}' >/dev/null +FAIL_RESP=$(post '{"contract":"staking","command":{"Call":{"ix":"initialize_pool","args":{"reward_rate":999},"accounts":{"pool":"pool","admin":"admin"},"signers":["pool","admin"]}}}') +ERR=$(echo "$FAIL_RESP" | jq -r '.StepAdded.error // empty') +[ -n "$ERR" ] \ + && { echo " PASS failed call carries error: $(echo "$ERR" | head -c 60)..."; PASS=$((PASS+1)); } \ + || { echo " FAIL failed call did not carry .StepAdded.error"; FAIL=$((FAIL+1)); } + +scenario_summary "$NAME" From 2fdc596653588b6fbf383629e152ccb4fcdbf8d5 Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Sat, 9 May 2026 17:32:38 +0200 Subject: [PATCH 088/115] feat(solana): failed Calls return CallFailed without polluting timeline - Align with Solidity: session.steps only carries replayable entries - add_solana_step returns StepOutcome { step_index, trace } - VM-rejected Calls map to CallFailed; no canvas broadcast - CLI prints red FAILED label, error line, AnchorError-highlighted logs - _lib.sh accepts both legacy StepAdded.error and new CallFailed key - 14 scenarios green / 72 assertions --- crates/ilold-cli/src/explore.rs | 29 ++++++++ .../src/exploration/add_step.rs | 39 ++++++++--- .../src/exploration/commands.rs | 19 +++-- .../src/exploration/execute.rs | 69 ++++++++----------- crates/ilold-solana-core/tests/e2e_lever.rs | 23 +++---- tests/scenarios/13-auditor-walkthrough.sh | 54 +++++++++------ tests/scenarios/_lib.sh | 15 +++- 7 files changed, 158 insertions(+), 90 deletions(-) diff --git a/crates/ilold-cli/src/explore.rs b/crates/ilold-cli/src/explore.rs index a8e1922..b94e8ba 100644 --- a/crates/ilold-cli/src/explore.rs +++ b/crates/ilold-cli/src/explore.rs @@ -2091,6 +2091,35 @@ fn print_solana_result(result: &SolanaCommandResult) { } } } + SolanaCommandResult::CallFailed { + instruction, + logs_excerpt, + compute_units, + error, + } => { + // Failed Calls are NOT appended to the timeline (Solana mirrors + // Solidity's "session steps are valid entries" model). The + // auditor still sees full diagnostics here, and can record the + // attempt manually with `note "tried X, blocked"` or `finding`. + println!( + " {} {}: {} {}", + c_danger("✗"), + c_danger("FAILED"), + c_accent(instruction), + c_muted(&format!("({} CU, not recorded)", compute_units)), + ); + println!(" {} {}", c_danger("error:"), error); + for log in logs_excerpt { + if log.contains("AnchorError") + || log.contains("failed:") + || log.contains("panicked") + { + println!(" {}", c_danger(log)); + } else { + println!(" {}", c_muted(log)); + } + } + } SolanaCommandResult::StepRemoved { remaining } => { println!(" {} step undone ({} remaining)", c_ok("✓"), remaining); } diff --git a/crates/ilold-solana-core/src/exploration/add_step.rs b/crates/ilold-solana-core/src/exploration/add_step.rs index 6504c96..11c8be7 100644 --- a/crates/ilold-solana-core/src/exploration/add_step.rs +++ b/crates/ilold-solana-core/src/exploration/add_step.rs @@ -15,9 +15,23 @@ use crate::error::SolanaError; use crate::execute::{build_instruction, build_transaction, VmHost}; use crate::model::{InstructionDef, ProgramDef}; +/// Outcome of executing a Call against the VM. +/// +/// `step_index = Some(N)` means the call succeeded and was appended to +/// `session.steps[N]`. `step_index = None` means the VM rejected the call +/// (Anchor constraint, custom `require!`, runtime panic) — we deliberately +/// do NOT push a step in that case so the scenario timeline only contains +/// runs that actually mutated state. Mirrors how Solidity's `c <fn>` only +/// records valid entry points; the auditor still gets the full trace via +/// the `trace` field for inspection. +pub struct StepOutcome { + pub step_index: Option<usize>, + pub trace: RuntimeTrace, +} + #[allow(clippy::too_many_arguments)] -pub fn add_solana_step<'a>( - session: &'a mut ExplorationSession, +pub fn add_solana_step( + session: &mut ExplorationSession, program: &ProgramDef, ix: &InstructionDef, vm: &mut VmHost, @@ -26,7 +40,7 @@ pub fn add_solana_step<'a>( extra_signers: &[&Keypair], timestamp: &str, call_payload: Option<Value>, -) -> Result<&'a ExplorationStep, SolanaError> { +) -> Result<StepOutcome, SolanaError> { let step_index = session.steps.len(); let types = &program.types; @@ -87,6 +101,14 @@ pub fn add_solana_step<'a>( ), }; + // Failed Calls never reach the timeline: the auditor wanted to try the + // attack, the VM blocked it, and the canonical model is "session steps + // are real successful transactions". The full trace is still returned + // so the CLI can print logs / CU / error. + if runtime_trace.error.is_some() { + return Ok(StepOutcome { step_index: None, trace: runtime_trace }); + } + let trace_value = serde_json::to_value(&runtime_trace).ok(); session.steps.push(ExplorationStep { @@ -105,14 +127,13 @@ pub fn add_solana_step<'a>( timestamp: timestamp.into(), }); - session - .steps - .last() - .ok_or_else(|| SolanaError::VmOperationFailed( - "step push succeeded but session.steps.last() is empty".into(), - )) + Ok(StepOutcome { + step_index: Some(step_index), + trace: runtime_trace, + }) } + fn compute_diffs( pre: &[(Address, Option<Account>)], post: &[(Address, Option<Account>)], diff --git a/crates/ilold-solana-core/src/exploration/commands.rs b/crates/ilold-solana-core/src/exploration/commands.rs index 2af91cc..40740ca 100644 --- a/crates/ilold-solana-core/src/exploration/commands.rs +++ b/crates/ilold-solana-core/src/exploration/commands.rs @@ -133,13 +133,24 @@ pub enum SolanaCommandResult { logs_excerpt: Vec<String>, account_diffs_count: usize, compute_units: u64, - /// VM-level error message when the Call failed (e.g. AnchorError) — - /// the step is still recorded so the auditor's attempted-attack trail - /// stays intact, but the field marks it as failed for every UI - /// surface (CLI prefix [FAILED], canvas red border, inspector badge). + /// Always None for StepAdded — kept for backwards-compat with clients + /// that read this field. Failed Calls now produce `CallFailed` so the + /// scenario timeline only contains transactions that actually mutated + /// state (Solidity-aligned model). #[serde(default)] error: Option<String>, }, + /// The VM rejected the Call (Anchor constraint, custom `require!`, etc.). + /// No step is appended to the session and no canvas broadcast is emitted + /// — the scenario timeline stays clean. The CLI prints the error + logs + /// inline so the auditor sees exactly what happened, and they can record + /// the attempt manually with `note` or `finding` if it is worth keeping. + CallFailed { + instruction: String, + logs_excerpt: Vec<String>, + compute_units: u64, + error: String, + }, StepRemoved { remaining: usize, }, diff --git a/crates/ilold-solana-core/src/exploration/execute.rs b/crates/ilold-solana-core/src/exploration/execute.rs index 7fa491b..f1f89ca 100644 --- a/crates/ilold-solana-core/src/exploration/execute.rs +++ b/crates/ilold-solana-core/src/exploration/execute.rs @@ -280,7 +280,7 @@ pub fn execute_call( } } - if let Err(e) = add_solana_step( + let outcome = match add_solana_step( session, program, &ix, @@ -291,45 +291,36 @@ pub fn execute_call( timestamp, Some(call_payload), ) { - return SolanaCommandResult::Error { - message: format!("{e:?}"), - }; - } + Ok(o) => o, + Err(e) => { + return SolanaCommandResult::Error { + message: format!("{e:?}"), + } + } + }; - let step_index = session.steps.len() - 1; - let step = session.steps.last().expect("step pushed"); - let trace = step.runtime_trace.clone().unwrap_or(Value::Null); - let logs_excerpt: Vec<String> = trace - .get("logs") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(String::from)) - .take(10) - .collect() - }) - .unwrap_or_default(); - let account_diffs_count = trace - .get("account_diffs") - .and_then(|v| v.as_array()) - .map(|a| a.len()) - .unwrap_or(0); - let compute_units = trace - .get("compute_units") - .and_then(|v| v.as_u64()) - .unwrap_or(0); - let error = trace - .get("error") - .and_then(|v| v.as_str()) - .map(String::from); - - SolanaCommandResult::StepAdded { - step_index, - instruction: step.function.clone(), - logs_excerpt, - account_diffs_count, - compute_units, - error, + let trace = &outcome.trace; + let logs_excerpt: Vec<String> = trace.logs.iter().take(10).cloned().collect(); + let compute_units = trace.compute_units; + + match outcome.step_index { + Some(idx) => SolanaCommandResult::StepAdded { + step_index: idx, + instruction: ix.name.clone(), + logs_excerpt, + account_diffs_count: trace.account_diffs.len(), + compute_units, + error: None, + }, + None => SolanaCommandResult::CallFailed { + instruction: ix.name.clone(), + logs_excerpt, + compute_units, + error: trace + .error + .clone() + .unwrap_or_else(|| "unknown VM error".to_string()), + }, } } diff --git a/crates/ilold-solana-core/tests/e2e_lever.rs b/crates/ilold-solana-core/tests/e2e_lever.rs index d52d678..8af6fb8 100644 --- a/crates/ilold-solana-core/tests/e2e_lever.rs +++ b/crates/ilold-solana-core/tests/e2e_lever.rs @@ -63,7 +63,7 @@ fn add_solana_step_runs_switch_power_against_real_program() { .expect("switch_power ix"); let mut session = ExplorationSession::new("lever", "ilold"); - let step = add_solana_step( + let outcome = add_solana_step( &mut session, &program, switch, @@ -76,23 +76,16 @@ fn add_solana_step_runs_switch_power_against_real_program() { ) .expect("add_solana_step"); - assert_eq!(step.function, "switch_power"); - let trace = step.runtime_trace.as_ref().expect("runtime_trace populated"); + let step_index = outcome + .step_index + .expect("Call should succeed and produce a session step"); + assert_eq!(session.steps[step_index].function, "switch_power"); assert!( - trace.get("error").map(|v| v.is_null()).unwrap_or(true), + outcome.trace.error.is_none(), "transaction errored: {:?}", - trace.get("error") + outcome.trace.error, ); - let logs = trace - .get("logs") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); - let joined = logs - .iter() - .filter_map(|v| v.as_str()) - .collect::<Vec<_>>() - .join("\n"); + let joined = outcome.trace.logs.join("\n"); assert!( joined.contains("pulling the power switch"), "expected lever log line, got:\n{joined}" diff --git a/tests/scenarios/13-auditor-walkthrough.sh b/tests/scenarios/13-auditor-walkthrough.sh index 0e026ba..904f4aa 100755 --- a/tests/scenarios/13-auditor-walkthrough.sh +++ b/tests/scenarios/13-auditor-walkthrough.sh @@ -16,25 +16,30 @@ for n in admin pool alice alice_stake bob bob_stake carol carol_stake dave dave_ done echo " PASS users created (10)"; PASS=$((PASS+1)) -# Helper: pretty-print a Call response in REPL style. +# Helper: pretty-print a Call response in REPL style. After T-R48 a failed +# Call returns CallFailed (no step recorded) instead of StepAdded.error. print_call() { local label="$1"; local resp="$2" local key=$(echo "$resp" | jq -r 'keys[0]') - if [ "$key" = "StepAdded" ]; then - local idx=$(echo "$resp" | jq -r '.StepAdded.step_index') - local ix=$(echo "$resp" | jq -r '.StepAdded.instruction') - local cu=$(echo "$resp" | jq -r '.StepAdded.compute_units') - local diffs=$(echo "$resp" | jq -r '.StepAdded.account_diffs_count') - local err=$(echo "$resp" | jq -r '.StepAdded.error // empty') - if [ -n "$err" ]; then - echo " ✗ step $idx [FAILED]: $ix ($cu CU, $diffs diffs) ← $label" + case "$key" in + StepAdded) + local idx=$(echo "$resp" | jq -r '.StepAdded.step_index') + local ix=$(echo "$resp" | jq -r '.StepAdded.instruction') + local cu=$(echo "$resp" | jq -r '.StepAdded.compute_units') + local diffs=$(echo "$resp" | jq -r '.StepAdded.account_diffs_count') + echo " ✓ step $idx [ok]: $ix ($cu CU, $diffs diffs) ← $label" + ;; + CallFailed) + local ix=$(echo "$resp" | jq -r '.CallFailed.instruction') + local cu=$(echo "$resp" | jq -r '.CallFailed.compute_units') + local err=$(echo "$resp" | jq -r '.CallFailed.error') + echo " ✗ FAILED (not recorded): $ix ($cu CU) ← $label" echo " error: $err" - else - echo " ✓ step $idx [ok]: $ix ($cu CU, $diffs diffs) ← $label" - fi - else - echo " ✗ Error response for $label: $(echo "$resp" | jq -c '.')" - fi + ;; + *) + echo " ✗ Error response for $label: $(echo "$resp" | jq -c '.')" + ;; + esac } # ── Path 1 — happy path baseline ─────────────────────────────────────────── @@ -161,15 +166,24 @@ HAS_DECODED=$(echo "$STEP1" | jq -r '.StepDetail.diff_summary[]?.decoded_after | && { echo " PASS step diff carries decoded_after"; PASS=$((PASS+1)); } \ || { echo " FAIL step diff missing decoded_after"; FAIL=$((FAIL+1)); } -# ── Failed-step error field on the response (T-R47 fix) ──────────────────── +# ── Failed call returns CallFailed and does NOT pollute the timeline ─────── echo -echo " -- failed Call carries .StepAdded.error (T-R47 fix) --" +echo " -- failed Call returns CallFailed, no step appended (T-R48 fix) --" post '{"contract":"staking","command":"Clear"}' >/dev/null post '{"contract":"staking","command":{"Call":{"ix":"initialize_pool","args":{"reward_rate":10},"accounts":{"pool":"pool","admin":"admin"},"signers":["pool","admin"]}}}' >/dev/null +LEN_BEFORE=$(post '{"contract":"staking","command":"Session"}' | jq -r '.SessionView.steps | length') FAIL_RESP=$(post '{"contract":"staking","command":{"Call":{"ix":"initialize_pool","args":{"reward_rate":999},"accounts":{"pool":"pool","admin":"admin"},"signers":["pool","admin"]}}}') -ERR=$(echo "$FAIL_RESP" | jq -r '.StepAdded.error // empty') +KEY=$(echo "$FAIL_RESP" | jq -r 'keys[0]') +LEN_AFTER=$(post '{"contract":"staking","command":"Session"}' | jq -r '.SessionView.steps | length') +[ "$KEY" = "CallFailed" ] \ + && { echo " PASS response variant is CallFailed"; PASS=$((PASS+1)); } \ + || { echo " FAIL expected CallFailed got $KEY"; FAIL=$((FAIL+1)); } +[ "$LEN_BEFORE" = "$LEN_AFTER" ] \ + && { echo " PASS session length unchanged ($LEN_BEFORE → $LEN_AFTER)"; PASS=$((PASS+1)); } \ + || { echo " FAIL failed call polluted the timeline ($LEN_BEFORE → $LEN_AFTER)"; FAIL=$((FAIL+1)); } +ERR=$(echo "$FAIL_RESP" | jq -r '.CallFailed.error // empty') [ -n "$ERR" ] \ - && { echo " PASS failed call carries error: $(echo "$ERR" | head -c 60)..."; PASS=$((PASS+1)); } \ - || { echo " FAIL failed call did not carry .StepAdded.error"; FAIL=$((FAIL+1)); } + && { echo " PASS error message present: $(echo "$ERR" | head -c 60)..."; PASS=$((PASS+1)); } \ + || { echo " FAIL CallFailed.error missing"; FAIL=$((FAIL+1)); } scenario_summary "$NAME" diff --git a/tests/scenarios/_lib.sh b/tests/scenarios/_lib.sh index 9e4887d..1cc0359 100755 --- a/tests/scenarios/_lib.sh +++ b/tests/scenarios/_lib.sh @@ -39,9 +39,18 @@ step_failed() { } call_failed_in_logs() { - # $1 is the JSON response from a Call; returns 0 if logs show AnchorError. - echo "$1" | jq -r '.StepAdded.logs_excerpt[]? // empty' \ - | grep -qE 'AnchorError|failed:|panicked' + # $1 is the JSON response from a Call. Solana now distinguishes + # CallFailed (VM rejected, no step recorded) from StepAdded (success). + # We accept either signal. + local key + key=$(echo "$1" | jq -r 'keys[0] // empty') + case "$key" in + CallFailed) return 0 ;; + *) + echo "$1" | jq -r '.StepAdded.logs_excerpt[]? // empty' \ + | grep -qE 'AnchorError|failed:|panicked' + ;; + esac } setup_users() { From 57317e54fe714478fd2d60ad3a7d71a1c4fcba6d Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Sun, 10 May 2026 10:58:49 +0200 Subject: [PATCH 089/115] refactor(solana): extract ProgramView as canonical IDL projection - SDD-04 phase 1: ProgramDef::compute_view returns ProgramView - Migrate execute_funcs, execute_who, execute_pda to read from view - CLI output via /api/cmd stays byte-identical, snapshots verify it - Phase 2 fields left empty for T-R50 to populate --- .../src/exploration/execute.rs | 77 ++-- crates/ilold-solana-core/src/lib.rs | 3 + crates/ilold-solana-core/src/view.rs | 390 ++++++++++++++++++ .../tests/program_view_snapshot.rs | 131 ++++++ .../tests/snapshots/staking_view.json | 210 ++++++++++ .../snapshots/t_r49_pre/lever_funcs.json | 23 ++ .../t_r49_pre/lever_pda_initialize.json | 6 + .../t_r49_pre/lever_pda_switch_power.json | 6 + .../t_r49_pre/relations_pda_init_base.json | 14 + .../relations_pda_test_relation.json | 21 + .../snapshots/t_r49_pre/staking_funcs.json | 53 +++ .../t_r49_pre/staking_pda_add_rewards.json | 6 + .../t_r49_pre/staking_pda_claim_rewards.json | 6 + .../staking_pda_initialize_pool.json | 6 + .../t_r49_pre/staking_pda_stake.json | 6 + .../t_r49_pre/staking_pda_unstake.json | 6 + .../snapshots/t_r49_pre/staking_who_pool.json | 37 ++ .../t_r49_pre/staking_who_user_stake.json | 25 ++ 18 files changed, 987 insertions(+), 39 deletions(-) create mode 100644 crates/ilold-solana-core/src/view.rs create mode 100644 crates/ilold-solana-core/tests/program_view_snapshot.rs create mode 100644 crates/ilold-solana-core/tests/snapshots/staking_view.json create mode 100644 crates/ilold-solana-core/tests/snapshots/t_r49_pre/lever_funcs.json create mode 100644 crates/ilold-solana-core/tests/snapshots/t_r49_pre/lever_pda_initialize.json create mode 100644 crates/ilold-solana-core/tests/snapshots/t_r49_pre/lever_pda_switch_power.json create mode 100644 crates/ilold-solana-core/tests/snapshots/t_r49_pre/relations_pda_init_base.json create mode 100644 crates/ilold-solana-core/tests/snapshots/t_r49_pre/relations_pda_test_relation.json create mode 100644 crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_funcs.json create mode 100644 crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_add_rewards.json create mode 100644 crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_claim_rewards.json create mode 100644 crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_initialize_pool.json create mode 100644 crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_stake.json create mode 100644 crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_unstake.json create mode 100644 crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_who_pool.json create mode 100644 crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_who_user_stake.json diff --git a/crates/ilold-solana-core/src/exploration/execute.rs b/crates/ilold-solana-core/src/exploration/execute.rs index f1f89ca..f2db94c 100644 --- a/crates/ilold-solana-core/src/exploration/execute.rs +++ b/crates/ilold-solana-core/src/exploration/execute.rs @@ -10,7 +10,8 @@ use solana_signer::Signer; use crate::decode::borsh::decode_defined_fields; use crate::execute::VmHost; -use crate::model::{AccountTypeDef, ProgramDef, SeedSpec}; +use crate::model::{AccountTypeDef, ProgramDef}; +use crate::view::SeedView; use super::add_step::add_solana_step; use super::commands::{ @@ -21,7 +22,8 @@ use super::commands::{ const DEFAULT_USER_LAMPORTS: u64 = 10_000_000_000; pub fn execute_funcs(program: &ProgramDef) -> SolanaCommandResult { - let items = program + let view = program.compute_view(); + let items = view .instructions .iter() .map(|ix| InstructionEntry { @@ -125,7 +127,8 @@ pub fn execute_session( } pub fn execute_pda(program: &ProgramDef, instruction: &str) -> SolanaCommandResult { - let ix = match program.instructions.iter().find(|i| i.name == instruction) { + let view = program.compute_view(); + let ix = match view.instructions.iter().find(|i| i.name == instruction) { Some(i) => i, None => { return SolanaCommandResult::Error { @@ -134,22 +137,16 @@ pub fn execute_pda(program: &ProgramDef, instruction: &str) -> SolanaCommandResu } }; + let self_program_id = view.program_id.clone(); let pdas: Vec<PdaEntry> = ix .accounts .iter() - .filter_map(|spec| { - let pda_spec = spec.pda.as_ref()?; - let seeds = pda_spec.seeds.iter().map(describe_seed).collect(); - let prog = match &pda_spec.program { - None => program.program_id.to_string(), - Some(SeedSpec::Const { value }) => Address::try_from(value.as_slice()) - .map(|a| a.to_string()) - .unwrap_or_else(|_| format!("const:{:02x?}", value)), - Some(SeedSpec::Account { path }) => format!("account:{path}"), - Some(SeedSpec::Arg { path, .. }) => format!("arg:{path}"), - }; + .filter_map(|acc| { + let pda = acc.pda.as_ref()?; + let seeds = pda.seeds.iter().map(describe_seed_view).collect(); + let prog = pda.program.clone().unwrap_or_else(|| self_program_id.clone()); Some(PdaEntry { - account_name: spec.name.clone(), + account_name: acc.name.clone(), seeds, program: prog, }) @@ -497,15 +494,32 @@ pub fn execute_status( SolanaCommandResult::StatusUpdated } -fn describe_seed(seed: &SeedSpec) -> String { +fn describe_seed_view(seed: &SeedView) -> String { match seed { - SeedSpec::Const { value } => match std::str::from_utf8(value) { - Ok(s) if s.chars().all(|c| c.is_ascii_graphic() || c == ' ') => format!("const:'{s}'"), - _ => format!("const:{:02x?}", value), + SeedView::Const { value_hex, value_utf8 } => match value_utf8 { + Some(s) => format!("const:'{s}'"), + None => { + let bytes = hex_to_bytes(value_hex); + format!("const:{:02x?}", bytes) + } }, - SeedSpec::Account { path } => format!("account:{path}"), - SeedSpec::Arg { path, .. } => format!("arg:{path}"), + SeedView::Account { path } => format!("account:{path}"), + SeedView::Arg { name, .. } => format!("arg:{name}"), + } +} + +fn hex_to_bytes(value_hex: &str) -> Vec<u8> { + let stripped = value_hex.strip_prefix("0x").unwrap_or(value_hex); + let mut out = Vec::with_capacity(stripped.len() / 2); + let bytes = stripped.as_bytes(); + let mut i = 0; + while i + 1 < bytes.len() { + let hi = (bytes[i] as char).to_digit(16).unwrap_or(0) as u8; + let lo = (bytes[i + 1] as char).to_digit(16).unwrap_or(0) as u8; + out.push((hi << 4) | lo); + i += 2; } + out } fn decode_account_bytes( @@ -667,27 +681,12 @@ pub fn execute_who( program: &ProgramDef, account_type: &str, ) -> SolanaCommandResult { - // Heuristic: an account field name maps to its type by snake_case → PascalCase - // (e.g. `pool` → `Pool`, `user_stake` → `UserStake`). Anchor IDL doesn't - // carry the explicit type-of-account in the instruction shape, so we - // approximate by name match. False positives possible if naming diverges. - fn snake_to_pascal(s: &str) -> String { - s.split('_') - .filter(|p| !p.is_empty()) - .map(|p| { - let mut c = p.chars(); - match c.next() { - None => String::new(), - Some(f) => f.to_uppercase().collect::<String>() + c.as_str(), - } - }) - .collect() - } + let view = program.compute_view(); let target = account_type.to_string(); let mut hits: Vec<WhoEntry> = Vec::new(); - for ix in &program.instructions { + for ix in &view.instructions { for acc in &ix.accounts { - if snake_to_pascal(&acc.name) == target || acc.name == target { + if crate::view::snake_to_pascal(&acc.name) == target || acc.name == target { hits.push(WhoEntry { instruction: ix.name.clone(), account_field: acc.name.clone(), diff --git a/crates/ilold-solana-core/src/lib.rs b/crates/ilold-solana-core/src/lib.rs index 137cf87..6c72676 100644 --- a/crates/ilold-solana-core/src/lib.rs +++ b/crates/ilold-solana-core/src/lib.rs @@ -6,3 +6,6 @@ pub mod exploration; pub mod idl; pub mod ingest; pub mod model; +pub mod view; + +pub use view::ProgramView; diff --git a/crates/ilold-solana-core/src/view.rs b/crates/ilold-solana-core/src/view.rs new file mode 100644 index 0000000..1558e5c --- /dev/null +++ b/crates/ilold-solana-core/src/view.rs @@ -0,0 +1,390 @@ +use std::collections::HashSet; + +use serde::{Deserialize, Serialize}; + +use crate::model::{AccountSpec, AccountTypeDef, PdaSpec, ProgramDef, SeedSpec}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProgramView { + pub name: String, + pub program_id: String, + pub instructions: Vec<IxView>, + pub accounts: Vec<AccountView>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_coupling: Option<Vec<CouplingPair>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub admin_gated: Option<HashSet<String>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub system_accounts: Option<HashSet<String>>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IxView { + pub name: String, + pub discriminator_hex: String, + pub args: Vec<ArgView>, + pub accounts: Vec<IxAccountView>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub returns: Option<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArgView { + pub name: String, + pub ty: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct IxAccountView { + pub path: String, + pub name: String, + pub kind: AccountKind, + pub writable: bool, + pub signer: bool, + pub optional: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub address: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pda: Option<PdaView>, + #[serde(default)] + pub relations: Vec<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AccountKind { + Program, + System, + Sysvar, + Pda, + Other, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PdaView { + pub seeds: Vec<SeedView>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub program: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub bump_arg: Option<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum SeedView { + Const { + value_hex: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + value_utf8: Option<String>, + }, + Arg { + name: String, + ty: String, + }, + Account { + path: String, + }, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AccountView { + pub name: String, + pub discriminator_hex: String, + pub fields: Vec<FieldView>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct FieldView { + pub name: String, + pub ty: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CouplingPair { + pub a: String, + pub b: String, + pub shared_writable: Vec<String>, +} + +const SYSTEM_PROGRAM_NAMES: &[&str] = &[ + "system_program", + "token_program", + "associated_token_program", + "token_program_2022", +]; + +const SYSVAR_NAMES: &[&str] = &[ + "rent", + "clock", + "instructions", + "recent_blockhashes", + "slot_history", + "stake_history", + "epoch_schedule", + "fees", +]; + +impl ProgramDef { + pub fn compute_view(&self) -> ProgramView { + let instructions = self + .instructions + .iter() + .map(|ix| build_ix_view(ix, &self.account_types)) + .collect(); + let accounts = self + .account_types + .iter() + .map(build_account_view) + .collect(); + ProgramView { + name: self.name.clone(), + program_id: self.program_id.to_string(), + instructions, + accounts, + state_coupling: None, + admin_gated: None, + system_accounts: None, + } + } +} + +fn build_ix_view( + ix: &crate::model::InstructionDef, + account_types: &[AccountTypeDef], +) -> IxView { + let args = ix + .args + .iter() + .map(|a| ArgView { + name: a.name.clone(), + ty: String::new(), + }) + .collect(); + let accounts = ix + .accounts + .iter() + .map(|spec| build_ix_account_view(spec, account_types)) + .collect(); + IxView { + name: ix.name.clone(), + discriminator_hex: String::new(), + args, + accounts, + returns: None, + } +} + +fn build_ix_account_view( + spec: &AccountSpec, + account_types: &[AccountTypeDef], +) -> IxAccountView { + let pda = spec.pda.as_ref().map(build_pda_view); + let kind = classify_account_kind(&spec.name, &pda, account_types); + IxAccountView { + path: spec.path.clone(), + name: spec.name.clone(), + kind, + writable: spec.writable, + signer: spec.signer, + optional: spec.optional, + address: spec.address.as_ref().map(|a| a.to_string()), + pda, + relations: spec.relations.clone(), + } +} + +fn build_pda_view(pda: &PdaSpec) -> PdaView { + let seeds = pda.seeds.iter().map(seed_to_view).collect(); + let program = pda.program.as_ref().map(seed_program_to_string); + PdaView { + seeds, + program, + bump_arg: pda.bump_arg.clone(), + } +} + +fn seed_to_view(seed: &SeedSpec) -> SeedView { + match seed { + SeedSpec::Const { value } => { + let value_hex = bytes_to_hex(value); + let value_utf8 = bytes_to_ascii_graphic(value); + SeedView::Const { + value_hex, + value_utf8, + } + } + SeedSpec::Arg { path, .. } => SeedView::Arg { + name: path.clone(), + ty: String::new(), + }, + SeedSpec::Account { path } => SeedView::Account { path: path.clone() }, + } +} + +fn seed_program_to_string(seed: &SeedSpec) -> String { + match seed { + SeedSpec::Const { value } => match solana_address::Address::try_from(value.as_slice()) { + Ok(a) => a.to_string(), + Err(_) => format!("const:{:02x?}", value), + }, + SeedSpec::Account { path } => format!("account:{path}"), + SeedSpec::Arg { path, .. } => format!("arg:{path}"), + } +} + +fn build_account_view(account: &AccountTypeDef) -> AccountView { + AccountView { + name: account.name.clone(), + discriminator_hex: String::new(), + fields: Vec::new(), + } +} + +pub(crate) fn snake_to_pascal(s: &str) -> String { + s.split('_') + .filter(|p| !p.is_empty()) + .map(|p| { + let mut c = p.chars(); + match c.next() { + None => String::new(), + Some(f) => f.to_uppercase().collect::<String>() + c.as_str(), + } + }) + .collect() +} + +fn classify_account_kind( + name: &str, + pda: &Option<PdaView>, + account_types: &[AccountTypeDef], +) -> AccountKind { + if pda.is_some() { + return AccountKind::Pda; + } + if SYSTEM_PROGRAM_NAMES.contains(&name) { + return AccountKind::System; + } + if SYSVAR_NAMES.contains(&name) || name.starts_with("sysvar_") { + return AccountKind::Sysvar; + } + let pascal = snake_to_pascal(name); + if account_types.iter().any(|a| a.name == pascal) { + return AccountKind::Program; + } + AccountKind::Other +} + +fn bytes_to_hex(bytes: &[u8]) -> String { + let mut s = String::with_capacity(2 + bytes.len() * 2); + s.push_str("0x"); + for b in bytes { + s.push_str(&format!("{:02x}", b)); + } + s +} + +fn bytes_to_ascii_graphic(bytes: &[u8]) -> Option<String> { + let s = std::str::from_utf8(bytes).ok()?; + if s.chars().all(|c| c.is_ascii_graphic() || c == ' ') { + Some(s.to_string()) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::idl::parse_idl; + + const LEVER_JSON: &str = include_str!("../tests/fixtures/lever.json"); + const RELATIONS_JSON: &str = include_str!("../tests/fixtures/relations.json"); + + fn lever_program() -> ProgramDef { + ProgramDef::from_idl(parse_idl(LEVER_JSON).expect("parse lever")) + .expect("build lever ProgramDef") + } + + fn relations_program() -> ProgramDef { + ProgramDef::from_idl(parse_idl(RELATIONS_JSON).expect("parse relations")) + .expect("build relations ProgramDef") + } + + #[test] + fn compute_view_has_all_instructions() { + let view = lever_program().compute_view(); + assert_eq!(view.instructions.len(), 2); + let names: Vec<_> = view.instructions.iter().map(|i| i.name.as_str()).collect(); + assert!(names.contains(&"initialize")); + assert!(names.contains(&"switch_power")); + } + + #[test] + fn compute_view_pool_struct_has_real_fields() { + // Phase 1 placeholder: AccountView::fields stays empty until T-R50. + // The shape is asserted; populated content lands in Phase 2. + let view = lever_program().compute_view(); + let power = view + .accounts + .iter() + .find(|a| a.name == "PowerStatus") + .expect("PowerStatus account-type present"); + assert!(power.fields.is_empty()); + assert_eq!(power.discriminator_hex, ""); + } + + #[test] + fn account_kind_classification() { + let view = lever_program().compute_view(); + let initialize = view.instructions.iter().find(|i| i.name == "initialize").unwrap(); + let system = initialize.accounts.iter().find(|a| a.name == "system_program").unwrap(); + assert_eq!(system.kind, AccountKind::System); + // lever's "power" maps to account-type "PowerStatus" — snake_to_pascal + // gives "Power" which does not match, so it falls through to Other. + let power = initialize.accounts.iter().find(|a| a.name == "power").unwrap(); + assert_eq!(power.kind, AccountKind::Other); + + let relations = relations_program().compute_view(); + let init_base = relations.instructions.iter().find(|i| i.name == "init_base").unwrap(); + let pda_acc = init_base.accounts.iter().find(|a| a.name == "account").unwrap(); + assert_eq!(pda_acc.kind, AccountKind::Pda); + // relations also has my_account → MyAccount (snake→pascal match). + let test_relation = relations.instructions.iter().find(|i| i.name == "test_relation").unwrap(); + let typed_acc = test_relation.accounts.iter().find(|a| a.name == "my_account").unwrap(); + assert_eq!(typed_acc.kind, AccountKind::Program); + } + + #[test] + fn seed_const_value_utf8_only_when_ascii_graphic() { + let printable = SeedSpec::Const { + value: b"stake".to_vec(), + }; + match seed_to_view(&printable) { + SeedView::Const { value_hex, value_utf8 } => { + assert_eq!(value_hex, "0x7374616b65"); + assert_eq!(value_utf8.as_deref(), Some("stake")); + } + other => panic!("expected Const, got {other:?}"), + } + + // Tab + LF — both ASCII but neither is_ascii_graphic and neither is space. + let non_graphic = SeedSpec::Const { + value: vec![0x09, 0x0a], + }; + match seed_to_view(&non_graphic) { + SeedView::Const { value_hex, value_utf8 } => { + assert_eq!(value_hex, "0x090a"); + assert!(value_utf8.is_none()); + } + other => panic!("expected Const, got {other:?}"), + } + } + + #[test] + fn snake_to_pascal_handles_basic_cases() { + assert_eq!(snake_to_pascal("pool"), "Pool"); + assert_eq!(snake_to_pascal("user_stake"), "UserStake"); + assert_eq!(snake_to_pascal(""), ""); + assert_eq!(snake_to_pascal("__double"), "Double"); + } +} diff --git a/crates/ilold-solana-core/tests/program_view_snapshot.rs b/crates/ilold-solana-core/tests/program_view_snapshot.rs new file mode 100644 index 0000000..8f16900 --- /dev/null +++ b/crates/ilold-solana-core/tests/program_view_snapshot.rs @@ -0,0 +1,131 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use ilold_solana_core::exploration::{ + execute_funcs, execute_pda, execute_who, SolanaCommandResult, +}; +use ilold_solana_core::idl::parse_idl; +use ilold_solana_core::model::ProgramDef; + +const LEVER_JSON: &str = include_str!("fixtures/lever.json"); +const RELATIONS_JSON: &str = include_str!("fixtures/relations.json"); +const STAKING_JSON: &str = include_str!("../../../tests/fixtures/solana/staking/idls/staking.json"); + +fn snapshot_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("snapshots") + .join("t_r49_pre") +} + +fn regen_enabled() -> bool { + std::env::var("ILOLD_REGEN_SNAPSHOTS").is_ok() +} + +fn assert_snapshot(name: &str, value: &SolanaCommandResult) { + let json = serde_json::to_string_pretty(value).expect("serialize result"); + let path = snapshot_root().join(format!("{name}.json")); + if regen_enabled() { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, format!("{json}\n")).unwrap(); + return; + } + let expected = fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("missing snapshot {}: {e}", path.display())); + let expected_trimmed = expected.trim_end_matches('\n'); + let json_trimmed = json.trim_end_matches('\n'); + assert_eq!( + json_trimmed, expected_trimmed, + "snapshot drift in {name}\nstored at {}", + path.display() + ); +} + +fn staking_program() -> ProgramDef { + ProgramDef::from_idl(parse_idl(STAKING_JSON).expect("parse staking")) + .expect("build staking ProgramDef") +} + +fn lever_program() -> ProgramDef { + ProgramDef::from_idl(parse_idl(LEVER_JSON).expect("parse lever")) + .expect("build lever ProgramDef") +} + +fn relations_program() -> ProgramDef { + ProgramDef::from_idl(parse_idl(RELATIONS_JSON).expect("parse relations")) + .expect("build relations ProgramDef") +} + +#[test] +fn snapshot_funcs_staking() { + let program = staking_program(); + assert_snapshot("staking_funcs", &execute_funcs(&program)); +} + +#[test] +fn snapshot_funcs_lever() { + let program = lever_program(); + assert_snapshot("lever_funcs", &execute_funcs(&program)); +} + +#[test] +fn snapshot_who_pool() { + let program = staking_program(); + assert_snapshot("staking_who_pool", &execute_who(&program, "Pool")); +} + +#[test] +fn snapshot_who_user_stake() { + let program = staking_program(); + assert_snapshot("staking_who_user_stake", &execute_who(&program, "UserStake")); +} + +#[test] +fn snapshot_pda_staking_per_ix() { + let program = staking_program(); + for ix in &program.instructions { + let result = execute_pda(&program, &ix.name); + assert_snapshot(&format!("staking_pda_{}", ix.name), &result); + } +} + +#[test] +fn snapshot_pda_lever_per_ix() { + let program = lever_program(); + for ix in &program.instructions { + let result = execute_pda(&program, &ix.name); + assert_snapshot(&format!("lever_pda_{}", ix.name), &result); + } +} + +#[test] +fn snapshot_pda_relations_per_ix() { + let program = relations_program(); + for ix in &program.instructions { + let result = execute_pda(&program, &ix.name); + assert_snapshot(&format!("relations_pda_{}", ix.name), &result); + } +} + +#[test] +fn program_view_wire_format_staking() { + let view = staking_program().compute_view(); + let json = serde_json::to_string_pretty(&view).expect("serialize ProgramView"); + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("snapshots") + .join("staking_view.json"); + if regen_enabled() { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, format!("{json}\n")).unwrap(); + return; + } + let expected = fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("missing wire-format snapshot {}: {e}", path.display())); + let expected_trimmed = expected.trim_end_matches('\n'); + let json_trimmed = json.trim_end_matches('\n'); + assert_eq!( + json_trimmed, expected_trimmed, + "ProgramView wire-format drift; if intentional, regen with ILOLD_REGEN_SNAPSHOTS=1" + ); +} diff --git a/crates/ilold-solana-core/tests/snapshots/staking_view.json b/crates/ilold-solana-core/tests/snapshots/staking_view.json new file mode 100644 index 0000000..a18efb5 --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/staking_view.json @@ -0,0 +1,210 @@ +{ + "name": "staking", + "program_id": "AQjgX8bsAU8CvvEoP9q36vAbT83dRxdjK4zGEhhn6SFc", + "instructions": [ + { + "name": "add_rewards", + "discriminator_hex": "", + "args": [ + { + "name": "amount", + "ty": "" + } + ], + "accounts": [ + { + "path": "pool", + "name": "pool", + "kind": "program", + "writable": true, + "signer": false, + "optional": false, + "relations": [] + }, + { + "path": "admin", + "name": "admin", + "kind": "other", + "writable": false, + "signer": true, + "optional": false, + "relations": [] + } + ] + }, + { + "name": "claim_rewards", + "discriminator_hex": "", + "args": [], + "accounts": [ + { + "path": "pool", + "name": "pool", + "kind": "program", + "writable": true, + "signer": false, + "optional": false, + "relations": [] + }, + { + "path": "user_stake", + "name": "user_stake", + "kind": "program", + "writable": true, + "signer": false, + "optional": false, + "relations": [] + }, + { + "path": "user", + "name": "user", + "kind": "other", + "writable": false, + "signer": true, + "optional": false, + "relations": [] + } + ] + }, + { + "name": "initialize_pool", + "discriminator_hex": "", + "args": [ + { + "name": "reward_rate", + "ty": "" + } + ], + "accounts": [ + { + "path": "pool", + "name": "pool", + "kind": "program", + "writable": true, + "signer": true, + "optional": false, + "relations": [] + }, + { + "path": "admin", + "name": "admin", + "kind": "other", + "writable": true, + "signer": true, + "optional": false, + "relations": [] + }, + { + "path": "system_program", + "name": "system_program", + "kind": "system", + "writable": false, + "signer": false, + "optional": false, + "address": "11111111111111111111111111111111", + "relations": [] + } + ] + }, + { + "name": "stake", + "discriminator_hex": "", + "args": [ + { + "name": "amount", + "ty": "" + } + ], + "accounts": [ + { + "path": "pool", + "name": "pool", + "kind": "program", + "writable": true, + "signer": false, + "optional": false, + "relations": [] + }, + { + "path": "user_stake", + "name": "user_stake", + "kind": "program", + "writable": true, + "signer": true, + "optional": false, + "relations": [] + }, + { + "path": "user", + "name": "user", + "kind": "other", + "writable": true, + "signer": true, + "optional": false, + "relations": [] + }, + { + "path": "system_program", + "name": "system_program", + "kind": "system", + "writable": false, + "signer": false, + "optional": false, + "address": "11111111111111111111111111111111", + "relations": [] + } + ] + }, + { + "name": "unstake", + "discriminator_hex": "", + "args": [ + { + "name": "amount", + "ty": "" + } + ], + "accounts": [ + { + "path": "pool", + "name": "pool", + "kind": "program", + "writable": true, + "signer": false, + "optional": false, + "relations": [] + }, + { + "path": "user_stake", + "name": "user_stake", + "kind": "program", + "writable": true, + "signer": false, + "optional": false, + "relations": [] + }, + { + "path": "user", + "name": "user", + "kind": "other", + "writable": false, + "signer": true, + "optional": false, + "relations": [] + } + ] + } + ], + "accounts": [ + { + "name": "Pool", + "discriminator_hex": "", + "fields": [] + }, + { + "name": "UserStake", + "discriminator_hex": "", + "fields": [] + } + ] +} diff --git a/crates/ilold-solana-core/tests/snapshots/t_r49_pre/lever_funcs.json b/crates/ilold-solana-core/tests/snapshots/t_r49_pre/lever_funcs.json new file mode 100644 index 0000000..99fdb7b --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/t_r49_pre/lever_funcs.json @@ -0,0 +1,23 @@ +{ + "InstructionList": { + "items": [ + { + "name": "initialize", + "args_count": 0, + "accounts_count": 3, + "has_pdas": false, + "signers": [ + "power", + "user" + ] + }, + { + "name": "switch_power", + "args_count": 1, + "accounts_count": 1, + "has_pdas": false, + "signers": [] + } + ] + } +} diff --git a/crates/ilold-solana-core/tests/snapshots/t_r49_pre/lever_pda_initialize.json b/crates/ilold-solana-core/tests/snapshots/t_r49_pre/lever_pda_initialize.json new file mode 100644 index 0000000..a93d209 --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/t_r49_pre/lever_pda_initialize.json @@ -0,0 +1,6 @@ +{ + "PdaList": { + "instruction": "initialize", + "pdas": [] + } +} diff --git a/crates/ilold-solana-core/tests/snapshots/t_r49_pre/lever_pda_switch_power.json b/crates/ilold-solana-core/tests/snapshots/t_r49_pre/lever_pda_switch_power.json new file mode 100644 index 0000000..b73fca5 --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/t_r49_pre/lever_pda_switch_power.json @@ -0,0 +1,6 @@ +{ + "PdaList": { + "instruction": "switch_power", + "pdas": [] + } +} diff --git a/crates/ilold-solana-core/tests/snapshots/t_r49_pre/relations_pda_init_base.json b/crates/ilold-solana-core/tests/snapshots/t_r49_pre/relations_pda_init_base.json new file mode 100644 index 0000000..a2cf4ab --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/t_r49_pre/relations_pda_init_base.json @@ -0,0 +1,14 @@ +{ + "PdaList": { + "instruction": "init_base", + "pdas": [ + { + "account_name": "account", + "seeds": [ + "const:'seed'" + ], + "program": "Re1ationsDerivation111111111111111111111111" + } + ] + } +} diff --git a/crates/ilold-solana-core/tests/snapshots/t_r49_pre/relations_pda_test_relation.json b/crates/ilold-solana-core/tests/snapshots/t_r49_pre/relations_pda_test_relation.json new file mode 100644 index 0000000..965f88c --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/t_r49_pre/relations_pda_test_relation.json @@ -0,0 +1,21 @@ +{ + "PdaList": { + "instruction": "test_relation", + "pdas": [ + { + "account_name": "account", + "seeds": [ + "const:'seed'" + ], + "program": "Re1ationsDerivation111111111111111111111111" + }, + { + "account_name": "account", + "seeds": [ + "const:'seed'" + ], + "program": "Re1ationsDerivation111111111111111111111111" + } + ] + } +} diff --git a/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_funcs.json b/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_funcs.json new file mode 100644 index 0000000..8448ec7 --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_funcs.json @@ -0,0 +1,53 @@ +{ + "InstructionList": { + "items": [ + { + "name": "add_rewards", + "args_count": 1, + "accounts_count": 2, + "has_pdas": false, + "signers": [ + "admin" + ] + }, + { + "name": "claim_rewards", + "args_count": 0, + "accounts_count": 3, + "has_pdas": false, + "signers": [ + "user" + ] + }, + { + "name": "initialize_pool", + "args_count": 1, + "accounts_count": 3, + "has_pdas": false, + "signers": [ + "pool", + "admin" + ] + }, + { + "name": "stake", + "args_count": 1, + "accounts_count": 4, + "has_pdas": false, + "signers": [ + "user_stake", + "user" + ] + }, + { + "name": "unstake", + "args_count": 1, + "accounts_count": 3, + "has_pdas": false, + "signers": [ + "user" + ] + } + ] + } +} diff --git a/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_add_rewards.json b/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_add_rewards.json new file mode 100644 index 0000000..0178486 --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_add_rewards.json @@ -0,0 +1,6 @@ +{ + "PdaList": { + "instruction": "add_rewards", + "pdas": [] + } +} diff --git a/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_claim_rewards.json b/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_claim_rewards.json new file mode 100644 index 0000000..f9cebc6 --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_claim_rewards.json @@ -0,0 +1,6 @@ +{ + "PdaList": { + "instruction": "claim_rewards", + "pdas": [] + } +} diff --git a/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_initialize_pool.json b/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_initialize_pool.json new file mode 100644 index 0000000..96a7871 --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_initialize_pool.json @@ -0,0 +1,6 @@ +{ + "PdaList": { + "instruction": "initialize_pool", + "pdas": [] + } +} diff --git a/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_stake.json b/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_stake.json new file mode 100644 index 0000000..2555a96 --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_stake.json @@ -0,0 +1,6 @@ +{ + "PdaList": { + "instruction": "stake", + "pdas": [] + } +} diff --git a/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_unstake.json b/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_unstake.json new file mode 100644 index 0000000..50fc3b4 --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_unstake.json @@ -0,0 +1,6 @@ +{ + "PdaList": { + "instruction": "unstake", + "pdas": [] + } +} diff --git a/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_who_pool.json b/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_who_pool.json new file mode 100644 index 0000000..1016c7e --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_who_pool.json @@ -0,0 +1,37 @@ +{ + "WhoList": { + "account_type": "Pool", + "instructions": [ + { + "instruction": "add_rewards", + "account_field": "pool", + "writable": true, + "signer": false + }, + { + "instruction": "claim_rewards", + "account_field": "pool", + "writable": true, + "signer": false + }, + { + "instruction": "initialize_pool", + "account_field": "pool", + "writable": true, + "signer": true + }, + { + "instruction": "stake", + "account_field": "pool", + "writable": true, + "signer": false + }, + { + "instruction": "unstake", + "account_field": "pool", + "writable": true, + "signer": false + } + ] + } +} diff --git a/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_who_user_stake.json b/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_who_user_stake.json new file mode 100644 index 0000000..4cbbbe3 --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_who_user_stake.json @@ -0,0 +1,25 @@ +{ + "WhoList": { + "account_type": "UserStake", + "instructions": [ + { + "instruction": "claim_rewards", + "account_field": "user_stake", + "writable": true, + "signer": false + }, + { + "instruction": "stake", + "account_field": "user_stake", + "writable": true, + "signer": true + }, + { + "instruction": "unstake", + "account_field": "user_stake", + "writable": true, + "signer": false + } + ] + } +} From f9b2f858a27b83f8d9c1b25e1888eb90874e2f8b Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Sun, 10 May 2026 11:51:52 +0200 Subject: [PATCH 090/115] feat(solana): enrich ProgramView with types, discriminators, coupling - Add format_idl_type helper for IdlType stringification - Populate discriminator_hex on IxView and AccountView - Populate ArgView.ty, AccountView.fields, SeedView::Arg.ty via format_idl_type - Compute state_coupling, admin_gated, system_accounts heuristics - Move describe_seed_view + hex_to_bytes from execute.rs to view.rs - New SolanaCommand variants Info/Coupling/Vars with typed handlers - CLI: info / coupling / funcs-all / vars now dispatch via /api/cmd - Remove print_solana_ix_info, print_solana_funcs_all, print_solana_vars, idl_type_label - Rename baseline snapshots dir to funcs_who_pda_baseline - Regenerate staking_view.json wire snapshot consciously - New integration tests for Info, Coupling, Vars over /api/cmd --- crates/ilold-cli/src/explore.rs | 432 ++++++++---------- .../src/exploration/commands.rs | 23 + .../src/exploration/execute.rs | 62 +-- .../ilold-solana-core/src/exploration/mod.rs | 9 +- crates/ilold-solana-core/src/view.rs | 431 +++++++++++++++-- .../tests/program_view_snapshot.rs | 2 +- .../lever_funcs.json | 0 .../lever_pda_initialize.json | 0 .../lever_pda_switch_power.json | 0 .../relations_pda_init_base.json | 0 .../relations_pda_test_relation.json | 0 .../staking_funcs.json | 0 .../staking_pda_add_rewards.json | 0 .../staking_pda_claim_rewards.json | 0 .../staking_pda_initialize_pool.json | 0 .../staking_pda_stake.json | 0 .../staking_pda_unstake.json | 0 .../staking_who_pool.json | 0 .../staking_who_user_stake.json | 0 .../tests/snapshots/staking_view.json | 138 +++++- crates/ilold-web/src/api/session.rs | 12 +- .../tests/solana_command_pipeline.rs | 80 ++++ 22 files changed, 867 insertions(+), 322 deletions(-) rename crates/ilold-solana-core/tests/snapshots/{t_r49_pre => funcs_who_pda_baseline}/lever_funcs.json (100%) rename crates/ilold-solana-core/tests/snapshots/{t_r49_pre => funcs_who_pda_baseline}/lever_pda_initialize.json (100%) rename crates/ilold-solana-core/tests/snapshots/{t_r49_pre => funcs_who_pda_baseline}/lever_pda_switch_power.json (100%) rename crates/ilold-solana-core/tests/snapshots/{t_r49_pre => funcs_who_pda_baseline}/relations_pda_init_base.json (100%) rename crates/ilold-solana-core/tests/snapshots/{t_r49_pre => funcs_who_pda_baseline}/relations_pda_test_relation.json (100%) rename crates/ilold-solana-core/tests/snapshots/{t_r49_pre => funcs_who_pda_baseline}/staking_funcs.json (100%) rename crates/ilold-solana-core/tests/snapshots/{t_r49_pre => funcs_who_pda_baseline}/staking_pda_add_rewards.json (100%) rename crates/ilold-solana-core/tests/snapshots/{t_r49_pre => funcs_who_pda_baseline}/staking_pda_claim_rewards.json (100%) rename crates/ilold-solana-core/tests/snapshots/{t_r49_pre => funcs_who_pda_baseline}/staking_pda_initialize_pool.json (100%) rename crates/ilold-solana-core/tests/snapshots/{t_r49_pre => funcs_who_pda_baseline}/staking_pda_stake.json (100%) rename crates/ilold-solana-core/tests/snapshots/{t_r49_pre => funcs_who_pda_baseline}/staking_pda_unstake.json (100%) rename crates/ilold-solana-core/tests/snapshots/{t_r49_pre => funcs_who_pda_baseline}/staking_who_pool.json (100%) rename crates/ilold-solana-core/tests/snapshots/{t_r49_pre => funcs_who_pda_baseline}/staking_who_user_stake.json (100%) diff --git a/crates/ilold-cli/src/explore.rs b/crates/ilold-cli/src/explore.rs index b94e8ba..30de105 100644 --- a/crates/ilold-cli/src/explore.rs +++ b/crates/ilold-cli/src/explore.rs @@ -1149,32 +1149,41 @@ fn handle_solana_input( steps, ) } - "funcs-all" | "fa" => { - match fetch_program_detail(handle, client, base_url, contract) { - Ok(p) => print_solana_funcs_all(&p), - Err(e) => eprintln!(" {}", c_danger(&format!("fetch program: {e}"))), - } - InputResult::Continue - } + "funcs-all" | "fa" => dispatch_solana( + handle, + client, + base_url, + contract, + serde_json::json!("Funcs"), + steps, + ), "info" | "i" => { if arg.is_empty() { println!(" Usage: info <instruction>"); return InputResult::Continue; } - match fetch_program_detail(handle, client, base_url, contract) { - Ok(p) => print_solana_ix_info(&p, arg), - Err(e) => eprintln!(" {}", c_danger(&format!("fetch program: {e}"))), - } - InputResult::Continue - } - "vars" | "v" | "vars-all" | "va" => { - let verbose = matches!(cmd.as_str(), "vars-all" | "va"); - match fetch_program_detail(handle, client, base_url, contract) { - Ok(p) => print_solana_vars(&p, verbose), - Err(e) => eprintln!(" {}", c_danger(&format!("fetch program: {e}"))), - } - InputResult::Continue + let body = serde_json::json!({"Info": {"ix": arg}}); + dispatch_solana(handle, client, base_url, contract, body, steps) } + // `vars-all` historically toggled per-field detail. With the typed + // backend the verbose form is the only sensible one (fields ship in + // the wire format), so both aliases fall through to the same dispatch. + "vars" | "v" | "vars-all" | "va" => dispatch_solana( + handle, + client, + base_url, + contract, + serde_json::json!("Vars"), + steps, + ), + "coupling" | "cp" => dispatch_solana( + handle, + client, + base_url, + contract, + serde_json::json!("Coupling"), + steps, + ), "state" => dispatch_solana( handle, client, @@ -2465,6 +2474,15 @@ fn print_solana_result(result: &SolanaCommandResult) { } } } + SolanaCommandResult::IxInfo { ix, admin_gated } => { + print_ix_info(ix, *admin_gated); + } + SolanaCommandResult::CouplingList { pairs } => { + print_coupling_list(pairs); + } + SolanaCommandResult::AccountTypes { accounts } => { + print_account_types(accounts); + } SolanaCommandResult::Error { message } => { eprintln!(" {} {}", c_danger("✗"), message); } @@ -2472,6 +2490,164 @@ fn print_solana_result(result: &SolanaCommandResult) { println!(); } +fn print_ix_info(ix: &ilold_solana_core::view::IxView, admin_gated: bool) { + println!(); + println!(" {} {}", c_accent("instruction"), ix.name); + if !ix.discriminator_hex.is_empty() { + println!(" {} {}", c_muted("discriminator"), ix.discriminator_hex); + } + println!(); + println!(" {} ({})", c_accent("args"), ix.args.len()); + if ix.args.is_empty() { + println!(" {}", c_muted("(none)")); + } else { + let max = ix + .args + .iter() + .map(|a| a.name.chars().count()) + .max() + .unwrap_or(0); + for a in &ix.args { + println!( + " {} {} {}", + c_accent("·"), + fmt::pad_right(&a.name, max), + c_muted(&a.ty) + ); + } + } + println!(); + println!(" {} ({})", c_accent("accounts"), ix.accounts.len()); + if ix.accounts.is_empty() { + println!(" {}", c_muted("(none)")); + } else { + let max = ix + .accounts + .iter() + .map(|a| a.name.chars().count()) + .max() + .unwrap_or(0); + for a in &ix.accounts { + let mut flags: Vec<&str> = Vec::new(); + if a.signer { + flags.push("signer"); + } + if a.writable { + flags.push("writable"); + } + if a.optional { + flags.push("optional"); + } + let kind_label = match a.kind { + ilold_solana_core::view::AccountKind::Program => "program", + ilold_solana_core::view::AccountKind::System => "system", + ilold_solana_core::view::AccountKind::Sysvar => "sysvar", + ilold_solana_core::view::AccountKind::Pda => "pda", + ilold_solana_core::view::AccountKind::Other => "other", + }; + let suffix = if let Some(addr) = a.address.as_deref() { + format!("{kind_label} const {addr}") + } else if flags.is_empty() { + kind_label.to_string() + } else { + format!("{kind_label} {}", flags.join(" ")) + }; + println!( + " {} {} {}", + c_accent("·"), + fmt::pad_right(&a.name, max), + c_muted(&suffix) + ); + } + } + let pdas: Vec<&ilold_solana_core::view::IxAccountView> = + ix.accounts.iter().filter(|a| a.pda.is_some()).collect(); + if !pdas.is_empty() { + println!(); + println!(" {} ({})", c_accent("pdas"), pdas.len()); + for acc in pdas { + let pda = acc.pda.as_ref().expect("filtered above"); + let seeds: Vec<String> = pda + .seeds + .iter() + .map(ilold_solana_core::view::describe_seed_view) + .collect(); + let prog = pda + .program + .clone() + .unwrap_or_else(|| "self".to_string()); + println!( + " {} {} seeds=[{}] program={}", + c_accent("·"), + acc.name, + seeds.join(", "), + c_muted(&prog) + ); + } + } + println!(); + let gated_label = if admin_gated { + c_warn("true (heuristic)").to_string() + } else { + c_muted("false").to_string() + }; + println!(" {} {}", c_muted("admin_gated"), gated_label); +} + +fn print_coupling_list(pairs: &[ilold_solana_core::view::CouplingPair]) { + if pairs.is_empty() { + println!(" {}", c_muted("no instruction pairs share writable accounts")); + return; + } + let max = pairs + .iter() + .map(|p| p.a.chars().count() + p.b.chars().count() + 5) + .max() + .unwrap_or(0); + for p in pairs { + let pair = format!("{} ↔ {}", p.a, p.b); + println!( + " {} {} {}", + c_accent("·"), + fmt::pad_right(&pair, max), + c_muted(&format!("[{}]", p.shared_writable.join(", "))) + ); + } +} + +fn print_account_types(accounts: &[ilold_solana_core::view::AccountView]) { + if accounts.is_empty() { + println!(" {}", c_muted("No account types declared in IDL")); + return; + } + for at in accounts { + println!( + " {} {} {}", + c_accent("[T]"), + at.name, + c_muted(&at.discriminator_hex) + ); + if at.fields.is_empty() { + println!(" {}", c_muted("(opaque or zero-copy layout)")); + continue; + } + let max = at + .fields + .iter() + .map(|f| f.name.chars().count()) + .max() + .unwrap_or(0); + for f in &at.fields { + println!( + " {} {} {}", + c_accent("·"), + fmt::pad_right(&f.name, max), + c_muted(&f.ty) + ); + } + } +} + fn sync_active_scenario( handle: &tokio::runtime::Handle, client: &reqwest::Client, @@ -2835,222 +3011,6 @@ fn print_programs(state: &std::sync::Arc<ilold_web::state::AppState>, current: & println!(); } -fn idl_type_label(ty: &serde_json::Value) -> String { - if let Some(s) = ty.as_str() { - return s.to_string(); - } - if let Some(obj) = ty.as_object() { - if let Some(d) = obj.get("defined") { - if let Some(s) = d.as_str() { - return s.to_string(); - } - if let Some(n) = d.get("name").and_then(|v| v.as_str()) { - return n.to_string(); - } - } - if let Some(inner) = obj.get("option") { - return format!("Option<{}>", idl_type_label(inner)); - } - if let Some(inner) = obj.get("vec") { - return format!("Vec<{}>", idl_type_label(inner)); - } - if let Some(inner) = obj.get("array") { - return format!("[{}]", idl_type_label(inner)); - } - } - ty.to_string() -} - -fn print_solana_funcs_all(program: &serde_json::Value) { - let arr = match program.get("instructions").and_then(|v| v.as_array()) { - Some(a) => a, - None => { - println!(" {}", c_muted("No instructions")); - return; - } - }; - println!(); - let max_name = arr - .iter() - .filter_map(|ix| ix.get("name").and_then(|n| n.as_str())) - .map(|n| n.chars().count()) - .max() - .unwrap_or(0); - for ix in arr { - let name = ix.get("name").and_then(|v| v.as_str()).unwrap_or("?"); - let args = ix.get("args").and_then(|v| v.as_array()).map(|a| a.len()).unwrap_or(0); - let accs = ix.get("accounts").and_then(|v| v.as_array()); - let acc_count = accs.map(|a| a.len()).unwrap_or(0); - let signers = accs - .map(|a| { - a.iter() - .filter(|x| x.get("signer").and_then(|s| s.as_bool()).unwrap_or(false)) - .count() - }) - .unwrap_or(0); - let pdas = accs - .map(|a| a.iter().filter(|x| x.get("pda").is_some()).count()) - .unwrap_or(0); - let writables = accs - .map(|a| { - a.iter() - .filter(|x| x.get("writable").and_then(|s| s.as_bool()).unwrap_or(false)) - .count() - }) - .unwrap_or(0); - let padded = fmt::pad_right(name, max_name); - println!( - " {} {} {}", - c_accent("[ix]"), - padded, - c_muted(&format!( - "args={args} accs={acc_count} signers={signers} writables={writables} pdas={pdas}" - )) - ); - } - println!(); -} - -fn print_solana_ix_info(program: &serde_json::Value, ix_name: &str) { - let arr = program.get("instructions").and_then(|v| v.as_array()); - let ix = arr.and_then(|a| { - a.iter() - .find(|x| x.get("name").and_then(|n| n.as_str()) == Some(ix_name)) - }); - let ix = match ix { - Some(v) => v, - None => { - eprintln!(" {}", c_danger(&format!("instruction '{ix_name}' not found"))); - return; - } - }; - println!(); - println!(" {} {}", c_accent("instruction"), ix_name); - if let Some(disc) = ix.get("discriminator").and_then(|v| v.as_array()) { - let bytes: Vec<String> = disc.iter().filter_map(|b| b.as_u64()).map(|n| format!("{n:02x}")).collect(); - println!(" {} {}", c_muted("discriminator"), bytes.join(" ")); - } - let args = ix.get("args").and_then(|v| v.as_array()).cloned().unwrap_or_default(); - println!(); - println!(" {} ({})", c_accent("args"), args.len()); - if args.is_empty() { - println!(" {}", c_muted("(none)")); - } else { - let max = args - .iter() - .filter_map(|a| a.get("name").and_then(|n| n.as_str()).map(|s| s.chars().count())) - .max() - .unwrap_or(0); - for a in &args { - let name = a.get("name").and_then(|v| v.as_str()).unwrap_or("?"); - let ty = a.get("type").cloned().unwrap_or(serde_json::Value::Null); - println!( - " {} {} {}", - c_accent("·"), - fmt::pad_right(name, max), - c_muted(&idl_type_label(&ty)) - ); - } - } - let accs = ix - .get("accounts") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); - println!(); - println!(" {} ({})", c_accent("accounts"), accs.len()); - if accs.is_empty() { - println!(" {}", c_muted("(none)")); - } else { - let max = accs - .iter() - .filter_map(|a| a.get("name").and_then(|n| n.as_str()).map(|s| s.chars().count())) - .max() - .unwrap_or(0); - for a in &accs { - let name = a.get("name").and_then(|v| v.as_str()).unwrap_or("?"); - let signer = a.get("signer").and_then(|s| s.as_bool()).unwrap_or(false); - let writable = a.get("writable").and_then(|s| s.as_bool()).unwrap_or(false); - let pda = a.get("pda").is_some(); - let constant = a.get("address").and_then(|v| v.as_str()); - let mut flags = Vec::new(); - if signer { flags.push("signer"); } - if writable { flags.push("writable"); } - if pda { flags.push("pda"); } - let suffix = if let Some(addr) = constant { - format!("const {addr}") - } else { - flags.join(" ") - }; - println!( - " {} {} {}", - c_accent("·"), - fmt::pad_right(name, max), - c_muted(&suffix) - ); - } - } - println!(); -} - -fn print_solana_vars(program: &serde_json::Value, verbose: bool) { - let arr = match program.get("account_types").and_then(|v| v.as_array()) { - Some(a) => a, - None => { - println!(" {}", c_muted("No account types")); - return; - } - }; - println!(); - if arr.is_empty() { - println!(" {}", c_muted("No account types declared in IDL")); - println!(); - return; - } - for at in arr { - let name = at.get("name").and_then(|v| v.as_str()).unwrap_or("?"); - let disc = at - .get("discriminator") - .and_then(|v| v.as_array()) - .map(|d| { - d.iter() - .filter_map(|b| b.as_u64()) - .map(|n| format!("{n:02x}")) - .collect::<Vec<_>>() - .join(" ") - }) - .unwrap_or_default(); - println!(" {} {} {}", c_accent("[T]"), name, c_muted(&disc)); - if verbose { - let fields = at - .pointer("/layout/type/fields") - .and_then(|v| v.as_array()) - .cloned() - .unwrap_or_default(); - if fields.is_empty() { - println!(" {}", c_muted("(opaque or zero-copy layout)")); - } else { - let max = fields - .iter() - .filter_map(|f| f.get("name").and_then(|n| n.as_str()).map(|s| s.chars().count())) - .max() - .unwrap_or(0); - for f in &fields { - let fname = f.get("name").and_then(|v| v.as_str()).unwrap_or("?"); - let fty = f.get("type").cloned().unwrap_or(serde_json::Value::Null); - println!( - " {} {} {}", - c_accent("·"), - fmt::pad_right(fname, max), - c_muted(&idl_type_label(&fty)) - ); - } - } - } - } - println!(); -} - fn print_contracts(state: &std::sync::Arc<ilold_web::state::AppState>, current: &str) { use ilold_core::model::contract::ContractKind; let s = state.unwrap_solidity(); diff --git a/crates/ilold-solana-core/src/exploration/commands.rs b/crates/ilold-solana-core/src/exploration/commands.rs index 40740ca..ed053ba 100644 --- a/crates/ilold-solana-core/src/exploration/commands.rs +++ b/crates/ilold-solana-core/src/exploration/commands.rs @@ -7,6 +7,8 @@ use ilold_session_core::journal::types::{ReviewStatus, Severity}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use crate::view::{AccountView, CouplingPair, IxView}; + #[derive(Debug, Clone, Serialize, Deserialize)] pub enum SolanaCommand { Call { @@ -87,6 +89,16 @@ pub enum SolanaCommand { Timeline { pubkey: String, }, + /// Detail of a single instruction — args (typed), accounts with badges, + /// PDAs with seeds, discriminator hex, admin-gated bool. + Info { + ix: String, + }, + /// Pairs of instructions that share at least one writable account. + Coupling, + /// Account-type catalogue (`vars` in the REPL): name + discriminator + + /// fields. Slice of `ProgramView::accounts`. + Vars, } fn default_initial_lamports() -> u64 { @@ -247,6 +259,17 @@ pub enum SolanaCommandResult { label: Option<String>, entries: Vec<TimelineEntry>, }, + /// Detail for a single instruction, sliced from `ProgramView`. + IxInfo { + ix: IxView, + admin_gated: bool, + }, + CouplingList { + pairs: Vec<CouplingPair>, + }, + AccountTypes { + accounts: Vec<AccountView>, + }, Error { message: String, }, diff --git a/crates/ilold-solana-core/src/exploration/execute.rs b/crates/ilold-solana-core/src/exploration/execute.rs index f2db94c..64931e0 100644 --- a/crates/ilold-solana-core/src/exploration/execute.rs +++ b/crates/ilold-solana-core/src/exploration/execute.rs @@ -11,7 +11,7 @@ use solana_signer::Signer; use crate::decode::borsh::decode_defined_fields; use crate::execute::VmHost; use crate::model::{AccountTypeDef, ProgramDef}; -use crate::view::SeedView; +use crate::view::describe_seed_view; use super::add_step::add_solana_step; use super::commands::{ @@ -42,6 +42,38 @@ pub fn execute_funcs(program: &ProgramDef) -> SolanaCommandResult { SolanaCommandResult::InstructionList { items } } +pub fn execute_info(program: &ProgramDef, ix_name: &str) -> SolanaCommandResult { + let view = program.compute_view(); + let ix = match view.instructions.iter().find(|i| i.name == ix_name) { + Some(i) => i.clone(), + None => { + return SolanaCommandResult::Error { + message: format!("instruction '{ix_name}' not found"), + }; + } + }; + let admin_gated = view + .admin_gated + .as_ref() + .map(|set| set.contains(ix_name)) + .unwrap_or(false); + SolanaCommandResult::IxInfo { ix, admin_gated } +} + +pub fn execute_coupling(program: &ProgramDef) -> SolanaCommandResult { + let view = program.compute_view(); + SolanaCommandResult::CouplingList { + pairs: view.state_coupling.unwrap_or_default(), + } +} + +pub fn execute_vars(program: &ProgramDef) -> SolanaCommandResult { + let view = program.compute_view(); + SolanaCommandResult::AccountTypes { + accounts: view.accounts, + } +} + pub fn execute_users(users: &HashMap<String, Keypair>, vm: &VmHost) -> SolanaCommandResult { let mut entries: Vec<UserEntry> = users .iter() @@ -494,34 +526,6 @@ pub fn execute_status( SolanaCommandResult::StatusUpdated } -fn describe_seed_view(seed: &SeedView) -> String { - match seed { - SeedView::Const { value_hex, value_utf8 } => match value_utf8 { - Some(s) => format!("const:'{s}'"), - None => { - let bytes = hex_to_bytes(value_hex); - format!("const:{:02x?}", bytes) - } - }, - SeedView::Account { path } => format!("account:{path}"), - SeedView::Arg { name, .. } => format!("arg:{name}"), - } -} - -fn hex_to_bytes(value_hex: &str) -> Vec<u8> { - let stripped = value_hex.strip_prefix("0x").unwrap_or(value_hex); - let mut out = Vec::with_capacity(stripped.len() / 2); - let bytes = stripped.as_bytes(); - let mut i = 0; - while i + 1 < bytes.len() { - let hi = (bytes[i] as char).to_digit(16).unwrap_or(0) as u8; - let lo = (bytes[i + 1] as char).to_digit(16).unwrap_or(0) as u8; - out.push((hi << 4) | lo); - i += 2; - } - out -} - fn decode_account_bytes( data: &[u8], account_types: &[AccountTypeDef], diff --git a/crates/ilold-solana-core/src/exploration/mod.rs b/crates/ilold-solana-core/src/exploration/mod.rs index 23e8af1..a21c498 100644 --- a/crates/ilold-solana-core/src/exploration/mod.rs +++ b/crates/ilold-solana-core/src/exploration/mod.rs @@ -8,8 +8,9 @@ pub use commands::{ SolanaCommandResult, UserEntry, }; pub use execute::{ - execute_airdrop, execute_back, execute_call, execute_clear, execute_export, - execute_finding, execute_findings_list, execute_funcs, execute_inspect, execute_note, - execute_pda, execute_session, execute_state, execute_status, execute_step, - execute_time_warp, execute_timeline, execute_users, execute_users_new, execute_who, + execute_airdrop, execute_back, execute_call, execute_clear, execute_coupling, + execute_export, execute_finding, execute_findings_list, execute_funcs, execute_info, + execute_inspect, execute_note, execute_pda, execute_session, execute_state, + execute_status, execute_step, execute_time_warp, execute_timeline, execute_users, + execute_users_new, execute_vars, execute_who, }; diff --git a/crates/ilold-solana-core/src/view.rs b/crates/ilold-solana-core/src/view.rs index 1558e5c..3c697e9 100644 --- a/crates/ilold-solana-core/src/view.rs +++ b/crates/ilold-solana-core/src/view.rs @@ -1,8 +1,13 @@ -use std::collections::HashSet; +use std::collections::{BTreeSet, HashSet}; +use anchor_lang_idl::types::{ + IdlArrayLen, IdlDefinedFields, IdlField, IdlGenericArg, IdlType, IdlTypeDefTy, +}; use serde::{Deserialize, Serialize}; -use crate::model::{AccountSpec, AccountTypeDef, PdaSpec, ProgramDef, SeedSpec}; +use crate::model::{ + AccountSpec, AccountTypeDef, InstructionDef, PdaSpec, ProgramDef, SeedSpec, +}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ProgramView { @@ -12,12 +17,44 @@ pub struct ProgramView { pub accounts: Vec<AccountView>, #[serde(default, skip_serializing_if = "Option::is_none")] pub state_coupling: Option<Vec<CouplingPair>>, - #[serde(default, skip_serializing_if = "Option::is_none")] + /// Sorted (deterministic) for snapshot stability; spec lists this as a set. + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_sorted_opt_string_set", + deserialize_with = "deserialize_opt_string_set" + )] pub admin_gated: Option<HashSet<String>>, - #[serde(default, skip_serializing_if = "Option::is_none")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + serialize_with = "serialize_sorted_opt_string_set", + deserialize_with = "deserialize_opt_string_set" + )] pub system_accounts: Option<HashSet<String>>, } +fn serialize_sorted_opt_string_set<S: serde::Serializer>( + value: &Option<HashSet<String>>, + serializer: S, +) -> Result<S::Ok, S::Error> { + match value { + None => serializer.serialize_none(), + Some(set) => { + let mut sorted: Vec<&String> = set.iter().collect(); + sorted.sort(); + serializer.collect_seq(sorted) + } + } +} + +fn deserialize_opt_string_set<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result<Option<HashSet<String>>, D::Error> { + let opt: Option<Vec<String>> = Option::deserialize(deserializer)?; + Ok(opt.map(|v| v.into_iter().collect())) +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct IxView { pub name: String, @@ -126,59 +163,63 @@ const SYSVAR_NAMES: &[&str] = &[ impl ProgramDef { pub fn compute_view(&self) -> ProgramView { - let instructions = self + let instructions: Vec<IxView> = self .instructions .iter() .map(|ix| build_ix_view(ix, &self.account_types)) .collect(); - let accounts = self + let accounts: Vec<AccountView> = self .account_types .iter() .map(build_account_view) .collect(); + + let state_coupling = Some(compute_coupling(&instructions)); + let admin_gated = Some(compute_admin_gated(self, &accounts)); + let system_accounts = Some(collect_system_accounts(&instructions)); + ProgramView { name: self.name.clone(), program_id: self.program_id.to_string(), instructions, accounts, - state_coupling: None, - admin_gated: None, - system_accounts: None, + state_coupling, + admin_gated, + system_accounts, } } } -fn build_ix_view( - ix: &crate::model::InstructionDef, - account_types: &[AccountTypeDef], -) -> IxView { - let args = ix +fn build_ix_view(ix: &InstructionDef, account_types: &[AccountTypeDef]) -> IxView { + let args: Vec<ArgView> = ix .args .iter() .map(|a| ArgView { name: a.name.clone(), - ty: String::new(), + ty: format_idl_type(&a.ty), }) .collect(); let accounts = ix .accounts .iter() - .map(|spec| build_ix_account_view(spec, account_types)) + .map(|spec| build_ix_account_view(spec, &ix.args, account_types)) .collect(); + let returns = ix.returns.as_ref().map(format_idl_type); IxView { name: ix.name.clone(), - discriminator_hex: String::new(), + discriminator_hex: format_discriminator(&ix.discriminator), args, accounts, - returns: None, + returns, } } fn build_ix_account_view( spec: &AccountSpec, + ix_args: &[IdlField], account_types: &[AccountTypeDef], ) -> IxAccountView { - let pda = spec.pda.as_ref().map(build_pda_view); + let pda = spec.pda.as_ref().map(|p| build_pda_view(p, ix_args)); let kind = classify_account_kind(&spec.name, &pda, account_types); IxAccountView { path: spec.path.clone(), @@ -193,8 +234,8 @@ fn build_ix_account_view( } } -fn build_pda_view(pda: &PdaSpec) -> PdaView { - let seeds = pda.seeds.iter().map(seed_to_view).collect(); +fn build_pda_view(pda: &PdaSpec, ix_args: &[IdlField]) -> PdaView { + let seeds = pda.seeds.iter().map(|s| seed_to_view(s, ix_args)).collect(); let program = pda.program.as_ref().map(seed_program_to_string); PdaView { seeds, @@ -203,7 +244,7 @@ fn build_pda_view(pda: &PdaSpec) -> PdaView { } } -fn seed_to_view(seed: &SeedSpec) -> SeedView { +fn seed_to_view(seed: &SeedSpec, ix_args: &[IdlField]) -> SeedView { match seed { SeedSpec::Const { value } => { let value_hex = bytes_to_hex(value); @@ -213,10 +254,19 @@ fn seed_to_view(seed: &SeedSpec) -> SeedView { value_utf8, } } - SeedSpec::Arg { path, .. } => SeedView::Arg { - name: path.clone(), - ty: String::new(), - }, + SeedSpec::Arg { path, ty } => { + // Prefer the type the IDL parser already attached to the seed; if + // missing for some reason, fall back to the matching ix arg. + let resolved = ix_args + .iter() + .find(|f| f.name == *path) + .map(|f| format_idl_type(&f.ty)) + .unwrap_or_else(|| format_idl_type(ty)); + SeedView::Arg { + name: path.clone(), + ty: resolved, + } + } SeedSpec::Account { path } => SeedView::Account { path: path.clone() }, } } @@ -233,10 +283,30 @@ fn seed_program_to_string(seed: &SeedSpec) -> String { } fn build_account_view(account: &AccountTypeDef) -> AccountView { + // Empty when the IDL did not ship a real layout (placeholder_typedef path + // in model::program). The frontend renders both cases the same. + let fields = match &account.layout.ty { + IdlTypeDefTy::Struct { fields: Some(IdlDefinedFields::Named(named)) } => named + .iter() + .map(|f| FieldView { + name: f.name.clone(), + ty: format_idl_type(&f.ty), + }) + .collect(), + IdlTypeDefTy::Struct { fields: Some(IdlDefinedFields::Tuple(items)) } => items + .iter() + .enumerate() + .map(|(idx, ty)| FieldView { + name: idx.to_string(), + ty: format_idl_type(ty), + }) + .collect(), + _ => Vec::new(), + }; AccountView { name: account.name.clone(), - discriminator_hex: String::new(), - fields: Vec::new(), + discriminator_hex: format_discriminator(&account.discriminator), + fields, } } @@ -292,6 +362,181 @@ fn bytes_to_ascii_graphic(bytes: &[u8]) -> Option<String> { } } +fn format_discriminator(d: &[u8; 8]) -> String { + let mut s = String::with_capacity(2 + d.len() * 2); + s.push_str("0x"); + for b in d { + s.push_str(&format!("{:02x}", b)); + } + s +} + +/// Stringify an `IdlType` into a short, deterministic, frontend-friendly label. +/// Spec table lives in `docs/sdd/04-program-view-canonical/design.md`. +fn format_idl_type(ty: &IdlType) -> String { + match ty { + IdlType::Bool => "bool".into(), + IdlType::U8 => "u8".into(), + IdlType::I8 => "i8".into(), + IdlType::U16 => "u16".into(), + IdlType::I16 => "i16".into(), + IdlType::U32 => "u32".into(), + IdlType::I32 => "i32".into(), + IdlType::F32 => "f32".into(), + IdlType::U64 => "u64".into(), + IdlType::I64 => "i64".into(), + IdlType::F64 => "f64".into(), + IdlType::U128 => "u128".into(), + IdlType::I128 => "i128".into(), + IdlType::U256 => "u256".into(), + IdlType::I256 => "i256".into(), + IdlType::Bytes => "bytes".into(), + IdlType::String => "string".into(), + IdlType::Pubkey => "Pubkey".into(), + IdlType::Option(inner) => format!("Option<{}>", format_idl_type(inner)), + IdlType::Vec(inner) => format!("Vec<{}>", format_idl_type(inner)), + IdlType::Array(inner, len) => { + let len_str = match len { + IdlArrayLen::Value(v) => v.to_string(), + IdlArrayLen::Generic(name) => name.clone(), + }; + format!("[{}; {}]", format_idl_type(inner), len_str) + } + IdlType::Defined { name, generics } => { + if generics.is_empty() { + name.clone() + } else { + let parts: Vec<String> = generics.iter().map(format_generic_arg).collect(); + format!("{name}<{}>", parts.join(", ")) + } + } + IdlType::Generic(name) => name.clone(), + // The enum is `#[non_exhaustive]`; future variants render as Debug so + // we never crash but wire-format drift becomes obvious in snapshots. + other => format!("{other:?}"), + } +} + +fn format_generic_arg(arg: &IdlGenericArg) -> String { + match arg { + IdlGenericArg::Type { ty } => format_idl_type(ty), + IdlGenericArg::Const { value } => value.clone(), + } +} + +fn compute_coupling(ixs: &[IxView]) -> Vec<CouplingPair> { + let mut out: Vec<CouplingPair> = Vec::new(); + for i in 0..ixs.len() { + for j in (i + 1)..ixs.len() { + let writable_i: HashSet<&str> = ixs[i] + .accounts + .iter() + .filter(|a| a.writable) + .map(|a| a.name.as_str()) + .collect(); + let writable_j: HashSet<&str> = ixs[j] + .accounts + .iter() + .filter(|a| a.writable) + .map(|a| a.name.as_str()) + .collect(); + let mut shared: Vec<String> = writable_i + .intersection(&writable_j) + .map(|s| s.to_string()) + .collect(); + if shared.is_empty() { + continue; + } + shared.sort(); + // Sort the pair members so (a, b) is canonical regardless of + // instruction declaration order in the IDL. + let (a, b) = if ixs[i].name <= ixs[j].name { + (ixs[i].name.clone(), ixs[j].name.clone()) + } else { + (ixs[j].name.clone(), ixs[i].name.clone()) + }; + out.push(CouplingPair { + a, + b, + shared_writable: shared, + }); + } + } + out.sort_by(|x, y| { + y.shared_writable + .len() + .cmp(&x.shared_writable.len()) + .then_with(|| x.a.cmp(&y.a)) + .then_with(|| x.b.cmp(&y.b)) + }); + out +} + +fn compute_admin_gated(program: &ProgramDef, accounts: &[AccountView]) -> HashSet<String> { + let admin_account_exists = accounts + .iter() + .any(|a| a.fields.iter().any(|f| is_admin_field(&f.name) && f.ty == "Pubkey")); + if !admin_account_exists { + return HashSet::new(); + } + program + .instructions + .iter() + .filter(|ix| { + ix.accounts + .iter() + .any(|acc| is_admin_field(&acc.name) && acc.signer) + }) + .map(|ix| ix.name.clone()) + .collect() +} + +fn is_admin_field(name: &str) -> bool { + name == "admin" || name == "authority" +} + +fn collect_system_accounts(ixs: &[IxView]) -> HashSet<String> { + let mut set: BTreeSet<String> = BTreeSet::new(); + for ix in ixs { + for acc in &ix.accounts { + if matches!(acc.kind, AccountKind::System | AccountKind::Sysvar) { + set.insert(acc.name.clone()); + } + } + } + set.into_iter().collect() +} + +/// Render a `SeedView` as the compact CLI string used by `pda` and `info`. +/// Lives next to the wire format because it is a formatter over the wire type. +pub fn describe_seed_view(seed: &SeedView) -> String { + match seed { + SeedView::Const { value_hex, value_utf8 } => match value_utf8 { + Some(s) => format!("const:'{s}'"), + None => { + let bytes = hex_to_bytes(value_hex); + format!("const:{:02x?}", bytes) + } + }, + SeedView::Account { path } => format!("account:{path}"), + SeedView::Arg { name, .. } => format!("arg:{name}"), + } +} + +pub fn hex_to_bytes(value_hex: &str) -> Vec<u8> { + let stripped = value_hex.strip_prefix("0x").unwrap_or(value_hex); + let mut out = Vec::with_capacity(stripped.len() / 2); + let bytes = stripped.as_bytes(); + let mut i = 0; + while i + 1 < bytes.len() { + let hi = (bytes[i] as char).to_digit(16).unwrap_or(0) as u8; + let lo = (bytes[i + 1] as char).to_digit(16).unwrap_or(0) as u8; + out.push((hi << 4) | lo); + i += 2; + } + out +} + #[cfg(test)] mod tests { use super::*; @@ -320,17 +565,31 @@ mod tests { } #[test] - fn compute_view_pool_struct_has_real_fields() { - // Phase 1 placeholder: AccountView::fields stays empty until T-R50. - // The shape is asserted; populated content lands in Phase 2. + fn compute_view_account_fields_and_discriminators_populated() { let view = lever_program().compute_view(); let power = view .accounts .iter() .find(|a| a.name == "PowerStatus") .expect("PowerStatus account-type present"); - assert!(power.fields.is_empty()); - assert_eq!(power.discriminator_hex, ""); + assert_eq!(power.fields.len(), 1); + assert_eq!(power.fields[0].name, "is_on"); + assert_eq!(power.fields[0].ty, "bool"); + assert_eq!(power.discriminator_hex, "0x9193c623fd65e71a"); + } + + #[test] + fn compute_view_ix_args_have_string_types() { + let view = lever_program().compute_view(); + let switch = view + .instructions + .iter() + .find(|i| i.name == "switch_power") + .expect("switch_power present"); + assert_eq!(switch.discriminator_hex, "0xe2ee38acbf2d7a57"); + assert_eq!(switch.args.len(), 1); + assert_eq!(switch.args[0].name, "name"); + assert_eq!(switch.args[0].ty, "string"); } #[test] @@ -359,7 +618,7 @@ mod tests { let printable = SeedSpec::Const { value: b"stake".to_vec(), }; - match seed_to_view(&printable) { + match seed_to_view(&printable, &[]) { SeedView::Const { value_hex, value_utf8 } => { assert_eq!(value_hex, "0x7374616b65"); assert_eq!(value_utf8.as_deref(), Some("stake")); @@ -371,7 +630,7 @@ mod tests { let non_graphic = SeedSpec::Const { value: vec![0x09, 0x0a], }; - match seed_to_view(&non_graphic) { + match seed_to_view(&non_graphic, &[]) { SeedView::Const { value_hex, value_utf8 } => { assert_eq!(value_hex, "0x090a"); assert!(value_utf8.is_none()); @@ -387,4 +646,106 @@ mod tests { assert_eq!(snake_to_pascal(""), ""); assert_eq!(snake_to_pascal("__double"), "Double"); } + + #[test] + fn format_idl_type_covers_primitives_and_compounds() { + assert_eq!(format_idl_type(&IdlType::U8), "u8"); + assert_eq!(format_idl_type(&IdlType::U64), "u64"); + assert_eq!(format_idl_type(&IdlType::I128), "i128"); + assert_eq!(format_idl_type(&IdlType::Bool), "bool"); + assert_eq!(format_idl_type(&IdlType::String), "string"); + assert_eq!(format_idl_type(&IdlType::Bytes), "bytes"); + assert_eq!(format_idl_type(&IdlType::Pubkey), "Pubkey"); + + let opt = IdlType::Option(Box::new(IdlType::U64)); + assert_eq!(format_idl_type(&opt), "Option<u64>"); + + let vec_pk = IdlType::Vec(Box::new(IdlType::Pubkey)); + assert_eq!(format_idl_type(&vec_pk), "Vec<Pubkey>"); + + let arr = IdlType::Array(Box::new(IdlType::U8), IdlArrayLen::Value(32)); + assert_eq!(format_idl_type(&arr), "[u8; 32]"); + + let arr_generic = IdlType::Array(Box::new(IdlType::U8), IdlArrayLen::Generic("N".into())); + assert_eq!(format_idl_type(&arr_generic), "[u8; N]"); + + let defined = IdlType::Defined { + name: "Pool".into(), + generics: vec![], + }; + assert_eq!(format_idl_type(&defined), "Pool"); + + let generic_arg = IdlType::Defined { + name: "Box".into(), + generics: vec![IdlGenericArg::Type { + ty: IdlType::U64, + }], + }; + assert_eq!(format_idl_type(&generic_arg), "Box<u64>"); + + let nested = IdlType::Vec(Box::new(IdlType::Option(Box::new(IdlType::U32)))); + assert_eq!(format_idl_type(&nested), "Vec<Option<u32>>"); + + assert_eq!(format_idl_type(&IdlType::Generic("T".into())), "T"); + } + + #[test] + fn compute_view_state_coupling_empty_when_no_writable_overlap() { + let view = lever_program().compute_view(); + // lever has 2 instructions: initialize and switch_power. + // Both write `power`, so they MUST be coupled. + let coupling = view.state_coupling.expect("state_coupling computed"); + assert_eq!(coupling.len(), 1); + assert_eq!(coupling[0].a, "initialize"); + assert_eq!(coupling[0].b, "switch_power"); + assert_eq!(coupling[0].shared_writable, vec!["power"]); + } + + #[test] + fn compute_view_admin_gated_requires_both_signer_and_field() { + // lever has no admin field on PowerStatus — admin_gated must be empty. + let view = lever_program().compute_view(); + let gated = view.admin_gated.expect("admin_gated computed"); + assert!(gated.is_empty(), "lever should not gate any ix"); + } + + #[test] + fn compute_view_system_accounts_collects_kinds() { + let view = lever_program().compute_view(); + let sys = view.system_accounts.expect("system_accounts computed"); + assert!(sys.contains("system_program")); + } + + const STAKING_JSON: &str = include_str!( + "../../../tests/fixtures/solana/staking/idls/staking.json" + ); + + fn staking_program() -> ProgramDef { + ProgramDef::from_idl(parse_idl(STAKING_JSON).expect("parse staking")) + .expect("build staking ProgramDef") + } + + #[test] + fn compute_view_coupling_includes_stake_unstake_on_staking() { + let view = staking_program().compute_view(); + let coupling = view.state_coupling.expect("state_coupling computed"); + let pair = coupling + .iter() + .find(|p| p.a == "stake" && p.b == "unstake") + .expect("stake↔unstake pair present"); + assert!(pair.shared_writable.iter().any(|n| n == "pool")); + assert!(pair.shared_writable.iter().any(|n| n == "user_stake")); + } + + #[test] + fn compute_view_admin_gated_marks_initialize_pool_on_staking() { + let view = staking_program().compute_view(); + let gated = view.admin_gated.expect("admin_gated computed"); + // Pool has admin: Pubkey AND both initialize_pool and add_rewards + // require an `admin` signer. Both must appear; stake/unstake must NOT. + assert!(gated.contains("initialize_pool")); + assert!(gated.contains("add_rewards")); + assert!(!gated.contains("stake")); + assert!(!gated.contains("unstake")); + } } diff --git a/crates/ilold-solana-core/tests/program_view_snapshot.rs b/crates/ilold-solana-core/tests/program_view_snapshot.rs index 8f16900..78ade8d 100644 --- a/crates/ilold-solana-core/tests/program_view_snapshot.rs +++ b/crates/ilold-solana-core/tests/program_view_snapshot.rs @@ -15,7 +15,7 @@ fn snapshot_root() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")) .join("tests") .join("snapshots") - .join("t_r49_pre") + .join("funcs_who_pda_baseline") } fn regen_enabled() -> bool { diff --git a/crates/ilold-solana-core/tests/snapshots/t_r49_pre/lever_funcs.json b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/lever_funcs.json similarity index 100% rename from crates/ilold-solana-core/tests/snapshots/t_r49_pre/lever_funcs.json rename to crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/lever_funcs.json diff --git a/crates/ilold-solana-core/tests/snapshots/t_r49_pre/lever_pda_initialize.json b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/lever_pda_initialize.json similarity index 100% rename from crates/ilold-solana-core/tests/snapshots/t_r49_pre/lever_pda_initialize.json rename to crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/lever_pda_initialize.json diff --git a/crates/ilold-solana-core/tests/snapshots/t_r49_pre/lever_pda_switch_power.json b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/lever_pda_switch_power.json similarity index 100% rename from crates/ilold-solana-core/tests/snapshots/t_r49_pre/lever_pda_switch_power.json rename to crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/lever_pda_switch_power.json diff --git a/crates/ilold-solana-core/tests/snapshots/t_r49_pre/relations_pda_init_base.json b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/relations_pda_init_base.json similarity index 100% rename from crates/ilold-solana-core/tests/snapshots/t_r49_pre/relations_pda_init_base.json rename to crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/relations_pda_init_base.json diff --git a/crates/ilold-solana-core/tests/snapshots/t_r49_pre/relations_pda_test_relation.json b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/relations_pda_test_relation.json similarity index 100% rename from crates/ilold-solana-core/tests/snapshots/t_r49_pre/relations_pda_test_relation.json rename to crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/relations_pda_test_relation.json diff --git a/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_funcs.json b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_funcs.json similarity index 100% rename from crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_funcs.json rename to crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_funcs.json diff --git a/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_add_rewards.json b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_pda_add_rewards.json similarity index 100% rename from crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_add_rewards.json rename to crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_pda_add_rewards.json diff --git a/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_claim_rewards.json b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_pda_claim_rewards.json similarity index 100% rename from crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_claim_rewards.json rename to crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_pda_claim_rewards.json diff --git a/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_initialize_pool.json b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_pda_initialize_pool.json similarity index 100% rename from crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_initialize_pool.json rename to crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_pda_initialize_pool.json diff --git a/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_stake.json b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_pda_stake.json similarity index 100% rename from crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_stake.json rename to crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_pda_stake.json diff --git a/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_unstake.json b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_pda_unstake.json similarity index 100% rename from crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_pda_unstake.json rename to crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_pda_unstake.json diff --git a/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_who_pool.json b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_who_pool.json similarity index 100% rename from crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_who_pool.json rename to crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_who_pool.json diff --git a/crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_who_user_stake.json b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_who_user_stake.json similarity index 100% rename from crates/ilold-solana-core/tests/snapshots/t_r49_pre/staking_who_user_stake.json rename to crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_who_user_stake.json diff --git a/crates/ilold-solana-core/tests/snapshots/staking_view.json b/crates/ilold-solana-core/tests/snapshots/staking_view.json index a18efb5..d65be5f 100644 --- a/crates/ilold-solana-core/tests/snapshots/staking_view.json +++ b/crates/ilold-solana-core/tests/snapshots/staking_view.json @@ -4,11 +4,11 @@ "instructions": [ { "name": "add_rewards", - "discriminator_hex": "", + "discriminator_hex": "0x58ba19e326895117", "args": [ { "name": "amount", - "ty": "" + "ty": "u64" } ], "accounts": [ @@ -34,7 +34,7 @@ }, { "name": "claim_rewards", - "discriminator_hex": "", + "discriminator_hex": "0x0490844774179750", "args": [], "accounts": [ { @@ -68,11 +68,11 @@ }, { "name": "initialize_pool", - "discriminator_hex": "", + "discriminator_hex": "0x5fb40aac54aee828", "args": [ { "name": "reward_rate", - "ty": "" + "ty": "u64" } ], "accounts": [ @@ -108,11 +108,11 @@ }, { "name": "stake", - "discriminator_hex": "", + "discriminator_hex": "0xceb0ca12c8d1b36c", "args": [ { "name": "amount", - "ty": "" + "ty": "u64" } ], "accounts": [ @@ -157,11 +157,11 @@ }, { "name": "unstake", - "discriminator_hex": "", + "discriminator_hex": "0x5a5f6b2acd7c32e1", "args": [ { "name": "amount", - "ty": "" + "ty": "u64" } ], "accounts": [ @@ -198,13 +198,125 @@ "accounts": [ { "name": "Pool", - "discriminator_hex": "", - "fields": [] + "discriminator_hex": "0xf19a6d0411b16dbc", + "fields": [ + { + "name": "admin", + "ty": "Pubkey" + }, + { + "name": "total_staked", + "ty": "u64" + }, + { + "name": "total_rewards", + "ty": "u64" + }, + { + "name": "reward_rate", + "ty": "u64" + } + ] }, { "name": "UserStake", - "discriminator_hex": "", - "fields": [] + "discriminator_hex": "0x6635a36b098a5799", + "fields": [ + { + "name": "user", + "ty": "Pubkey" + }, + { + "name": "amount", + "ty": "u64" + }, + { + "name": "claimed_rewards", + "ty": "u64" + } + ] } + ], + "state_coupling": [ + { + "a": "claim_rewards", + "b": "stake", + "shared_writable": [ + "pool", + "user_stake" + ] + }, + { + "a": "claim_rewards", + "b": "unstake", + "shared_writable": [ + "pool", + "user_stake" + ] + }, + { + "a": "stake", + "b": "unstake", + "shared_writable": [ + "pool", + "user_stake" + ] + }, + { + "a": "add_rewards", + "b": "claim_rewards", + "shared_writable": [ + "pool" + ] + }, + { + "a": "add_rewards", + "b": "initialize_pool", + "shared_writable": [ + "pool" + ] + }, + { + "a": "add_rewards", + "b": "stake", + "shared_writable": [ + "pool" + ] + }, + { + "a": "add_rewards", + "b": "unstake", + "shared_writable": [ + "pool" + ] + }, + { + "a": "claim_rewards", + "b": "initialize_pool", + "shared_writable": [ + "pool" + ] + }, + { + "a": "initialize_pool", + "b": "stake", + "shared_writable": [ + "pool" + ] + }, + { + "a": "initialize_pool", + "b": "unstake", + "shared_writable": [ + "pool" + ] + } + ], + "admin_gated": [ + "add_rewards", + "initialize_pool" + ], + "system_accounts": [ + "system_program" ] } diff --git a/crates/ilold-web/src/api/session.rs b/crates/ilold-web/src/api/session.rs index 4527dc8..6e75d06 100644 --- a/crates/ilold-web/src/api/session.rs +++ b/crates/ilold-web/src/api/session.rs @@ -21,10 +21,11 @@ use ilold_core::slicing::{build_slice_result, SliceDirection, SliceResult}; use ilold_session_core::exploration::scenario::ScenarioAction as SharedScenarioAction; use ilold_solana_core::exploration::{ canvas_patch_from_solana, execute_airdrop, execute_back, execute_call, execute_clear, - execute_export, execute_findings_list, execute_step, execute_timeline, execute_who, - execute_finding, execute_funcs, execute_inspect, execute_note, execute_pda, execute_session, - execute_state, execute_status, execute_time_warp, execute_users, execute_users_new, - SolanaCommand, SolanaCommandResult, + execute_coupling, execute_export, execute_findings_list, execute_info, execute_step, + execute_timeline, execute_vars, execute_who, execute_finding, execute_funcs, + execute_inspect, execute_note, execute_pda, execute_session, execute_state, + execute_status, execute_time_warp, execute_users, execute_users_new, SolanaCommand, + SolanaCommandResult, }; use solana_keypair::Keypair; @@ -669,6 +670,9 @@ async fn handle_solana_command( SolanaCommand::Timeline { pubkey } => { execute_timeline(session, &program, &pubkey, &active_scenario, scenario_users) } + SolanaCommand::Info { ix } => execute_info(&program, &ix), + SolanaCommand::Coupling => execute_coupling(&program), + SolanaCommand::Vars => execute_vars(&program), SolanaCommand::SaveSession { .. } | SolanaCommand::LoadSession { .. } | SolanaCommand::Scenario { .. } => unreachable!("handled above"), diff --git a/crates/ilold-web/tests/solana_command_pipeline.rs b/crates/ilold-web/tests/solana_command_pipeline.rs index 84e4d89..57bb090 100644 --- a/crates/ilold-web/tests/solana_command_pipeline.rs +++ b/crates/ilold-web/tests/solana_command_pipeline.rs @@ -356,3 +356,83 @@ async fn solana_back_rewinds_vm_state() { "switch_power should still run after Back+replay; got: {logs}" ); } + +#[tokio::test] +async fn solana_info_returns_typed_ix_view() { + let (client, port) = start_solana().await; + let result = cmd( + &client, + port, + "lever", + serde_json::json!({"Info": {"ix": "switch_power"}}), + ) + .await; + let info = result.get("IxInfo").expect("IxInfo variant"); + let ix = info.get("ix").expect("ix slice"); + assert_eq!( + ix.get("name").and_then(|v| v.as_str()), + Some("switch_power") + ); + let disc = ix + .get("discriminator_hex") + .and_then(|v| v.as_str()) + .expect("discriminator_hex"); + assert!(disc.starts_with("0x") && disc.len() == 18); + let args = ix + .get("args") + .and_then(|v| v.as_array()) + .expect("args array"); + assert_eq!(args.len(), 1); + assert_eq!( + args[0].get("ty").and_then(|v| v.as_str()), + Some("string") + ); + assert_eq!( + info.get("admin_gated").and_then(|v| v.as_bool()), + Some(false) + ); +} + +#[tokio::test] +async fn solana_coupling_returns_pairs() { + let (client, port) = start_solana().await; + let result = cmd(&client, port, "lever", serde_json::json!("Coupling")).await; + let pairs = result + .get("CouplingList") + .and_then(|v| v.get("pairs")) + .and_then(|v| v.as_array()) + .expect("CouplingList.pairs"); + // lever has 2 ixs both writing `power` → exactly one pair. + assert_eq!(pairs.len(), 1); + let only = &pairs[0]; + assert_eq!(only.get("a").and_then(|v| v.as_str()), Some("initialize")); + assert_eq!( + only.get("b").and_then(|v| v.as_str()), + Some("switch_power") + ); +} + +#[tokio::test] +async fn solana_vars_returns_account_types() { + let (client, port) = start_solana().await; + let result = cmd(&client, port, "lever", serde_json::json!("Vars")).await; + let accounts = result + .get("AccountTypes") + .and_then(|v| v.get("accounts")) + .and_then(|v| v.as_array()) + .expect("AccountTypes.accounts"); + assert_eq!(accounts.len(), 1); + assert_eq!( + accounts[0].get("name").and_then(|v| v.as_str()), + Some("PowerStatus") + ); + let fields = accounts[0] + .get("fields") + .and_then(|v| v.as_array()) + .expect("fields"); + assert_eq!(fields.len(), 1); + assert_eq!( + fields[0].get("ty").and_then(|v| v.as_str()), + Some("bool") + ); +} From b4f86b41cc3a8496e13b30f08775d05412db7f10 Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Sun, 10 May 2026 11:56:47 +0200 Subject: [PATCH 091/115] chore(solana): drop verbose comments from view enrichment - Remove explanatory comments restating obvious code - Drop test-internal commentary; test names already describe scope - Keep helper signatures docless --- crates/ilold-cli/src/explore.rs | 3 --- crates/ilold-solana-core/src/view.rs | 22 ---------------------- 2 files changed, 25 deletions(-) diff --git a/crates/ilold-cli/src/explore.rs b/crates/ilold-cli/src/explore.rs index 30de105..5e9e562 100644 --- a/crates/ilold-cli/src/explore.rs +++ b/crates/ilold-cli/src/explore.rs @@ -1165,9 +1165,6 @@ fn handle_solana_input( let body = serde_json::json!({"Info": {"ix": arg}}); dispatch_solana(handle, client, base_url, contract, body, steps) } - // `vars-all` historically toggled per-field detail. With the typed - // backend the verbose form is the only sensible one (fields ship in - // the wire format), so both aliases fall through to the same dispatch. "vars" | "v" | "vars-all" | "va" => dispatch_solana( handle, client, diff --git a/crates/ilold-solana-core/src/view.rs b/crates/ilold-solana-core/src/view.rs index 3c697e9..6da891e 100644 --- a/crates/ilold-solana-core/src/view.rs +++ b/crates/ilold-solana-core/src/view.rs @@ -17,7 +17,6 @@ pub struct ProgramView { pub accounts: Vec<AccountView>, #[serde(default, skip_serializing_if = "Option::is_none")] pub state_coupling: Option<Vec<CouplingPair>>, - /// Sorted (deterministic) for snapshot stability; spec lists this as a set. #[serde( default, skip_serializing_if = "Option::is_none", @@ -255,8 +254,6 @@ fn seed_to_view(seed: &SeedSpec, ix_args: &[IdlField]) -> SeedView { } } SeedSpec::Arg { path, ty } => { - // Prefer the type the IDL parser already attached to the seed; if - // missing for some reason, fall back to the matching ix arg. let resolved = ix_args .iter() .find(|f| f.name == *path) @@ -283,8 +280,6 @@ fn seed_program_to_string(seed: &SeedSpec) -> String { } fn build_account_view(account: &AccountTypeDef) -> AccountView { - // Empty when the IDL did not ship a real layout (placeholder_typedef path - // in model::program). The frontend renders both cases the same. let fields = match &account.layout.ty { IdlTypeDefTy::Struct { fields: Some(IdlDefinedFields::Named(named)) } => named .iter() @@ -371,8 +366,6 @@ fn format_discriminator(d: &[u8; 8]) -> String { s } -/// Stringify an `IdlType` into a short, deterministic, frontend-friendly label. -/// Spec table lives in `docs/sdd/04-program-view-canonical/design.md`. fn format_idl_type(ty: &IdlType) -> String { match ty { IdlType::Bool => "bool".into(), @@ -411,8 +404,6 @@ fn format_idl_type(ty: &IdlType) -> String { } } IdlType::Generic(name) => name.clone(), - // The enum is `#[non_exhaustive]`; future variants render as Debug so - // we never crash but wire-format drift becomes obvious in snapshots. other => format!("{other:?}"), } } @@ -448,8 +439,6 @@ fn compute_coupling(ixs: &[IxView]) -> Vec<CouplingPair> { continue; } shared.sort(); - // Sort the pair members so (a, b) is canonical regardless of - // instruction declaration order in the IDL. let (a, b) = if ixs[i].name <= ixs[j].name { (ixs[i].name.clone(), ixs[j].name.clone()) } else { @@ -507,8 +496,6 @@ fn collect_system_accounts(ixs: &[IxView]) -> HashSet<String> { set.into_iter().collect() } -/// Render a `SeedView` as the compact CLI string used by `pda` and `info`. -/// Lives next to the wire format because it is a formatter over the wire type. pub fn describe_seed_view(seed: &SeedView) -> String { match seed { SeedView::Const { value_hex, value_utf8 } => match value_utf8 { @@ -598,8 +585,6 @@ mod tests { let initialize = view.instructions.iter().find(|i| i.name == "initialize").unwrap(); let system = initialize.accounts.iter().find(|a| a.name == "system_program").unwrap(); assert_eq!(system.kind, AccountKind::System); - // lever's "power" maps to account-type "PowerStatus" — snake_to_pascal - // gives "Power" which does not match, so it falls through to Other. let power = initialize.accounts.iter().find(|a| a.name == "power").unwrap(); assert_eq!(power.kind, AccountKind::Other); @@ -607,7 +592,6 @@ mod tests { let init_base = relations.instructions.iter().find(|i| i.name == "init_base").unwrap(); let pda_acc = init_base.accounts.iter().find(|a| a.name == "account").unwrap(); assert_eq!(pda_acc.kind, AccountKind::Pda); - // relations also has my_account → MyAccount (snake→pascal match). let test_relation = relations.instructions.iter().find(|i| i.name == "test_relation").unwrap(); let typed_acc = test_relation.accounts.iter().find(|a| a.name == "my_account").unwrap(); assert_eq!(typed_acc.kind, AccountKind::Program); @@ -626,7 +610,6 @@ mod tests { other => panic!("expected Const, got {other:?}"), } - // Tab + LF — both ASCII but neither is_ascii_graphic and neither is space. let non_graphic = SeedSpec::Const { value: vec![0x09, 0x0a], }; @@ -692,8 +675,6 @@ mod tests { #[test] fn compute_view_state_coupling_empty_when_no_writable_overlap() { let view = lever_program().compute_view(); - // lever has 2 instructions: initialize and switch_power. - // Both write `power`, so they MUST be coupled. let coupling = view.state_coupling.expect("state_coupling computed"); assert_eq!(coupling.len(), 1); assert_eq!(coupling[0].a, "initialize"); @@ -703,7 +684,6 @@ mod tests { #[test] fn compute_view_admin_gated_requires_both_signer_and_field() { - // lever has no admin field on PowerStatus — admin_gated must be empty. let view = lever_program().compute_view(); let gated = view.admin_gated.expect("admin_gated computed"); assert!(gated.is_empty(), "lever should not gate any ix"); @@ -741,8 +721,6 @@ mod tests { fn compute_view_admin_gated_marks_initialize_pool_on_staking() { let view = staking_program().compute_view(); let gated = view.admin_gated.expect("admin_gated computed"); - // Pool has admin: Pubkey AND both initialize_pool and add_rewards - // require an `admin` signer. Both must appear; stake/unstake must NOT. assert!(gated.contains("initialize_pool")); assert!(gated.contains("add_rewards")); assert!(!gated.contains("stake")); From f5a02736efc47c6b64d9ce3565f2d2faae41b98c Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Sun, 10 May 2026 12:08:09 +0200 Subject: [PATCH 092/115] feat(solana): enrich who with args, struct fields, ix and field lookups Resolve who query to AccountType, Instruction, or Field. AccountType lists ix with args and struct fields per match. Instruction lists touched accounts with type, struct, flags. Field identifies owner type and lists ix that write it (heuristic). WhoList shape extended via serde defaults; legacy consumers untouched. Regenerate staking_who_pool baseline snapshot consciously. --- crates/ilold-cli/src/explore.rs | 237 +++++++++++- .../src/exploration/commands.rs | 45 ++- .../src/exploration/execute.rs | 342 +++++++++++++++++- .../ilold-solana-core/src/exploration/mod.rs | 2 +- .../staking_who_pool.json | 59 ++- .../staking_who_user_stake.json | 37 +- .../tests/solana_command_pipeline.rs | 136 +++++++ 7 files changed, 825 insertions(+), 33 deletions(-) diff --git a/crates/ilold-cli/src/explore.rs b/crates/ilold-cli/src/explore.rs index 5e9e562..6040275 100644 --- a/crates/ilold-cli/src/explore.rs +++ b/crates/ilold-cli/src/explore.rs @@ -2422,24 +2422,28 @@ fn print_solana_result(result: &SolanaCommandResult) { println!(" {}", line); } } - SolanaCommandResult::WhoList { account_type, instructions } => { - if instructions.is_empty() { - println!(" {} no instruction references account_type '{}'", c_muted("·"), account_type); - } else { - println!(" {} '{}' referenced by:", c_accent("·"), c_accent(account_type)); - for w in instructions { - let mut flags = Vec::new(); - if w.signer { flags.push("signer"); } - if w.writable { flags.push("writable"); } - println!( - " {} {} (as {}) {}", - c_accent("·"), - c_accent(&w.instruction), - w.account_field, - c_muted(&flags.join(" ")) - ); - } - } + SolanaCommandResult::WhoList { + account_type, + instructions, + query_kind, + field_owner, + field_type, + owner_fields, + ix_args, + ix_discriminator_hex, + ix_accounts, + } => { + print_who_list( + account_type, + instructions, + *query_kind, + field_owner.as_deref(), + field_type.as_deref(), + owner_fields.as_deref(), + ix_args.as_deref(), + ix_discriminator_hex.as_deref(), + ix_accounts.as_deref(), + ); } SolanaCommandResult::TimelineView { pubkey, label, entries } => { let header = label.clone().unwrap_or_else(|| pubkey.clone()); @@ -2645,6 +2649,203 @@ fn print_account_types(accounts: &[ilold_solana_core::view::AccountView]) { } } +#[allow(clippy::too_many_arguments)] +fn print_who_list( + target: &str, + instructions: &[ilold_solana_core::exploration::commands::WhoEntry], + query_kind: ilold_solana_core::exploration::commands::WhoQueryKind, + field_owner: Option<&str>, + field_type: Option<&str>, + owner_fields: Option<&[ilold_solana_core::view::FieldView]>, + ix_args: Option<&[ilold_solana_core::view::ArgView]>, + ix_discriminator_hex: Option<&str>, + ix_accounts: Option<&[ilold_solana_core::exploration::commands::WhoIxAccount]>, +) { + use ilold_solana_core::exploration::commands::WhoQueryKind; + match query_kind { + WhoQueryKind::AccountType => { + println!( + " {} '{}' (account type)", + c_accent("·"), + c_accent(target) + ); + print_field_summary(owner_fields, "fields"); + println!(); + if instructions.is_empty() { + println!(" {}", c_muted("not referenced by any instruction")); + return; + } + println!( + " Referenced by {} instruction{}:", + instructions.len(), + if instructions.len() == 1 { "" } else { "s" } + ); + println!(); + for w in instructions { + print_who_entry_block(w); + } + } + WhoQueryKind::Field => { + let owner = field_owner.unwrap_or("?"); + let ty = field_type.unwrap_or("?"); + println!( + " {} '{}' (field of {}, type {})", + c_accent("·"), + c_accent(target), + c_accent(owner), + c_muted(ty) + ); + print_field_summary(owner_fields, &format!("{owner} struct")); + println!(); + println!( + " {}", + c_warn( + "Heuristic: the following instructions write the owner account." + ) + ); + println!( + " {}", + c_muted( + "Without source-level analysis we cannot tell which one(s)" + ) + ); + println!( + " {}", + c_muted("actually mutate this field; cross-check with `step <idx>`.") + ); + println!(); + if instructions.is_empty() { + println!(" {}", c_muted("(no writers found)")); + return; + } + for w in instructions { + print_who_entry_block(w); + } + } + WhoQueryKind::Instruction => { + println!( + " {} '{}' (instruction)", + c_accent("·"), + c_accent(target) + ); + match ix_args { + Some(args) if !args.is_empty() => { + let parts: Vec<String> = args + .iter() + .map(|a| format!("{}: {}", a.name, a.ty)) + .collect(); + println!(" args: {}", c_muted(&parts.join(", "))); + } + _ => println!(" {}", c_muted("args: (none)")), + } + if let Some(d) = ix_discriminator_hex { + println!(" {} {}", c_muted("discriminator"), d); + } + println!(); + let accounts = ix_accounts.unwrap_or(&[]); + if accounts.is_empty() { + println!(" {}", c_muted("touches no accounts")); + return; + } + println!( + " Touches {} account{}:", + accounts.len(), + if accounts.len() == 1 { "" } else { "s" } + ); + println!(); + for acc in accounts { + print_who_ix_account(acc); + } + } + WhoQueryKind::NotFound => { + println!( + " {} no instruction, account type or field references '{}'", + c_muted("·"), + target + ); + } + } +} + +fn print_field_summary( + fields: Option<&[ilold_solana_core::view::FieldView]>, + label: &str, +) { + match fields { + Some(fs) if !fs.is_empty() => { + let parts: Vec<String> = fs + .iter() + .map(|f| format!("{}: {}", f.name, f.ty)) + .collect(); + println!(" {}: {}", label, c_muted(&parts.join(", "))); + } + Some(_) => { + println!(" {}: {}", label, c_muted("(opaque or zero-copy layout)")); + } + None => {} + } +} + +fn print_who_entry_block(entry: &ilold_solana_core::exploration::commands::WhoEntry) { + let mut flags = Vec::new(); + if entry.signer { + flags.push("signer"); + } + if entry.writable { + flags.push("writable"); + } + let flags_str = flags.join(" "); + println!( + " {} {} (as {}) {}", + c_accent("·"), + c_accent(&entry.instruction), + entry.account_field, + c_muted(&flags_str) + ); + match entry.ix_args.as_ref() { + Some(args) if !args.is_empty() => { + let parts: Vec<String> = args + .iter() + .map(|a| format!("{}: {}", a.name, a.ty)) + .collect(); + println!(" args: {}", c_muted(&parts.join(", "))); + } + Some(_) => println!(" {}", c_muted("args: (none)")), + None => {} + } +} + +fn print_who_ix_account(acc: &ilold_solana_core::exploration::commands::WhoIxAccount) { + let type_label = acc + .account_type + .as_deref() + .map(|t| format!("({t})")) + .unwrap_or_else(|| "(—)".to_string()); + let mut flags = Vec::new(); + if acc.signer { + flags.push("signer"); + } + if acc.writable { + flags.push("writable"); + } + println!( + " {} {} {} {}", + c_accent("·"), + c_accent(&acc.name), + c_muted(&type_label), + c_muted(&flags.join(" ")) + ); + if let Some(fs) = acc.fields.as_ref() { + if !fs.is_empty() { + let parts: Vec<String> = fs + .iter() + .map(|f| format!("{}: {}", f.name, f.ty)) + .collect(); + println!(" struct: {}", c_muted(&parts.join(", "))); + } + } +} + fn sync_active_scenario( handle: &tokio::runtime::Handle, client: &reqwest::Client, diff --git a/crates/ilold-solana-core/src/exploration/commands.rs b/crates/ilold-solana-core/src/exploration/commands.rs index ed053ba..53f3d84 100644 --- a/crates/ilold-solana-core/src/exploration/commands.rs +++ b/crates/ilold-solana-core/src/exploration/commands.rs @@ -7,7 +7,7 @@ use ilold_session_core::journal::types::{ReviewStatus, Severity}; use serde::{Deserialize, Serialize}; use serde_json::Value; -use crate::view::{AccountView, CouplingPair, IxView}; +use crate::view::{AccountView, ArgView, CouplingPair, FieldView, IxView}; #[derive(Debug, Clone, Serialize, Deserialize)] pub enum SolanaCommand { @@ -253,6 +253,20 @@ pub enum SolanaCommandResult { WhoList { account_type: String, instructions: Vec<WhoEntry>, + #[serde(default)] + query_kind: WhoQueryKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + field_owner: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + field_type: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + owner_fields: Option<Vec<FieldView>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + ix_args: Option<Vec<ArgView>>, + #[serde(default, skip_serializing_if = "Option::is_none")] + ix_discriminator_hex: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] + ix_accounts: Option<Vec<WhoIxAccount>>, }, TimelineView { pubkey: String, @@ -305,6 +319,35 @@ pub struct WhoEntry { pub account_field: String, pub writable: bool, pub signer: bool, + /// Resolved Anchor account type (e.g. "Pool"). None for system / sysvar / + /// program / unknown accounts. Lets the renderer show "(as pool: Pool)". + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account_type: Option<String>, + /// Args of the instruction this entry references. Useful when the auditor + /// landed on this entry via an AccountType or Field query and wants to see + /// what knobs each ix exposes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ix_args: Option<Vec<ArgView>>, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] +pub enum WhoQueryKind { + #[default] + AccountType, + Field, + Instruction, + NotFound, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WhoIxAccount { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub account_type: Option<String>, + pub writable: bool, + pub signer: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fields: Option<Vec<FieldView>>, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/ilold-solana-core/src/exploration/execute.rs b/crates/ilold-solana-core/src/exploration/execute.rs index 64931e0..2b22325 100644 --- a/crates/ilold-solana-core/src/exploration/execute.rs +++ b/crates/ilold-solana-core/src/exploration/execute.rs @@ -16,7 +16,7 @@ use crate::view::describe_seed_view; use super::add_step::add_solana_step; use super::commands::{ AccountSummary, FindingSummary, InstructionEntry, PdaEntry, SolanaCommandResult, - StepDiffSummary, TimelineEntry, UserEntry, WhoEntry, + StepDiffSummary, TimelineEntry, UserEntry, WhoEntry, WhoIxAccount, WhoQueryKind, }; const DEFAULT_USER_LAMPORTS: u64 = 10_000_000_000; @@ -683,29 +683,197 @@ where pub fn execute_who( program: &ProgramDef, - account_type: &str, + query: &str, ) -> SolanaCommandResult { let view = program.compute_view(); - let target = account_type.to_string(); + let raw = query.trim(); + + let resolved_account_type = view + .accounts + .iter() + .find(|a| a.name == raw) + .map(|a| a.name.clone()) + .or_else(|| { + let pascal = crate::view::snake_to_pascal(raw); + view.accounts + .iter() + .find(|a| a.name == pascal) + .map(|a| a.name.clone()) + }); + + if let Some(name) = resolved_account_type { + return who_for_account_type(&view, &name); + } + + if let Some(ix) = view.instructions.iter().find(|i| i.name == raw) { + return who_for_instruction(&view, ix); + } + + if let Some((owner_name, field)) = find_field_owner(&view, raw) { + return who_for_field(&view, raw, &owner_name, &field); + } + + SolanaCommandResult::WhoList { + account_type: raw.to_string(), + instructions: Vec::new(), + query_kind: WhoQueryKind::NotFound, + field_owner: None, + field_type: None, + owner_fields: None, + ix_args: None, + ix_discriminator_hex: None, + ix_accounts: None, + } +} + +fn who_for_account_type( + view: &crate::view::ProgramView, + type_name: &str, +) -> SolanaCommandResult { + let mut hits: Vec<WhoEntry> = Vec::new(); + for ix in &view.instructions { + for acc in &ix.accounts { + let resolved = resolve_account_type(view, &acc.name); + if resolved.as_deref() == Some(type_name) { + hits.push(WhoEntry { + instruction: ix.name.clone(), + account_field: acc.name.clone(), + writable: acc.writable, + signer: acc.signer, + account_type: resolved, + ix_args: Some(ix.args.clone()), + }); + } + } + } + hits.sort_by(|a, b| a.instruction.cmp(&b.instruction)); + let owner_fields = view + .accounts + .iter() + .find(|a| a.name == type_name) + .map(|a| a.fields.clone()); + SolanaCommandResult::WhoList { + account_type: type_name.to_string(), + instructions: hits, + query_kind: WhoQueryKind::AccountType, + field_owner: None, + field_type: None, + owner_fields, + ix_args: None, + ix_discriminator_hex: None, + ix_accounts: None, + } +} + +fn who_for_instruction( + view: &crate::view::ProgramView, + ix: &crate::view::IxView, +) -> SolanaCommandResult { + let accounts: Vec<WhoIxAccount> = ix + .accounts + .iter() + .map(|acc| { + let resolved = resolve_account_type(view, &acc.name); + let fields = resolved.as_ref().and_then(|t| { + view.accounts + .iter() + .find(|a| &a.name == t) + .map(|a| a.fields.clone()) + }); + WhoIxAccount { + name: acc.name.clone(), + account_type: resolved, + writable: acc.writable, + signer: acc.signer, + fields, + } + }) + .collect(); + SolanaCommandResult::WhoList { + account_type: ix.name.clone(), + instructions: Vec::new(), + query_kind: WhoQueryKind::Instruction, + field_owner: None, + field_type: None, + owner_fields: None, + ix_args: Some(ix.args.clone()), + ix_discriminator_hex: Some(ix.discriminator_hex.clone()), + ix_accounts: Some(accounts), + } +} + +fn who_for_field( + view: &crate::view::ProgramView, + field_name: &str, + owner: &str, + field: &crate::view::FieldView, +) -> SolanaCommandResult { + // Heuristic: without source-level analysis we list every ix that touches + // the owner account-type as writable. The renderer must surface this. let mut hits: Vec<WhoEntry> = Vec::new(); for ix in &view.instructions { for acc in &ix.accounts { - if crate::view::snake_to_pascal(&acc.name) == target || acc.name == target { + let resolved = resolve_account_type(view, &acc.name); + if resolved.as_deref() == Some(owner) && acc.writable { hits.push(WhoEntry { instruction: ix.name.clone(), account_field: acc.name.clone(), writable: acc.writable, signer: acc.signer, + account_type: resolved, + ix_args: Some(ix.args.clone()), }); + break; } } } + hits.sort_by(|a, b| a.instruction.cmp(&b.instruction)); + let owner_fields = view + .accounts + .iter() + .find(|a| a.name == owner) + .map(|a| a.fields.clone()); SolanaCommandResult::WhoList { - account_type: target, + account_type: field_name.to_string(), instructions: hits, + query_kind: WhoQueryKind::Field, + field_owner: Some(owner.to_string()), + field_type: Some(field.ty.clone()), + owner_fields, + ix_args: None, + ix_discriminator_hex: None, + ix_accounts: None, } } +fn find_field_owner( + view: &crate::view::ProgramView, + field_name: &str, +) -> Option<(String, crate::view::FieldView)> { + // Stable iteration order: AccountView vector preserves IDL order, which is + // the order ProgramDef::from_idl emitted. That is deterministic per-IDL. + for acc in &view.accounts { + if let Some(f) = acc.fields.iter().find(|f| f.name == field_name) { + return Some((acc.name.clone(), f.clone())); + } + } + None +} + +fn resolve_account_type( + view: &crate::view::ProgramView, + account_name: &str, +) -> Option<String> { + let pascal = crate::view::snake_to_pascal(account_name); + if view.accounts.iter().any(|a| a.name == pascal) { + return Some(pascal); + } + if view.accounts.iter().any(|a| a.name == account_name) { + return Some(account_name.to_string()); + } + None +} + pub fn execute_timeline( session: &ExplorationSession, program: &ProgramDef, @@ -774,3 +942,167 @@ pub fn execute_timeline( entries, } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::idl::parse_idl; + + const STAKING_JSON: &str = include_str!( + "../../../../tests/fixtures/solana/staking/idls/staking.json" + ); + const LEVER_JSON: &str = include_str!("../../tests/fixtures/lever.json"); + + fn staking() -> ProgramDef { + ProgramDef::from_idl(parse_idl(STAKING_JSON).expect("parse staking")) + .expect("build staking ProgramDef") + } + + fn lever() -> ProgramDef { + ProgramDef::from_idl(parse_idl(LEVER_JSON).expect("parse lever")) + .expect("build lever ProgramDef") + } + + fn unwrap_who( + result: SolanaCommandResult, + ) -> ( + String, + Vec<WhoEntry>, + WhoQueryKind, + Option<String>, + Option<Vec<crate::view::FieldView>>, + Option<Vec<crate::exploration::commands::WhoIxAccount>>, + Option<Vec<crate::view::ArgView>>, + ) { + match result { + SolanaCommandResult::WhoList { + account_type, + instructions, + query_kind, + field_owner, + owner_fields, + ix_accounts, + ix_args, + .. + } => ( + account_type, + instructions, + query_kind, + field_owner, + owner_fields, + ix_accounts, + ix_args, + ), + other => panic!("expected WhoList, got {other:?}"), + } + } + + #[test] + fn who_resolves_account_type_pool() { + let (target, hits, kind, owner, fields, accounts, _) = + unwrap_who(execute_who(&staking(), "Pool")); + assert_eq!(target, "Pool"); + assert_eq!(kind, WhoQueryKind::AccountType); + assert!(owner.is_none()); + assert_eq!(hits.len(), 5); + let names: Vec<_> = hits.iter().map(|w| w.instruction.as_str()).collect(); + assert_eq!( + names, + vec!["add_rewards", "claim_rewards", "initialize_pool", "stake", "unstake"] + ); + // Sorted alphabetically for snapshot stability. + assert!(hits.iter().all(|w| w.account_type.as_deref() == Some("Pool"))); + assert!(hits.iter().all(|w| w.ix_args.is_some())); + let pool_fields = fields.expect("Pool fields populated"); + assert!(pool_fields.iter().any(|f| f.name == "total_staked")); + assert!(accounts.is_none()); + } + + #[test] + fn who_resolves_lowercase_account_type() { + let (target, hits, kind, ..) = unwrap_who(execute_who(&staking(), "pool")); + assert_eq!(target, "Pool"); + assert_eq!(kind, WhoQueryKind::AccountType); + assert_eq!(hits.len(), 5); + } + + #[test] + fn who_resolves_instruction_claim_rewards() { + let (target, hits, kind, owner, fields, accounts, args) = + unwrap_who(execute_who(&staking(), "claim_rewards")); + assert_eq!(target, "claim_rewards"); + assert_eq!(kind, WhoQueryKind::Instruction); + assert!(hits.is_empty()); + assert!(owner.is_none()); + assert!(fields.is_none()); + let accs = accounts.expect("ix_accounts populated"); + assert!(accs.iter().any(|a| a.name == "pool" && a.account_type.as_deref() == Some("Pool"))); + assert!(accs + .iter() + .any(|a| a.name == "user_stake" && a.account_type.as_deref() == Some("UserStake"))); + // The 'user' signer maps to no account type — must not crash, must be None. + assert!(accs + .iter() + .any(|a| a.name == "user" && a.account_type.is_none() && a.signer)); + assert!(args.is_some()); + } + + #[test] + fn who_resolves_field_total_staked() { + let (target, hits, kind, owner, fields, accounts, _) = + unwrap_who(execute_who(&staking(), "total_staked")); + assert_eq!(target, "total_staked"); + assert_eq!(kind, WhoQueryKind::Field); + assert_eq!(owner.as_deref(), Some("Pool")); + assert!(accounts.is_none()); + let pool_fields = fields.expect("owner_fields present"); + assert!(pool_fields.iter().any(|f| f.name == "total_staked")); + // All 5 ix that touch Pool as writable. + let names: Vec<_> = hits.iter().map(|w| w.instruction.as_str()).collect(); + assert_eq!( + names, + vec!["add_rewards", "claim_rewards", "initialize_pool", "stake", "unstake"] + ); + } + + #[test] + fn who_returns_not_found_for_unknown_query() { + let (target, hits, kind, owner, ..) = + unwrap_who(execute_who(&staking(), "nonexistent")); + assert_eq!(target, "nonexistent"); + assert_eq!(kind, WhoQueryKind::NotFound); + assert!(hits.is_empty()); + assert!(owner.is_none()); + } + + #[test] + fn who_field_returns_no_writers_when_account_name_does_not_map() { + // Edge case: lever declares the IDL account as `power` (snake) but the + // type is `PowerStatus` — snake_to_pascal("power") = "Power" ≠ + // "PowerStatus". Without source-level analysis we can't bridge that gap, + // so the heuristic must surface zero writers for `is_on` rather than + // guessing. This is a known limitation we surface honestly. + let (target, hits, kind, owner, fields, ..) = + unwrap_who(execute_who(&lever(), "is_on")); + assert_eq!(target, "is_on"); + assert_eq!(kind, WhoQueryKind::Field); + assert_eq!(owner.as_deref(), Some("PowerStatus")); + assert!(hits.is_empty(), "no field-name-to-type bridge available"); + assert!(fields.is_some()); + } + + #[test] + fn who_instruction_handles_system_program_account_kind() { + let (_, _, kind, _, _, accounts, _) = + unwrap_who(execute_who(&lever(), "initialize")); + assert_eq!(kind, WhoQueryKind::Instruction); + let accs = accounts.expect("ix_accounts populated"); + let sys = accs + .iter() + .find(|a| a.name == "system_program") + .expect("system_program present"); + assert!(sys.account_type.is_none()); + assert!(!sys.signer && !sys.writable); + assert!(sys.fields.is_none()); + } +} diff --git a/crates/ilold-solana-core/src/exploration/mod.rs b/crates/ilold-solana-core/src/exploration/mod.rs index a21c498..a206b77 100644 --- a/crates/ilold-solana-core/src/exploration/mod.rs +++ b/crates/ilold-solana-core/src/exploration/mod.rs @@ -5,7 +5,7 @@ pub mod execute; pub use add_step::add_solana_step; pub use commands::{ canvas_patch_from_solana, AccountSummary, InstructionEntry, PdaEntry, SolanaCommand, - SolanaCommandResult, UserEntry, + SolanaCommandResult, UserEntry, WhoEntry, WhoIxAccount, WhoQueryKind, }; pub use execute::{ execute_airdrop, execute_back, execute_call, execute_clear, execute_coupling, diff --git a/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_who_pool.json b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_who_pool.json index 1016c7e..685d9bb 100644 --- a/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_who_pool.json +++ b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_who_pool.json @@ -6,31 +6,80 @@ "instruction": "add_rewards", "account_field": "pool", "writable": true, - "signer": false + "signer": false, + "account_type": "Pool", + "ix_args": [ + { + "name": "amount", + "ty": "u64" + } + ] }, { "instruction": "claim_rewards", "account_field": "pool", "writable": true, - "signer": false + "signer": false, + "account_type": "Pool", + "ix_args": [] }, { "instruction": "initialize_pool", "account_field": "pool", "writable": true, - "signer": true + "signer": true, + "account_type": "Pool", + "ix_args": [ + { + "name": "reward_rate", + "ty": "u64" + } + ] }, { "instruction": "stake", "account_field": "pool", "writable": true, - "signer": false + "signer": false, + "account_type": "Pool", + "ix_args": [ + { + "name": "amount", + "ty": "u64" + } + ] }, { "instruction": "unstake", "account_field": "pool", "writable": true, - "signer": false + "signer": false, + "account_type": "Pool", + "ix_args": [ + { + "name": "amount", + "ty": "u64" + } + ] + } + ], + "query_kind": "AccountType", + "owner_fields": [ + { + "name": "admin", + "ty": "Pubkey" + }, + { + "name": "total_staked", + "ty": "u64" + }, + { + "name": "total_rewards", + "ty": "u64" + }, + { + "name": "reward_rate", + "ty": "u64" } ] } diff --git a/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_who_user_stake.json b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_who_user_stake.json index 4cbbbe3..802f8d4 100644 --- a/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_who_user_stake.json +++ b/crates/ilold-solana-core/tests/snapshots/funcs_who_pda_baseline/staking_who_user_stake.json @@ -6,19 +6,50 @@ "instruction": "claim_rewards", "account_field": "user_stake", "writable": true, - "signer": false + "signer": false, + "account_type": "UserStake", + "ix_args": [] }, { "instruction": "stake", "account_field": "user_stake", "writable": true, - "signer": true + "signer": true, + "account_type": "UserStake", + "ix_args": [ + { + "name": "amount", + "ty": "u64" + } + ] }, { "instruction": "unstake", "account_field": "user_stake", "writable": true, - "signer": false + "signer": false, + "account_type": "UserStake", + "ix_args": [ + { + "name": "amount", + "ty": "u64" + } + ] + } + ], + "query_kind": "AccountType", + "owner_fields": [ + { + "name": "user", + "ty": "Pubkey" + }, + { + "name": "amount", + "ty": "u64" + }, + { + "name": "claimed_rewards", + "ty": "u64" } ] } diff --git a/crates/ilold-web/tests/solana_command_pipeline.rs b/crates/ilold-web/tests/solana_command_pipeline.rs index 57bb090..8605c35 100644 --- a/crates/ilold-web/tests/solana_command_pipeline.rs +++ b/crates/ilold-web/tests/solana_command_pipeline.rs @@ -11,6 +11,23 @@ fn cpi_fixture() -> PathBuf { .join("tests/fixtures/solana/cpi") } +fn staking_fixture() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("tests/fixtures/solana/staking") +} + +async fn start_staking() -> (reqwest::Client, u16) { + let detected = detect(&staking_fixture()).expect("detect staking fixture"); + let (_state, port) = ilold_web::start_solana_server(detected, 0) + .await + .expect("start solana server"); + (reqwest::Client::new(), port) +} + async fn start_solana() -> (reqwest::Client, u16) { let detected = detect(&cpi_fixture()).expect("detect cpi fixture"); let (_state, port) = ilold_web::start_solana_server(detected, 0) @@ -436,3 +453,122 @@ async fn solana_vars_returns_account_types() { Some("bool") ); } + +#[tokio::test] +async fn solana_who_account_type_pool_returns_writers() { + let (client, port) = start_staking().await; + let result = cmd( + &client, + port, + "staking", + serde_json::json!({"Who": {"account_type": "Pool"}}), + ) + .await; + let who = result.get("WhoList").expect("WhoList variant"); + assert_eq!( + who.get("query_kind").and_then(|v| v.as_str()), + Some("AccountType") + ); + assert_eq!( + who.get("account_type").and_then(|v| v.as_str()), + Some("Pool") + ); + let ixs = who + .get("instructions") + .and_then(|v| v.as_array()) + .expect("instructions array"); + assert_eq!(ixs.len(), 5); + let names: Vec<_> = ixs + .iter() + .filter_map(|i| i.get("instruction").and_then(|v| v.as_str())) + .collect(); + assert_eq!( + names, + vec!["add_rewards", "claim_rewards", "initialize_pool", "stake", "unstake"] + ); + let fields = who + .get("owner_fields") + .and_then(|v| v.as_array()) + .expect("owner_fields populated for AccountType"); + assert!(fields.iter().any(|f| f.get("name").and_then(|v| v.as_str()) == Some("total_staked"))); +} + +#[tokio::test] +async fn solana_who_field_total_staked_marks_owner_pool() { + let (client, port) = start_staking().await; + let result = cmd( + &client, + port, + "staking", + serde_json::json!({"Who": {"account_type": "total_staked"}}), + ) + .await; + let who = result.get("WhoList").expect("WhoList variant"); + assert_eq!(who.get("query_kind").and_then(|v| v.as_str()), Some("Field")); + assert_eq!( + who.get("field_owner").and_then(|v| v.as_str()), + Some("Pool") + ); + let ixs = who + .get("instructions") + .and_then(|v| v.as_array()) + .expect("instructions array"); + assert_eq!(ixs.len(), 5); +} + +#[tokio::test] +async fn solana_who_instruction_claim_rewards_lists_accounts() { + let (client, port) = start_staking().await; + let result = cmd( + &client, + port, + "staking", + serde_json::json!({"Who": {"account_type": "claim_rewards"}}), + ) + .await; + let who = result.get("WhoList").expect("WhoList variant"); + assert_eq!( + who.get("query_kind").and_then(|v| v.as_str()), + Some("Instruction") + ); + let accs = who + .get("ix_accounts") + .and_then(|v| v.as_array()) + .expect("ix_accounts populated"); + let pool = accs + .iter() + .find(|a| a.get("name").and_then(|v| v.as_str()) == Some("pool")) + .expect("pool account in ix_accounts"); + assert_eq!( + pool.get("account_type").and_then(|v| v.as_str()), + Some("Pool") + ); + assert_eq!(pool.get("writable").and_then(|v| v.as_bool()), Some(true)); + let user = accs + .iter() + .find(|a| a.get("name").and_then(|v| v.as_str()) == Some("user")) + .expect("user signer in ix_accounts"); + assert!(user.get("account_type").is_none() || user.get("account_type").map(|v| v.is_null()).unwrap_or(false)); +} + +#[tokio::test] +async fn solana_who_unknown_query_returns_not_found() { + let (client, port) = start_staking().await; + let result = cmd( + &client, + port, + "staking", + serde_json::json!({"Who": {"account_type": "foo"}}), + ) + .await; + let who = result.get("WhoList").expect("WhoList variant"); + assert_eq!( + who.get("query_kind").and_then(|v| v.as_str()), + Some("NotFound") + ); + let ixs = who + .get("instructions") + .and_then(|v| v.as_array()) + .expect("instructions array"); + assert!(ixs.is_empty()); +} From 0263954168ef1ea2f28d187a010dc4bc6b15499a Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Sun, 10 May 2026 12:16:29 +0200 Subject: [PATCH 093/115] feat(cli): structured ? help with purpose, syntax, examples, returns Add a help.rs module with one HelpBlock per Solana REPL command and render a Purpose / Syntax / Flags / Examples / Returns / See also reference whenever an auditor (or an LLM agent) appends ? to a command name; the inline-? handler now lives in handle_solana_input via inline_help_target which also accepts the spaced form (cmd ?), the menu hint mentions the new full-reference form, and a coverage test enumerates every command alias to keep the help table in lockstep with the dispatch match. --- crates/ilold-cli/src/explore.rs | 32 +- crates/ilold-cli/src/help.rs | 632 ++++++++++++++++++++++++++++++++ crates/ilold-cli/src/main.rs | 1 + 3 files changed, 662 insertions(+), 3 deletions(-) create mode 100644 crates/ilold-cli/src/help.rs diff --git a/crates/ilold-cli/src/explore.rs b/crates/ilold-cli/src/explore.rs index 6040275..da7dc0f 100644 --- a/crates/ilold-cli/src/explore.rs +++ b/crates/ilold-cli/src/explore.rs @@ -1118,6 +1118,19 @@ fn handle_input( } } +fn inline_help_target(cmd: &str, arg: &str) -> Option<String> { + if cmd == "?" || cmd == "help" || cmd == "h" { + return None; + } + if cmd.ends_with('?') && cmd.len() > 1 { + return Some(cmd[..cmd.len() - 1].to_string()); + } + if arg.trim() == "?" { + return Some(cmd.to_string()); + } + None +} + #[allow(clippy::too_many_arguments)] fn handle_solana_input( line: &str, @@ -1133,6 +1146,18 @@ fn handle_solana_input( let cmd = parts[0].to_lowercase(); let arg = parts.get(1).map(|s| s.trim()).unwrap_or(""); + if let Some(base) = inline_help_target(&cmd, arg) { + match crate::help::render_solana_help_block(&base) { + Some(text) => print!("{text}"), + None => println!( + " {} no help registered for {}", + c_danger("✗"), + c_accent(&base) + ), + } + return InputResult::Continue; + } + match cmd.as_str() { "?" | "help" | "h" => { print_solana_help(); @@ -3821,8 +3846,9 @@ fn print_help_for(backend: BackendKind) { ]), ("Analysis", &[ ("st", "step <index>", "Re-inspect a step: CU, logs, diffs"), - ("", "who <account_type>", "List instructions referencing this account type (e.g. who Pool)"), + ("", "who <query>", "Resolve query: AccountType | Instruction | Field"), ("tl", "timeline <pubkey>", "Cross-step mutation history of an account, decoded"), + ("cp", "coupling", "List ix pairs that share writable accounts"), ]), ("Findings", &[ ("fi", "finding <sev> <title>", "Record a security finding"), @@ -3830,12 +3856,12 @@ fn print_help_for(backend: BackendKind) { ("n", "note <text>", "Add annotation to active sequence"), ("", "status <ix> <s>", "Set review status: open|reviewed|finding"), ("ex", "export", "Markdown report: sequence + findings + program info"), - ("sc", "scenario <sub>", "new|list|switch|fork|delete <name> [step]"), ]), ("Workspace", &[ + ("sc", "scenario <sub>", "new|list|switch|fork|delete <name> [step]"), ("", "save <name>", "Save active scenario JSON to disk"), ("", "load <name>", "Load scenario JSON from disk"), - ("?", "help", "Print this help"), + ("?", "help", "Print this help (append ? to any cmd for full reference)"), ("q", "quit/exit", "Exit"), ]), ], diff --git a/crates/ilold-cli/src/help.rs b/crates/ilold-cli/src/help.rs new file mode 100644 index 0000000..13b258e --- /dev/null +++ b/crates/ilold-cli/src/help.rs @@ -0,0 +1,632 @@ +use crate::colors::{c_accent, c_bright, c_muted}; + +pub struct HelpBlock { + pub title: &'static str, + pub aliases: &'static [&'static str], + pub purpose: &'static str, + pub syntax: &'static [(&'static str, &'static str)], + pub flags: &'static [(&'static str, &'static str)], + pub examples: &'static [(&'static str, &'static str)], + pub returns: &'static str, + pub see_also: &'static [&'static str], +} + +pub const SOLANA_HELP_BLOCKS: &[HelpBlock] = &[ + HelpBlock { + title: "c | call", + aliases: &["c", "call"], + purpose: "Run an Anchor instruction against the LiteSVM and append the result as a step on the active scenario.", + syntax: &[ + ("c <ix> arg=val acc=user", "Concise key=value form (signers auto-resolved from IDL)"), + ("c <ix> {json}", "Full JSON form: {\"args\":{...},\"accounts\":{...},\"signers\":[...]}"), + ], + flags: &[ + ("--signer=a,b", "Force these accounts to sign (override IDL defaults)"), + ("--no-signer=name", "Remove a default signer (test negative cases)"), + ], + examples: &[ + ("c stake amount=1000 pool=pool user_stake=alice_stake user=alice", "Stake 1000 from alice"), + ("c initialize_pool reward_rate=10 pool=pool admin=admin", "Bootstrap a pool"), + ("c stake {\"args\":{\"amount\":1000},\"accounts\":{\"pool\":\"pool\",\"user_stake\":\"alice_stake\",\"user\":\"alice\"}}", "Same call in JSON form"), + ], + returns: "StepAdded { step_index, instruction, logs_excerpt, account_diffs_count, compute_units } on success, or CallFailed { instruction, logs_excerpt, compute_units, error } when the VM rejects.", + see_also: &["info", "pda", "state", "step", "back"], + }, + HelpBlock { + title: "b | back", + aliases: &["b", "back"], + purpose: "Remove the last step from the active scenario and rewind the VM to that point.", + syntax: &[("b", "")], + flags: &[], + examples: &[("b", "Drop the most recent call before resuming exploration")], + returns: "ScenarioUpdated with the truncated step list.", + see_also: &["clear", "step", "session"], + }, + HelpBlock { + title: "cl | clear", + aliases: &["cl", "clear"], + purpose: "Reset the active scenario steps and the underlying VM state.", + syntax: &[("cl", "")], + flags: &[], + examples: &[("cl", "Wipe the timeline before starting a new attack flow")], + returns: "ScenarioUpdated with an empty step list.", + see_also: &["back", "scenario", "session"], + }, + HelpBlock { + title: "state", + aliases: &["state"], + purpose: "Show the decoded view of every account mutated during the active scenario.", + syntax: &[("state", "")], + flags: &[], + examples: &[("state", "Inspect post-step balances and PDA contents at the latest step")], + returns: "StateView { accounts: [{ pubkey, decoded_fields, owner_program, ... }] }.", + see_also: &["timeline", "inspect", "session"], + }, + HelpBlock { + title: "s | session", + aliases: &["s", "session"], + purpose: "Print the active scenario summary: ordered steps, findings, notes.", + syntax: &[("s", "")], + flags: &[], + examples: &[("s", "Recap what has been called so far and which findings are attached")], + returns: "SessionView { scenario_name, steps, findings, notes }.", + see_also: &["scenario", "state", "findings"], + }, + HelpBlock { + title: "ct | programs | contracts", + aliases: &["ct", "programs", "progs", "contracts"], + purpose: "List every program detected in the workspace (multi-program Anchor workspaces included).", + syntax: &[("ct", "")], + flags: &[], + examples: &[("ct", "Discover the available programs before switching with use")], + returns: "Plain list of program names with the active one marked.", + see_also: &["use", "funcs", "vars"], + }, + HelpBlock { + title: "use", + aliases: &["use"], + purpose: "Switch the active program so subsequent commands target it.", + syntax: &[("use <program>", "")], + flags: &[], + examples: &[("use staking", "Make staking the active program")], + returns: "Updates the prompt label and the completer source.", + see_also: &["programs", "funcs", "vars"], + }, + HelpBlock { + title: "f | funcs | functions", + aliases: &["f", "funcs", "functions"], + purpose: "List the instructions exposed by the active program.", + syntax: &[("f", "")], + flags: &[], + examples: &[("f", "Enumerate callable instructions with arg/account counts")], + returns: "FuncsList { instructions: [{ name, arg_count, account_count, signer_count, pda_count }] }.", + see_also: &["funcs-all", "info", "vars"], + }, + HelpBlock { + title: "fa | funcs-all", + aliases: &["fa", "funcs-all"], + purpose: "List instructions with full counts plus admin-gating and coupling hints (T-R50 ProgramView).", + syntax: &[("fa", "")], + flags: &[], + examples: &[("fa", "Spot admin-only entry points and shared-writable couplings at a glance")], + returns: "FuncsAll { instructions: [{ name, args, accounts, signers, pdas, admin_gated, couples_with }] }.", + see_also: &["funcs", "info", "coupling"], + }, + HelpBlock { + title: "i | info", + aliases: &["i", "info"], + purpose: "Detail an instruction: typed args, accounts with flags, signers, PDAs, discriminator.", + syntax: &[("i <ix>", "")], + flags: &[], + examples: &[ + ("i stake", "Inspect the stake instruction in full"), + ("info initialize_pool", ""), + ], + returns: "InstructionDetail { name, args, accounts, signers, pdas, discriminator }.", + see_also: &["funcs-all", "pda", "who", "call"], + }, + HelpBlock { + title: "v | vars", + aliases: &["v", "vars", "vars-all", "va"], + purpose: "List declared account types with their Anchor discriminators.", + syntax: &[ + ("v", "Compact view"), + ("va", "Same as v in current Solana backend (full layout)"), + ], + flags: &[], + examples: &[("v", "List Pool, UserStake, etc. with discriminators")], + returns: "VarsList { account_types: [{ name, discriminator, fields }] }.", + see_also: &["who", "inspect", "funcs"], + }, + HelpBlock { + title: "users", + aliases: &["users"], + purpose: "Manage the keypair set in the active scenario: list existing users or mint a new one.", + syntax: &[ + ("users", "List keypairs"), + ("users new <name> [lamports]", "Create keypair and airdrop (default 10 SOL)"), + ], + flags: &[], + examples: &[ + ("users", "Show all named keypairs"), + ("users new alice", "Create alice with 10 SOL"), + ("users new bob 5000000000", "Create bob with 5 SOL"), + ], + returns: "UsersList { users: [{ name, pubkey, lamports }] } or UsersUpdated.", + see_also: &["airdrop", "inspect", "scenario"], + }, + HelpBlock { + title: "airdrop", + aliases: &["airdrop", "air"], + purpose: "Top up an existing keypair with extra lamports.", + syntax: &[("airdrop <user> <lamports>", "")], + flags: &[], + examples: &[("airdrop alice 1000000000", "Add 1 SOL to alice")], + returns: "UsersUpdated reflecting the new balance.", + see_also: &["users", "inspect"], + }, + HelpBlock { + title: "tw | time-warp", + aliases: &["tw", "time-warp"], + purpose: "Advance (or rewind) the Clock sysvar so vesting / reward / lockup logic can be exercised.", + syntax: &[("tw <delta_seconds>", "Positive moves forward, negative back")], + flags: &[], + examples: &[ + ("tw 86400", "Skip one day"), + ("tw -3600", "Rewind one hour"), + ], + returns: "ClockUpdated { unix_timestamp, slot }.", + see_also: &["state", "call"], + }, + HelpBlock { + title: "pda", + aliases: &["pda"], + purpose: "List the PDAs declared by an instruction (Anchor seeds, derived addresses).", + syntax: &[("pda <ix>", "")], + flags: &[], + examples: &[("pda stake", "See seeds + bump seeds for the stake instruction")], + returns: "PdaList { instruction, pdas: [{ name, seeds, bump }] }.", + see_also: &["info", "inspect", "call"], + }, + HelpBlock { + title: "inspect", + aliases: &["inspect", "acc"], + purpose: "Read a VM account by pubkey and decode it via the Anchor discriminator.", + syntax: &[("inspect <pubkey>", "")], + flags: &[], + examples: &[ + ("inspect alice", "Decode alice's PDA / wallet"), + ("inspect 6Yg7...", "Decode by raw base58 pubkey"), + ], + returns: "AccountView { pubkey, owner, lamports, data_decoded }.", + see_also: &["state", "timeline", "vars"], + }, + HelpBlock { + title: "st | step", + aliases: &["st", "step"], + purpose: "Re-inspect a specific step of the active scenario: CU, logs, decoded diffs.", + syntax: &[("st <index>", "")], + flags: &[], + examples: &[ + ("st 0", "Inspect the first step"), + ("step 3", ""), + ], + returns: "StepDetail { index, instruction, logs, account_diffs, compute_units }.", + see_also: &["session", "state", "timeline"], + }, + HelpBlock { + title: "who", + aliases: &["who"], + purpose: "Resolve a query against the IDL: account type, instruction, or struct field.", + syntax: &[("who <AccountType|ix_name|field_name>", "")], + flags: &[], + examples: &[ + ("who Pool", "AccountType: list ix that touch it with args+fields"), + ("who pool", "Same — case-insensitive snake_to_pascal fallback"), + ("who claim_rewards", "Instruction: list accounts it touches with type+fields"), + ("who total_staked", "Field: identify owner type, list ix that write it (heuristic)"), + ], + returns: "WhoList { query_kind: AccountType|Instruction|Field|NotFound, ... }.", + see_also: &["info", "funcs", "vars", "coupling"], + }, + HelpBlock { + title: "tl | timeline", + aliases: &["tl", "timeline"], + purpose: "Show the cross-step mutation history of an account, decoded.", + syntax: &[("tl <pubkey>", "Pubkey or named keypair")], + flags: &[], + examples: &[ + ("tl alice", "Trace alice across every step"), + ("timeline pool", "Watch the pool PDA evolve"), + ], + returns: "Timeline { pubkey, entries: [{ step, decoded_fields, diff }] }.", + see_also: &["state", "inspect", "step"], + }, + HelpBlock { + title: "coupling | cp", + aliases: &["coupling", "cp"], + purpose: "List instruction pairs that share a writable account (coupling heuristic from T-R50).", + syntax: &[("coupling", "")], + flags: &[], + examples: &[("coupling", "Surface ix that may interfere via shared writable state")], + returns: "CouplingList { pairs: [{ ix_a, ix_b, shared_writable: [..] }] }.", + see_also: &["who", "funcs-all", "info"], + }, + HelpBlock { + title: "fi | finding", + aliases: &["fi", "finding"], + purpose: "Record a security finding tied to the latest step of the active scenario.", + syntax: &[("fi <severity> <title>", "")], + flags: &[ + ("--rec=\"...\"", "Optional remediation recommendation (quote it if it has spaces)"), + ], + examples: &[ + ("fi high reentrancy via stake", "Severity + free-form title"), + ("finding critical missing signer --rec=\"require admin signature\"", ""), + ], + returns: "FindingRecorded { id, severity, title, step_index }.", + see_also: &["findings", "note", "status", "export"], + }, + HelpBlock { + title: "fl | findings", + aliases: &["fl", "findings"], + purpose: "List every finding recorded in the active scenario.", + syntax: &[("fl", "")], + flags: &[], + examples: &[("fl", "")], + returns: "FindingsList { findings: [{ id, severity, title, step_index, recommendation }] }.", + see_also: &["finding", "export", "session"], + }, + HelpBlock { + title: "n | note", + aliases: &["n", "note"], + purpose: "Attach a free-form annotation to the active scenario.", + syntax: &[("n <text>", "")], + flags: &[], + examples: &[("n suspicious admin path here", "Drop a context note before moving on")], + returns: "NoteAdded.", + see_also: &["finding", "session"], + }, + HelpBlock { + title: "status", + aliases: &["status"], + purpose: "Set the review status of an instruction: open, reviewed, or finding.", + syntax: &[("status <ix> <open|reviewed|finding>", "")], + flags: &[], + examples: &[ + ("status stake reviewed", "Mark stake as reviewed"), + ("status claim_rewards finding", "Flag claim_rewards as having an issue"), + ], + returns: "StatusUpdated { ix, status }.", + see_also: &["finding", "findings", "export"], + }, + HelpBlock { + title: "ex | export", + aliases: &["ex", "export"], + purpose: "Generate the audit deliverable (Markdown) with sequence, findings, and program info.", + syntax: &[("ex", "")], + flags: &[ + ("--auditor=<name>", "Auditor identity in the report metadata"), + ("--version=<v>", "Project version pinned in the report"), + ("--date=<YYYY-MM-DD>", "Audit date override (defaults to today)"), + ], + examples: &[ + ("ex", "Export with no metadata"), + ("export --auditor=\"Alba S.\" --version=v1.2 --date=2026-05-09", ""), + ], + returns: "ExportArtifact { path, markdown }.", + see_also: &["findings", "finding", "session"], + }, + HelpBlock { + title: "sc | scenario", + aliases: &["sc", "scenario"], + purpose: "Manage scenarios: create, list, switch, fork from a step, delete.", + syntax: &[ + ("sc list", "List all scenarios in the workspace (default action)"), + ("sc new <name>", "Create a fresh empty scenario"), + ("sc switch <name>", "Activate an existing scenario"), + ("sc fork <name> [step]", "Branch from the active scenario at an optional step index"), + ("sc delete <name>", "Remove a scenario"), + ], + flags: &[], + examples: &[ + ("sc new reentrancy", "Start a new scenario named reentrancy"), + ("sc fork attack-v2 3", "Fork the active scenario at step 3"), + ("sc switch main", ""), + ], + returns: "ScenarioList or ScenarioUpdated depending on the sub-command.", + see_also: &["session", "save", "load"], + }, + HelpBlock { + title: "save", + aliases: &["save"], + purpose: "Serialise the active scenario to ~/.ilold/sessions/<name>.json.", + syntax: &[("save <name>", "")], + flags: &[ + ("--with-keypairs", "Bundle plaintext test keypairs (do NOT commit the file)"), + ], + examples: &[ + ("save reentrancy-attack", ""), + ("save reentrancy-attack --with-keypairs", "Bundle keypairs for full reproducibility"), + ], + returns: "SessionSaved { json } — the CLI writes the file and warns if keypairs are bundled.", + see_also: &["load", "scenario", "export"], + }, + HelpBlock { + title: "load", + aliases: &["load"], + purpose: "Restore a scenario JSON from disk and replay it into the VM.", + syntax: &[("load <name>", "")], + flags: &[], + examples: &[("load reentrancy-attack", "Replay the saved scenario step-by-step")], + returns: "SessionLoaded { steps } — VM is rebuilt deterministically.", + see_also: &["save", "scenario", "session"], + }, + HelpBlock { + title: "seq | sequence", + aliases: &["seq", "sequence"], + purpose: "Render the narrative of the active scenario (Solana falls back to the session view).", + syntax: &[("seq", "")], + flags: &[], + examples: &[("seq", "Read the step-by-step story so far")], + returns: "SessionView (Solana parity with the Solidity sequence command).", + see_also: &["session", "step", "state"], + }, + HelpBlock { + title: "browser", + aliases: &["browser"], + purpose: "Print the URL of the local web canvas (the explore REPL serves it next to the API).", + syntax: &[("browser", "")], + flags: &[], + examples: &[("browser", "")], + returns: "Plain text with the API base URL.", + see_also: &["session", "state"], + }, + HelpBlock { + title: "? | help", + aliases: &["?", "help", "h"], + purpose: "Print the command menu. Append ? to any command for the full reference (e.g. call?, who?).", + syntax: &[("?", "")], + flags: &[], + examples: &[ + ("?", "Top-level menu"), + ("call?", "Full reference for call"), + ("who?", "Full reference for who"), + ], + returns: "Formatted help text.", + see_also: &[], + }, + HelpBlock { + title: "q | quit | exit", + aliases: &["q", "quit", "exit"], + purpose: "Exit the explore REPL.", + syntax: &[("q", "")], + flags: &[], + examples: &[("q", "")], + returns: "Terminates the session.", + see_also: &[], + }, +]; + +pub fn render_solana_help_block(cmd: &str) -> Option<String> { + let key = cmd.trim().to_lowercase(); + if key.is_empty() { + return None; + } + let block = SOLANA_HELP_BLOCKS + .iter() + .find(|b| b.aliases.iter().any(|a| *a == key))?; + + let mut out = String::new(); + out.push('\n'); + out.push_str(&format!(" {}\n\n", c_bright(block.title))); + + out.push_str(&format!(" {}\n", c_accent("Purpose"))); + out.push_str(&format!(" {}\n", block.purpose)); + + if !block.syntax.is_empty() { + out.push('\n'); + out.push_str(&format!(" {}\n", c_accent("Syntax"))); + let pad = block.syntax.iter().map(|(s, _)| s.len()).max().unwrap_or(0); + for (form, note) in block.syntax { + if note.is_empty() { + out.push_str(&format!(" {}\n", form)); + } else { + out.push_str(&format!( + " {:<width$} {}\n", + form, + c_muted(note), + width = pad + )); + } + } + } + + if !block.flags.is_empty() { + out.push('\n'); + out.push_str(&format!(" {}\n", c_accent("Flags"))); + let pad = block.flags.iter().map(|(f, _)| f.len()).max().unwrap_or(0); + for (flag, desc) in block.flags { + out.push_str(&format!( + " {:<width$} {}\n", + flag, + c_muted(desc), + width = pad + )); + } + } + + if !block.examples.is_empty() { + out.push('\n'); + out.push_str(&format!(" {}\n", c_accent("Examples"))); + let pad = block.examples.iter().map(|(e, _)| e.len()).max().unwrap_or(0); + for (ex, note) in block.examples { + if note.is_empty() { + out.push_str(&format!(" {}\n", ex)); + } else { + out.push_str(&format!( + " {:<width$} {}\n", + ex, + c_muted(note), + width = pad + )); + } + } + } + + if !block.returns.is_empty() { + out.push('\n'); + out.push_str(&format!(" {}\n", c_accent("Returns"))); + out.push_str(&format!(" {}\n", block.returns)); + } + + if !block.see_also.is_empty() { + out.push('\n'); + out.push_str(&format!(" {}\n", c_accent("See also"))); + out.push_str(&format!(" {}\n", block.see_also.join(", "))); + } + + out.push('\n'); + Some(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn aliases_for(title_prefix: &str) -> &'static [&'static str] { + SOLANA_HELP_BLOCKS + .iter() + .find(|b| b.title.starts_with(title_prefix)) + .map(|b| b.aliases) + .expect("block exists") + } + + #[test] + fn renders_call_block_with_all_sections() { + let out = render_solana_help_block("call").expect("call block"); + assert!(out.contains("Purpose")); + assert!(out.contains("Syntax")); + assert!(out.contains("Flags")); + assert!(out.contains("Examples")); + assert!(out.contains("Returns")); + assert!(out.contains("See also")); + assert!(out.contains("--signer=")); + assert!(out.contains("StepAdded")); + } + + #[test] + fn aliases_render_identical_text() { + let by_short = render_solana_help_block("c").expect("c"); + let by_long = render_solana_help_block("call").expect("call"); + assert_eq!(by_short, by_long); + + let info_short = render_solana_help_block("i").expect("i"); + let info_long = render_solana_help_block("info").expect("info"); + assert_eq!(info_short, info_long); + } + + #[test] + fn who_block_documents_three_query_kinds() { + let out = render_solana_help_block("who").expect("who"); + assert!(out.contains("AccountType")); + assert!(out.contains("Instruction")); + assert!(out.contains("Field")); + } + + #[test] + fn scenario_block_lists_all_subcommands() { + let out = render_solana_help_block("scenario").expect("scenario"); + for sub in ["new", "list", "switch", "fork", "delete"] { + assert!(out.contains(sub), "scenario help missing {sub}"); + } + } + + #[test] + fn save_block_documents_with_keypairs_flag() { + let out = render_solana_help_block("save").expect("save"); + assert!(out.contains("--with-keypairs")); + assert!(out.contains("do NOT commit")); + } + + #[test] + fn help_block_returns_self() { + let out = render_solana_help_block("?").expect("?"); + assert!(out.contains("Append ? to any command")); + } + + #[test] + fn unknown_command_returns_none() { + assert!(render_solana_help_block("xxxx").is_none()); + assert!(render_solana_help_block("").is_none()); + } + + #[test] + fn omits_sections_when_empty() { + let out = render_solana_help_block("back").expect("back"); + assert!(!out.contains("Flags")); + assert!(!out.contains("See also\n \n")); + assert!(out.contains("See also")); + } + + #[test] + fn lookup_is_case_insensitive_on_input() { + let lower = render_solana_help_block("call").expect("call"); + let upper = render_solana_help_block("CALL").expect("CALL"); + assert_eq!(lower, upper); + } + + #[test] + fn every_solana_command_has_a_help_block() { + // Every command identifier accepted by handle_solana_input must have + // either its own HelpBlock or be aliased into one. If you add a new + // command branch in explore.rs, register it here too. + let registered: &[&str] = &[ + "?", "help", "h", + "quit", "q", "exit", + "funcs", "functions", "f", + "funcs-all", "fa", + "info", "i", + "vars", "v", "vars-all", "va", + "coupling", "cp", + "state", + "session", "s", + "back", + "clear", + "users", + "airdrop", "air", + "time-warp", "tw", + "pda", + "inspect", "acc", + "call", "c", + "ct", "contracts", "programs", "progs", + "use", + "sc", "scenario", + "note", "n", + "fi", "finding", + "seq", "sequence", + "browser", + "step", "st", + "save", + "load", + "findings", "fl", + "export", "ex", + "who", + "timeline", "tl", + "status", + ]; + for name in registered { + assert!( + render_solana_help_block(name).is_some(), + "missing HelpBlock for command alias `{name}`" + ); + } + } + + #[test] + fn aliases_for_helper_finds_blocks() { + let call_aliases = aliases_for("c | call"); + assert!(call_aliases.contains(&"c")); + assert!(call_aliases.contains(&"call")); + } +} diff --git a/crates/ilold-cli/src/main.rs b/crates/ilold-cli/src/main.rs index 4fede5d..d1b9ad3 100644 --- a/crates/ilold-cli/src/main.rs +++ b/crates/ilold-cli/src/main.rs @@ -9,6 +9,7 @@ mod colors; mod context; mod explore; mod fmt; +mod help; mod interactive; #[derive(Parser)] From 0829e38f6e85ef4b99193150d93ca4be3cf58648 Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Sun, 10 May 2026 17:16:11 +0200 Subject: [PATCH 094/115] feat(view): expose program-view endpoint and migrate CLI to typed payload - Add GET /api/program/:name/view returning typed ProgramView - Reuse program lookup via shared find_solana_program helper, coexist with legacy endpoint - CLI fetch_program_detail switches to /view with strong-typed deserialization - build_call_from_kv consumes IxView/ArgView directly instead of walking serde_json::Value - Update kv_parser tests to build ProgramView from the staking IDL fixture - Integration test asserts /view shape, parity with legacy endpoint, and 404 path --- crates/ilold-cli/src/explore.rs | 141 ++++++------------ crates/ilold-web/src/api/project.rs | 33 ++-- crates/ilold-web/src/lib.rs | 1 + .../tests/solana_command_pipeline.rs | 93 ++++++++++++ 4 files changed, 165 insertions(+), 103 deletions(-) diff --git a/crates/ilold-cli/src/explore.rs b/crates/ilold-cli/src/explore.rs index da7dc0f..c2081a8 100644 --- a/crates/ilold-cli/src/explore.rs +++ b/crates/ilold-cli/src/explore.rs @@ -12,6 +12,7 @@ use reedline::{ use ilold_core::classify::entry_points::AccessLevel; use ilold_core::exploration::commands::CommandResult; use ilold_solana_core::exploration::SolanaCommandResult; +use ilold_solana_core::view::ProgramView; use crate::colors::*; use crate::fmt; @@ -1716,75 +1717,33 @@ fn fetch_program_detail( client: &reqwest::Client, base_url: &str, name: &str, -) -> Result<serde_json::Value, String> { +) -> Result<ProgramView, String> { handle.block_on(async { let resp = client - .get(format!("{base_url}/api/program/{name}")) + .get(format!("{base_url}/api/program/{name}/view")) .send() .await .map_err(|e| format!("request: {e}"))?; if !resp.status().is_success() { return Err(format!("status {}", resp.status())); } - resp.json::<serde_json::Value>() + resp.json::<ProgramView>() .await .map_err(|e| format!("parse: {e}")) }) } fn build_call_from_kv( - program: &serde_json::Value, + program: &ProgramView, ix_name: &str, rest: &str, ) -> Result<serde_json::Value, String> { let ix = program - .get("instructions") - .and_then(|v| v.as_array()) - .and_then(|arr| arr.iter().find(|i| i.get("name").and_then(|n| n.as_str()) == Some(ix_name))) + .instructions + .iter() + .find(|i| i.name == ix_name) .ok_or_else(|| format!("instruction '{ix_name}' not found in program"))?; - let arg_keys: std::collections::HashSet<String> = ix - .get("args") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|a| a.get("name").and_then(|n| n.as_str()).map(String::from)) - .collect() - }) - .unwrap_or_default(); - let arg_types: std::collections::HashMap<String, serde_json::Value> = ix - .get("args") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|a| { - let n = a.get("name").and_then(|x| x.as_str())?.to_string(); - let t = a.get("type").cloned().unwrap_or(serde_json::Value::Null); - Some((n, t)) - }) - .collect() - }) - .unwrap_or_default(); - let account_keys: std::collections::HashSet<String> = ix - .get("accounts") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|a| a.get("name").and_then(|n| n.as_str()).map(String::from)) - .collect() - }) - .unwrap_or_default(); - let signer_accounts: Vec<String> = ix - .get("accounts") - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter(|a| a.get("signer").and_then(|s| s.as_bool()).unwrap_or(false)) - .filter_map(|a| a.get("name").and_then(|n| n.as_str()).map(String::from)) - .collect() - }) - .unwrap_or_default(); - let mut args = serde_json::Map::new(); let mut accounts = serde_json::Map::new(); let mut signer_overrides: Option<Vec<String>> = None; @@ -1813,16 +1772,17 @@ fn build_call_from_kv( Some(kv) => kv, None => return Err(format!("expected key=value, got '{token}'")), }; - if arg_keys.contains(key) { - let ty = arg_types.get(key).cloned().unwrap_or(serde_json::Value::Null); - args.insert(key.to_string(), coerce_kv(value, &ty)); - } else if account_keys.contains(key) { + if let Some(arg) = ix.args.iter().find(|a| a.name == key) { + args.insert(key.to_string(), coerce_kv(value, &arg.ty)); + } else if ix.accounts.iter().any(|a| a.name == key) { accounts.insert(key.to_string(), serde_json::Value::String(value.to_string())); } else { + let arg_list: Vec<String> = ix.args.iter().map(|a| a.name.clone()).collect(); + let acc_list: Vec<String> = ix.accounts.iter().map(|a| a.name.clone()).collect(); return Err(format!( "unknown key '{key}'; expected one of args [{}] or accounts [{}]", - arg_keys.iter().cloned().collect::<Vec<_>>().join(","), - account_keys.iter().cloned().collect::<Vec<_>>().join(",") + arg_list.join(","), + acc_list.join(",") )); } } @@ -1832,12 +1792,11 @@ fn build_call_from_kv( .into_iter() .filter(|n| !signer_negatives.contains(n)) .collect(), - None => signer_accounts + None => ix + .accounts .iter() - .filter_map(|name| { - let resolved = accounts.get(name).and_then(|v| v.as_str()).map(String::from)?; - Some(resolved) - }) + .filter(|a| a.signer) + .filter_map(|a| accounts.get(&a.name).and_then(|v| v.as_str()).map(String::from)) .filter(|n| !signer_negatives.contains(n)) .collect::<Vec<String>>(), }; @@ -1852,25 +1811,23 @@ fn build_call_from_kv( })) } -fn coerce_kv(raw: &str, ty: &serde_json::Value) -> serde_json::Value { - if let Some(s) = ty.as_str() { - match s { - "bool" => return serde_json::Value::Bool(raw == "true" || raw == "1"), - "u8" | "u16" | "u32" | "u64" | "i8" | "i16" | "i32" | "i64" | "f32" | "f64" => { - if let Ok(n) = raw.parse::<u64>() { - return serde_json::Value::Number(n.into()); - } - if let Ok(n) = raw.parse::<i64>() { - return serde_json::Value::Number(n.into()); - } - if let Ok(f) = raw.parse::<f64>() { - if let Some(n) = serde_json::Number::from_f64(f) { - return serde_json::Value::Number(n); - } +fn coerce_kv(raw: &str, ty: &str) -> serde_json::Value { + match ty { + "bool" => return serde_json::Value::Bool(raw == "true" || raw == "1"), + "u8" | "u16" | "u32" | "u64" | "i8" | "i16" | "i32" | "i64" | "f32" | "f64" => { + if let Ok(n) = raw.parse::<u64>() { + return serde_json::Value::Number(n.into()); + } + if let Ok(n) = raw.parse::<i64>() { + return serde_json::Value::Number(n.into()); + } + if let Ok(f) = raw.parse::<f64>() { + if let Some(n) = serde_json::Number::from_f64(f) { + return serde_json::Value::Number(n); } } - _ => {} } + _ => {} } serde_json::Value::String(raw.to_string()) } @@ -1878,25 +1835,22 @@ fn coerce_kv(raw: &str, ty: &serde_json::Value) -> serde_json::Value { #[cfg(test)] mod tests { use super::*; + use ilold_solana_core::idl::parse_idl; + use ilold_solana_core::model::ProgramDef; - fn staking_initialize_pool() -> serde_json::Value { - serde_json::json!({ - "name": "staking", - "instructions": [{ - "name": "initialize_pool", - "args": [{ "name": "reward_rate", "type": "u64" }], - "accounts": [ - { "name": "pool", "writable": true, "signer": true }, - { "name": "admin", "writable": true, "signer": true }, - { "name": "system_program", "address": "11111111111111111111111111111111" } - ] - }] - }) + const STAKING_IDL: &str = include_str!( + "../../../tests/fixtures/solana/staking/idls/staking.json" + ); + + fn staking_view() -> ProgramView { + ProgramDef::from_idl(parse_idl(STAKING_IDL).expect("parse staking idl")) + .expect("build staking ProgramDef") + .compute_view() } #[test] fn kv_parser_distributes_args_and_accounts() { - let program = staking_initialize_pool(); + let program = staking_view(); let body = build_call_from_kv( &program, "initialize_pool", @@ -1914,7 +1868,7 @@ mod tests { #[test] fn kv_parser_supports_no_signer_override() { - let program = staking_initialize_pool(); + let program = staking_view(); let body = build_call_from_kv( &program, "initialize_pool", @@ -1933,7 +1887,7 @@ mod tests { #[test] fn kv_parser_rejects_unknown_key() { - let program = staking_initialize_pool(); + let program = staking_view(); let err = build_call_from_kv( &program, "initialize_pool", @@ -1945,14 +1899,13 @@ mod tests { #[test] fn kv_parser_omits_constant_accounts_from_form() { - let program = staking_initialize_pool(); + let program = staking_view(); let body = build_call_from_kv( &program, "initialize_pool", "reward_rate=10 pool=pool admin=admin", ) .expect("kv parse"); - // system_program should not be sent — backend resolves it from spec.address assert!(body["Call"]["accounts"].get("system_program").is_none()); } } diff --git a/crates/ilold-web/src/api/project.rs b/crates/ilold-web/src/api/project.rs index bcdc560..4587f67 100644 --- a/crates/ilold-web/src/api/project.rs +++ b/crates/ilold-web/src/api/project.rs @@ -4,10 +4,25 @@ use axum::extract::{Path, State}; use axum::http::StatusCode; use axum::Json; use ilold_solana_core::model::ProgramDef; +use ilold_solana_core::view::ProgramView; use serde::Serialize; use crate::state::{require_solidity_msg, AppState, Backend}; +fn find_solana_program( + state: &Arc<AppState>, + name: &str, +) -> Result<ProgramDef, (StatusCode, String)> { + let solana = state + .solana() + .ok_or((StatusCode::BAD_REQUEST, "endpoint is Solana-only".into()))?; + solana + .project + .find_program(name) + .cloned() + .ok_or((StatusCode::NOT_FOUND, format!("program '{name}' not found"))) +} + #[derive(Serialize)] pub struct ProjectSummary { pub files: usize, @@ -119,15 +134,15 @@ pub async fn get_program_detail( State(state): State<Arc<AppState>>, Path(name): Path<String>, ) -> Result<Json<ProgramDef>, (StatusCode, String)> { - let solana = state - .solana() - .ok_or((StatusCode::BAD_REQUEST, "endpoint is Solana-only".into()))?; - solana - .project - .find_program(&name) - .cloned() - .map(Json) - .ok_or((StatusCode::NOT_FOUND, format!("program '{name}' not found"))) + find_solana_program(&state, &name).map(Json) +} + +pub async fn get_program_view( + State(state): State<Arc<AppState>>, + Path(name): Path<String>, +) -> Result<Json<ProgramView>, (StatusCode, String)> { + let program = find_solana_program(&state, &name)?; + Ok(Json(program.compute_view())) } pub async fn get_project_map( diff --git a/crates/ilold-web/src/lib.rs b/crates/ilold-web/src/lib.rs index 10f4a4e..37d4a3e 100644 --- a/crates/ilold-web/src/lib.rs +++ b/crates/ilold-web/src/lib.rs @@ -18,6 +18,7 @@ fn build_router(state: Arc<AppState>) -> Router { .route("/api/project", get(api::project::get_project)) .route("/api/project/map", get(api::project::get_project_map)) .route("/api/program/{name}", get(api::project::get_program_detail)) + .route("/api/program/{name}/view", get(api::project::get_program_view)) .route("/api/contract/{name}", get(api::contract::get_contract)) .route("/api/contract/{name}/callgraph", get(api::contract::get_callgraph)) .route("/api/contract/{name}/{func}/cfg", get(api::contract::get_cfg)) diff --git a/crates/ilold-web/tests/solana_command_pipeline.rs b/crates/ilold-web/tests/solana_command_pipeline.rs index 8605c35..594830e 100644 --- a/crates/ilold-web/tests/solana_command_pipeline.rs +++ b/crates/ilold-web/tests/solana_command_pipeline.rs @@ -572,3 +572,96 @@ async fn solana_who_unknown_query_returns_not_found() { .expect("instructions array"); assert!(ixs.is_empty()); } + +#[tokio::test] +async fn program_view_endpoint_returns_typed_view() { + let (client, port) = start_staking().await; + let res = client + .get(format!("http://127.0.0.1:{port}/api/program/staking/view")) + .send() + .await + .expect("GET /api/program/staking/view"); + assert!(res.status().is_success(), "GET /view returned {}", res.status()); + let view: serde_json::Value = res.json().await.expect("parse /view body"); + + assert_eq!(view.get("name").and_then(|v| v.as_str()), Some("staking")); + assert!(view.get("program_id").and_then(|v| v.as_str()).is_some()); + + let instructions = view + .get("instructions") + .and_then(|v| v.as_array()) + .expect("instructions array"); + assert!(!instructions.is_empty()); + let ix_names: Vec<&str> = instructions + .iter() + .filter_map(|i| i.get("name").and_then(|v| v.as_str())) + .collect(); + assert!(ix_names.contains(&"initialize_pool")); + assert!(ix_names.contains(&"stake")); + + let init = instructions + .iter() + .find(|i| i.get("name").and_then(|v| v.as_str()) == Some("initialize_pool")) + .expect("initialize_pool entry"); + assert!(init + .get("discriminator_hex") + .and_then(|v| v.as_str()) + .map(|s| s.starts_with("0x")) + .unwrap_or(false)); + let init_args = init.get("args").and_then(|v| v.as_array()).expect("args"); + assert!(init_args + .iter() + .any(|a| a.get("name").and_then(|v| v.as_str()) == Some("reward_rate") + && a.get("ty").and_then(|v| v.as_str()) == Some("u64"))); + + let accounts = view + .get("accounts") + .and_then(|v| v.as_array()) + .expect("accounts array"); + let account_names: Vec<&str> = accounts + .iter() + .filter_map(|a| a.get("name").and_then(|v| v.as_str())) + .collect(); + assert!(account_names.contains(&"Pool")); + + assert!(view.get("state_coupling").is_some()); + assert!(view.get("admin_gated").is_some()); + assert!(view.get("system_accounts").is_some()); + + let legacy_res = client + .get(format!("http://127.0.0.1:{port}/api/program/staking")) + .send() + .await + .expect("GET legacy /api/program/staking"); + assert!(legacy_res.status().is_success()); + let legacy: serde_json::Value = legacy_res.json().await.expect("parse legacy body"); + + let legacy_ix_names: Vec<&str> = legacy + .get("instructions") + .and_then(|v| v.as_array()) + .expect("legacy instructions") + .iter() + .filter_map(|i| i.get("name").and_then(|v| v.as_str())) + .collect(); + assert_eq!(legacy_ix_names, ix_names); + + let legacy_account_names: Vec<&str> = legacy + .get("account_types") + .and_then(|v| v.as_array()) + .expect("legacy account_types") + .iter() + .filter_map(|a| a.get("name").and_then(|v| v.as_str())) + .collect(); + assert_eq!(legacy_account_names, account_names); +} + +#[tokio::test] +async fn program_view_endpoint_returns_404_for_missing_program() { + let (client, port) = start_staking().await; + let res = client + .get(format!("http://127.0.0.1:{port}/api/program/ghost/view")) + .send() + .await + .expect("GET /view ghost"); + assert_eq!(res.status().as_u16(), 404); +} From b49290f9491366a63d0bb881647b7570f784e23c Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Sun, 10 May 2026 17:33:50 +0200 Subject: [PATCH 095/115] feat(canvas): paint Solana ProgramView with real fields, args, badges - Add ProgramView types and getProgramView client in rest.ts - Switch +page.svelte solanaProgram to ProgramView, rewrite handlers - handleSolanaIxExpand resolves account-type via snake-to-pascal and paints real struct fields - AccountNode shows up to 6 name:type entries plus signer/writable/pda badges - InstructionNode shows typed args, accounts/signers/pda meta, admin-gated red border - SolanaRunForm reads arg.ty directly, drop describeType - Delete dead composeProgramGraph and extractFieldList from canvas/program.ts - NodeInspector and sidebars migrate from ProgramDetail to ProgramView - Cross-check Node script parses staking_view snapshot against TS contract --- .../frontend/scripts/check-program-view.mjs | 65 ++++++++ crates/ilold-web/frontend/src/lib/api/rest.ts | 65 ++++++-- .../frontend/src/lib/canvas/program.ts | 96 ------------ .../contract/FunctionSidebar.svelte | 4 +- .../components/contract/NodeInspector.svelte | 33 +++- .../components/contract/SolanaRunForm.svelte | 42 ++---- .../contract/nodes/AccountNode.svelte | 104 ++++++++++++- .../contract/nodes/InstructionNode.svelte | 141 +++++++++++++++++- .../components/session/SessionSidebar.svelte | 4 +- .../frontend/src/lib/stores/graph.svelte.ts | 14 +- .../src/routes/contract/[name]/+page.svelte | 52 +++++-- 11 files changed, 440 insertions(+), 180 deletions(-) create mode 100644 crates/ilold-web/frontend/scripts/check-program-view.mjs delete mode 100644 crates/ilold-web/frontend/src/lib/canvas/program.ts diff --git a/crates/ilold-web/frontend/scripts/check-program-view.mjs b/crates/ilold-web/frontend/scripts/check-program-view.mjs new file mode 100644 index 0000000..0ad50e1 --- /dev/null +++ b/crates/ilold-web/frontend/scripts/check-program-view.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import assert from 'node:assert/strict'; + +const here = dirname(fileURLToPath(import.meta.url)); +const snapshotPath = resolve( + here, + '../../../ilold-solana-core/tests/snapshots/staking_view.json', +); + +const raw = readFileSync(snapshotPath, 'utf8'); +const view = JSON.parse(raw); + +assert.equal(typeof view.name, 'string', 'name is string'); +assert.equal(typeof view.program_id, 'string', 'program_id is string'); +assert.ok(Array.isArray(view.instructions), 'instructions is array'); +assert.ok(view.instructions.length > 0, 'instructions non-empty'); +assert.ok(Array.isArray(view.accounts), 'accounts is array'); + +const ix0 = view.instructions[0]; +assert.equal(typeof ix0.name, 'string', 'ix.name is string'); +assert.equal(typeof ix0.discriminator_hex, 'string', 'ix.discriminator_hex is string'); +assert.ok(Array.isArray(ix0.args), 'ix.args is array'); +assert.ok(Array.isArray(ix0.accounts), 'ix.accounts is array'); +for (const a of ix0.args) { + assert.equal(typeof a.name, 'string', 'arg.name is string'); + assert.equal(typeof a.ty, 'string', 'arg.ty is string'); +} +for (const acc of ix0.accounts) { + assert.equal(typeof acc.name, 'string', 'ix-account.name is string'); + assert.equal(typeof acc.path, 'string', 'ix-account.path is string'); + assert.ok( + ['program', 'system', 'sysvar', 'pda', 'other'].includes(acc.kind), + `ix-account.kind unknown: ${acc.kind}`, + ); + assert.equal(typeof acc.signer, 'boolean', 'ix-account.signer is boolean'); + assert.equal(typeof acc.writable, 'boolean', 'ix-account.writable is boolean'); +} + +const pool = view.accounts.find((a) => a.name === 'Pool'); +assert.ok(pool, 'Pool account-type present'); +assert.ok(Array.isArray(pool.fields), 'Pool.fields is array'); +assert.ok(pool.fields.length >= 4, `Pool.fields >= 4 (got ${pool.fields.length})`); +for (const f of pool.fields) { + assert.equal(typeof f.name, 'string', 'field.name is string'); + assert.equal(typeof f.ty, 'string', 'field.ty is string'); +} + +if (view.state_coupling) { + for (const pair of view.state_coupling) { + assert.equal(typeof pair.a, 'string'); + assert.equal(typeof pair.b, 'string'); + assert.ok(Array.isArray(pair.shared_writable)); + } +} +if (view.admin_gated) { + assert.ok(Array.isArray(view.admin_gated), 'admin_gated is array'); +} +if (view.system_accounts) { + assert.ok(Array.isArray(view.system_accounts), 'system_accounts is array'); +} + +console.log(`ok: ProgramView snapshot matches TS contract (${view.instructions.length} ixs, ${view.accounts.length} account-types)`); diff --git a/crates/ilold-web/frontend/src/lib/api/rest.ts b/crates/ilold-web/frontend/src/lib/api/rest.ts index 6162291..1fdda02 100644 --- a/crates/ilold-web/frontend/src/lib/api/rest.ts +++ b/crates/ilold-web/frontend/src/lib/api/rest.ts @@ -96,36 +96,69 @@ export interface MapInstruction { has_pdas: boolean; } -export interface ProgramDetail { +export type AccountKind = 'program' | 'system' | 'sysvar' | 'pda' | 'other'; + +export interface ArgView { name: string; - program_id: string; - instructions: InstructionDef[]; - account_types: AccountTypeDef[]; + ty: string; } -export interface InstructionDef { +export interface FieldView { name: string; - discriminator: number[]; - args: { name: string; type: any }[]; - accounts: AccountSpec[]; - returns?: any; + ty: string; } -export interface AccountSpec { +export type SeedView = + | { kind: 'const'; value_hex: string; value_utf8?: string } + | { kind: 'arg'; name: string; ty: string } + | { kind: 'account'; path: string }; + +export interface PdaView { + seeds: SeedView[]; + program?: string; + bump_arg?: string; +} + +export interface IxAccountView { path: string; name: string; + kind: AccountKind; writable: boolean; signer: boolean; optional: boolean; address?: string; - pda?: { seeds: any[]; program?: any; bump_arg?: string }; + pda?: PdaView; relations: string[]; } -export interface AccountTypeDef { +export interface IxView { + name: string; + discriminator_hex: string; + args: ArgView[]; + accounts: IxAccountView[]; + returns?: string; +} + +export interface AccountView { name: string; - discriminator: number[]; - layout: any; + discriminator_hex: string; + fields: FieldView[]; +} + +export interface CouplingPair { + a: string; + b: string; + shared_writable: string[]; +} + +export interface ProgramView { + name: string; + program_id: string; + instructions: IxView[]; + accounts: AccountView[]; + state_coupling?: CouplingPair[]; + admin_gated?: string[]; + system_accounts?: string[]; } export interface MapContract { @@ -172,8 +205,8 @@ export async function getContract(name: string): Promise<ContractDetail> { return res.json(); } -export async function getProgram(name: string): Promise<ProgramDetail> { - const res = await fetch(`${BASE}/api/program/${encodeURIComponent(name)}`); +export async function getProgramView(name: string): Promise<ProgramView> { + const res = await fetch(`${BASE}/api/program/${encodeURIComponent(name)}/view`); if (!res.ok) throw new Error(`Program ${name} not found`); return res.json(); } diff --git a/crates/ilold-web/frontend/src/lib/canvas/program.ts b/crates/ilold-web/frontend/src/lib/canvas/program.ts deleted file mode 100644 index f3951a9..0000000 --- a/crates/ilold-web/frontend/src/lib/canvas/program.ts +++ /dev/null @@ -1,96 +0,0 @@ -import type { Node, Edge } from '@xyflow/svelte'; -import type { ProgramDetail } from '$lib/api/rest'; -import type { - AccountNodeData, - GraphNodeData, - InstructionNodeData, -} from '$lib/stores/graph.svelte'; - -const INSTRUCTION_X_STEP = 220; -const ACCOUNT_X_STEP = 180; -const INSTRUCTION_Y = 320; -const ACCOUNT_Y = 0; - -export function composeProgramGraph(program: ProgramDetail): { - nodes: Node<GraphNodeData>[]; - edges: Edge[]; -} { - const accountIds = new Map<string, string>(); - const accountNodes: Node<GraphNodeData>[] = program.account_types.map( - (a, i) => { - const id = `account:${a.name}`; - accountIds.set(a.name, id); - const data: AccountNodeData = { - _type: 'account', - label: a.name, - programName: program.name, - fields: extractFieldList(a), - }; - return { - id, - type: 'account', - position: { x: i * ACCOUNT_X_STEP, y: ACCOUNT_Y }, - data, - }; - }, - ); - - const instructionNodes: Node<GraphNodeData>[] = program.instructions.map( - (ix, i) => { - const id = `ix:${ix.name}`; - const signers = (ix.accounts ?? []) - .filter((a: any) => a.signer) - .map((a: any) => a.name); - const hasPdas = (ix.accounts ?? []).some((a: any) => a.pda != null); - const data: InstructionNodeData = { - _type: 'instruction', - label: ix.name, - programName: program.name, - programId: program.program_id, - argsCount: (ix.args ?? []).length, - accountsCount: (ix.accounts ?? []).length, - hasPdas, - signers, - }; - return { - id, - type: 'instruction', - position: { x: i * INSTRUCTION_X_STEP, y: INSTRUCTION_Y }, - data, - }; - }, - ); - - const edges: Edge[] = []; - for (const ix of program.instructions) { - const ixId = `ix:${ix.name}`; - const seen = new Set<string>(); - for (const acc of ix.accounts ?? []) { - const accId = accountIds.get(acc.name); - if (!accId) continue; - const key = `${ixId}->${accId}`; - if (seen.has(key)) continue; - seen.add(key); - edges.push({ - id: `e:${key}`, - source: ixId, - sourceHandle: 't', - target: accId, - targetHandle: 'b', - animated: false, - }); - } - } - - return { nodes: [...accountNodes, ...instructionNodes], edges }; -} - -function extractFieldList(a: any): { name: string; type: string }[] { - const ty = a?.layout?.ty; - const fields = ty?.kind === 'Struct' ? ty.fields ?? ty?.Struct?.fields : null; - if (!Array.isArray(fields)) return []; - return fields.map((f: any) => ({ - name: f?.name ?? '?', - type: typeof f?.ty === 'string' ? f.ty : JSON.stringify(f?.ty ?? '?'), - })); -} diff --git a/crates/ilold-web/frontend/src/lib/components/contract/FunctionSidebar.svelte b/crates/ilold-web/frontend/src/lib/components/contract/FunctionSidebar.svelte index b1bdb7d..1a4e532 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/FunctionSidebar.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/FunctionSidebar.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import type { ContractDetail, ProgramDetail } from '$lib/api/rest'; + import type { ContractDetail, ProgramView } from '$lib/api/rest'; import { visibilityLabel } from '$lib/utils/visibility'; // Sidebar that lists every entry point of the loaded artifact: Solidity @@ -22,7 +22,7 @@ onremove, }: { contract?: ContractDetail | null; - program?: ProgramDetail | null; + program?: ProgramView | null; canvasFuncs: Set<string>; mode: 'cfg' | 'sequences' | 'session'; kind?: 'solidity' | 'solana'; diff --git a/crates/ilold-web/frontend/src/lib/components/contract/NodeInspector.svelte b/crates/ilold-web/frontend/src/lib/components/contract/NodeInspector.svelte index 3c1da0c..8a059c6 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/NodeInspector.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/NodeInspector.svelte @@ -1,7 +1,7 @@ <script lang="ts"> import Collapsible from '$lib/Collapsible.svelte'; import SolanaRunForm from './SolanaRunForm.svelte'; - import type { ProgramDetail, InstructionDef } from '$lib/api/rest'; + import type { ProgramView, IxView } from '$lib/api/rest'; interface Props { selectedNode: any; @@ -16,10 +16,10 @@ onpathselect: (funcName: string, path: any) => void; onexpandcfg: (funcName: string, nodeId?: string) => void; onsolanarun?: (instructionName: string) => void; - program?: ProgramDetail | null; + program?: ProgramView | null; solanaUsers?: { name: string; pubkey: string }[]; onsolanasubmit?: ( - ix: InstructionDef, + ix: IxView, payload: { args: Record<string, any>; accounts: Record<string, string>; signers: string[] }, ) => Promise<void>; } @@ -42,7 +42,7 @@ onsolanasubmit, }: Props = $props(); - const inspectedIx = $derived.by<InstructionDef | null>(() => { + const inspectedIx = $derived.by<IxView | null>(() => { if (!program || !selectedNode || selectedNode._type !== 'instruction') return null; return program.instructions.find((i) => i.name === selectedNode.label) ?? null; }); @@ -353,14 +353,20 @@ {:else if selectedNode._type === 'instruction'} <div class="d-row"><span class="d-label">Program</span><span>{selectedNode.programName}</span></div> - <div class="d-row"><span class="d-label">Args</span><span>{selectedNode.argsCount}</span></div> + <div class="d-row"><span class="d-label">Args</span><span>{(selectedNode.args ?? []).length}</span></div> <div class="d-row"><span class="d-label">Accounts</span><span>{selectedNode.accountsCount}</span></div> + {#if selectedNode.adminGated} + <div class="d-row"><span class="d-label">Admin</span><span class="text-danger">admin-gated (heuristic)</span></div> + {/if} {#if selectedNode.hasPdas} <div class="d-row"><span class="d-label">PDAs</span><span class="text-warning">declares PDAs</span></div> {/if} {#if selectedNode.signers && selectedNode.signers.length > 0} <div class="d-row"><span class="d-label">Signers</span><span>{selectedNode.signers.join(', ')}</span></div> {/if} + {#if selectedNode.discriminator_hex} + <div class="d-row"><span class="d-label">Disc</span><span class="font-mono">{selectedNode.discriminator_hex}</span></div> + {/if} {#if program && inspectedIx && onsolanasubmit} <SolanaRunForm {program} @@ -378,14 +384,29 @@ {:else if selectedNode._type === 'account'} <div class="d-row"><span class="d-label">Program</span><span>{selectedNode.programName}</span></div> + {#if selectedNode.account_type} + <div class="d-row"><span class="d-label">Type</span><span class="font-mono">{selectedNode.account_type}</span></div> + {/if} + {#if selectedNode.discriminator_hex} + <div class="d-row"><span class="d-label">Disc</span><span class="font-mono">{selectedNode.discriminator_hex}</span></div> + {/if} {#if selectedNode.fields && selectedNode.fields.length > 0} <div class="d-section-label">Fields</div> {#each selectedNode.fields as f} - <div class="d-row"><span class="d-label">{f.name}</span><span class="font-mono">{f.type}</span></div> + <div class="d-row"><span class="d-label">{f.name}</span><span class="font-mono">{f.ty}</span></div> {/each} {:else} <div class="d-hint">Layout fields not declared in this IDL</div> {/if} + {#if selectedNode.signer || selectedNode.writable || selectedNode.pda} + <div class="d-section-label">IX flags</div> + <div class="d-row"> + <span class="d-label">Flags</span> + <span> + {selectedNode.signer ? 'signer ' : ''}{selectedNode.writable ? 'writable ' : ''}{selectedNode.pda ? 'pda' : ''} + </span> + </div> + {/if} {:else if selectedNode._type === 'trace'} {#if selectedNode.error} diff --git a/crates/ilold-web/frontend/src/lib/components/contract/SolanaRunForm.svelte b/crates/ilold-web/frontend/src/lib/components/contract/SolanaRunForm.svelte index 05f8bca..c53401a 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/SolanaRunForm.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/SolanaRunForm.svelte @@ -1,5 +1,5 @@ <script lang="ts"> - import type { InstructionDef, ProgramDetail } from '$lib/api/rest'; + import type { IxView, ProgramView } from '$lib/api/rest'; let { program, @@ -7,8 +7,8 @@ users, onsubmit, }: { - program: ProgramDetail; - ix: InstructionDef; + program: ProgramView; + ix: IxView; users: { name: string; pubkey: string }[]; onsubmit: (payload: { args: Record<string, any>; @@ -38,34 +38,22 @@ signerValues = sgn; }); - const visibleAccounts = $derived((ix.accounts ?? []).filter((a: any) => !a.address)); - const constantAccounts = $derived((ix.accounts ?? []).filter((a: any) => a.address)); + const visibleAccounts = $derived((ix.accounts ?? []).filter((a) => !a.address)); + const constantAccounts = $derived((ix.accounts ?? []).filter((a) => a.address)); const NUMBER_INTS = new Set(['u8','u16','u32','u64','i8','i16','i32','i64','f32','f64']); const STRING_INTS = new Set(['u128','i128','u256','i256']); - function coerceArg(raw: string, ty: any): any { - if (typeof ty === 'string') { - if (ty === 'bool') return raw === 'true' || raw === '1'; - if (NUMBER_INTS.has(ty)) { - const n = Number(raw); - return Number.isFinite(n) ? n : raw; - } - if (STRING_INTS.has(ty)) { - return raw; - } + function coerceArg(raw: string, ty: string): any { + if (ty === 'bool') return raw === 'true' || raw === '1'; + if (NUMBER_INTS.has(ty)) { + const n = Number(raw); + return Number.isFinite(n) ? n : raw; } - return raw; - } - - function describeType(ty: any): string { - if (typeof ty === 'string') return ty; - if (ty == null) return '?'; - if (typeof ty === 'object' && 'defined' in ty) { - const d = (ty as any).defined; - return typeof d === 'string' ? d : (d?.name ?? JSON.stringify(d)); + if (STRING_INTS.has(ty)) { + return raw; } - return JSON.stringify(ty); + return raw; } async function handleSubmit(e: SubmitEvent) { @@ -77,7 +65,7 @@ for (const arg of ix.args ?? []) { const raw = argValues[arg.name] ?? ''; if (raw === '') continue; - args[arg.name] = coerceArg(raw, arg.type); + args[arg.name] = coerceArg(raw, arg.ty); } const accounts: Record<string, string> = {}; for (const [k, v] of Object.entries(accountValues)) { @@ -112,7 +100,7 @@ class="run-input" type="text" bind:value={argValues[arg.name]} - placeholder={describeType(arg.type)} + placeholder={arg.ty} /> </label> {/each} diff --git a/crates/ilold-web/frontend/src/lib/components/contract/nodes/AccountNode.svelte b/crates/ilold-web/frontend/src/lib/components/contract/nodes/AccountNode.svelte index 2c61a38..5abd8e9 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/nodes/AccountNode.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/nodes/AccountNode.svelte @@ -4,16 +4,48 @@ let { data }: { data: AccountNodeData } = $props(); - let fieldCount = $derived(data.fields?.length ?? 0); + const MAX_FIELDS = 6; + let fields = $derived(data.fields ?? []); + let visibleFields = $derived(fields.slice(0, MAX_FIELDS)); + let extraCount = $derived(Math.max(0, fields.length - MAX_FIELDS)); + let discriminatorTooltip = $derived( + data.discriminator_hex ? `discriminator: ${data.discriminator_hex}` : '', + ); </script> <div - class="account-node py-1.5 px-3 rounded-md bg-surface-alt border-[1.5px] text-text font-mono text-xs min-w-[100px] text-center cursor-pointer" + class="account-node py-1.5 px-3 rounded-md bg-surface-alt border-[1.5px] text-text font-mono text-xs min-w-[120px] cursor-pointer" class:dimmed={data._dimmed} + class:has-fields={fields.length > 0} + title={discriminatorTooltip} > - <div class="font-semibold">{data.label}</div> - {#if fieldCount > 0} - <div class="text-[8px] text-text-dim mt-0.5">{fieldCount} fields</div> + <div class="head text-center"> + <div class="font-semibold">{data.label}</div> + {#if data.account_type} + <div class="text-[8px] text-text-dim mt-0.5 uppercase tracking-wider">{data.account_type}</div> + {/if} + </div> + + {#if visibleFields.length > 0} + <ul class="field-list mt-1.5"> + {#each visibleFields as f (f.name)} + <li class="field-row"> + <span class="field-name">{f.name}</span> + <span class="field-type">{f.ty}</span> + </li> + {/each} + {#if extraCount > 0} + <li class="field-more">+{extraCount} more</li> + {/if} + </ul> + {/if} + + {#if data.signer || data.writable || data.pda} + <div class="badges"> + {#if data.signer}<span class="badge signer">signer</span>{/if} + {#if data.writable}<span class="badge writable">writable</span>{/if} + {#if data.pda}<span class="badge pda">pda</span>{/if} + </div> {/if} </div> <Handle type="target" id="t" position={Position.Top} /> @@ -28,4 +60,66 @@ .account-node.dimmed { opacity: 0.55; } + .field-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 1px; + } + .field-row { + display: flex; + justify-content: space-between; + gap: 8px; + font-size: 9px; + line-height: 1.3; + } + .field-name { + color: var(--color-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .field-type { + color: var(--color-text-dim); + font-style: italic; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .field-more { + font-size: 8px; + color: var(--color-text-dim); + text-align: center; + margin-top: 2px; + } + .badges { + display: flex; + flex-wrap: wrap; + gap: 3px; + justify-content: center; + margin-top: 4px; + } + .badge { + font-size: 8px; + padding: 1px 5px; + border-radius: 3px; + background: var(--color-border-subtle); + color: var(--color-text-muted); + text-transform: lowercase; + letter-spacing: 0.04em; + } + .badge.signer { + background: color-mix(in srgb, var(--color-accent) 18%, transparent); + color: var(--color-accent-hover); + } + .badge.writable { + background: color-mix(in srgb, var(--color-warning) 18%, transparent); + color: var(--color-warning); + } + .badge.pda { + background: color-mix(in srgb, var(--color-warning) 22%, transparent); + color: var(--color-warning); + } </style> diff --git a/crates/ilold-web/frontend/src/lib/components/contract/nodes/InstructionNode.svelte b/crates/ilold-web/frontend/src/lib/components/contract/nodes/InstructionNode.svelte index 9529e98..edba024 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/nodes/InstructionNode.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/nodes/InstructionNode.svelte @@ -4,22 +4,66 @@ let { data }: { data: InstructionNodeData } = $props(); - let hasSigners = $derived((data.signers?.length ?? 0) > 0); + const MAX_ARGS = 4; + let args = $derived(data.args ?? []); + let visibleArgs = $derived(args.slice(0, MAX_ARGS)); + let extraArgs = $derived(Math.max(0, args.length - MAX_ARGS)); + let signerCount = $derived(data.signers?.length ?? 0); + let discriminatorShort = $derived( + data.discriminator_hex && data.discriminator_hex.length > 10 + ? `${data.discriminator_hex.slice(0, 10)}...` + : data.discriminator_hex ?? '', + ); + let discriminatorTooltip = $derived( + data.discriminator_hex ? `discriminator: ${data.discriminator_hex}` : '', + ); + let nodeTitle = $derived( + data.adminGated + ? `admin-gated (heuristic)${discriminatorTooltip ? ' · ' + discriminatorTooltip : ''}` + : discriminatorTooltip, + ); </script> <div - class="instruction-node py-1.5 px-4 rounded-md bg-surface-alt border-[1.5px] border-accent text-text font-mono text-xs font-semibold min-w-[120px] text-center cursor-pointer" + class="instruction-node py-1.5 px-3 rounded-md bg-surface-alt border-[1.5px] border-accent text-text font-mono text-xs min-w-[140px] cursor-pointer" class:dimmed={data._dimmed} class:has-pdas={data.hasPdas} + class:admin-gated={data.adminGated} + title={nodeTitle} > - <span>{data.label}</span> - <div class="flex items-center justify-center gap-1 mt-0.5"> - <span class="text-[8px] text-text-dim">{data.argsCount}a · {data.accountsCount}acc</span> + <div class="head"> + <span class="label">{data.label}</span> + {#if discriminatorShort} + <span class="disc">{discriminatorShort}</span> + {/if} + </div> + + {#if visibleArgs.length > 0} + <ul class="arg-list mt-1"> + {#each visibleArgs as arg (arg.name)} + <li class="arg-row"> + <span class="arg-name">{arg.name}</span> + <span class="arg-type">{arg.ty}</span> + </li> + {/each} + {#if extraArgs > 0} + <li class="arg-more">+{extraArgs} more</li> + {/if} + </ul> + {/if} + + <div class="badges mt-1"> + <span class="badge meta">{data.accountsCount}acc</span> + {#if signerCount > 0} + <span class="badge signer" title={`Signers: ${data.signers.join(', ')}`}> + {signerCount} signer{signerCount > 1 ? 's' : ''} + </span> + {/if} {#if data.hasPdas} - <span class="text-[8px] px-1 rounded bg-warning/15 text-warning">PDA</span> + <span class="badge pda">pda</span> {/if} - {#if hasSigners} - <span class="text-[9px]" title={`Signers: ${data.signers.join(', ')}`}>🔑</span> + {#if data.adminGated} + <span class="badge admin">admin</span> {/if} </div> </div> @@ -32,7 +76,88 @@ .instruction-node.has-pdas { border-color: var(--color-warning); } + .instruction-node.admin-gated { + border-color: var(--color-danger); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-danger) 35%, transparent) inset; + } .instruction-node.dimmed { opacity: 0.55; } + .head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 6px; + } + .label { + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .disc { + font-size: 8px; + color: var(--color-text-dim); + font-family: var(--font-mono, monospace); + } + .arg-list { + list-style: none; + padding: 0; + margin: 0; + display: flex; + flex-direction: column; + gap: 1px; + } + .arg-row { + display: flex; + justify-content: space-between; + gap: 8px; + font-size: 9px; + line-height: 1.3; + } + .arg-name { + color: var(--color-text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .arg-type { + color: var(--color-text-dim); + font-style: italic; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .arg-more { + font-size: 8px; + color: var(--color-text-dim); + text-align: center; + } + .badges { + display: flex; + flex-wrap: wrap; + gap: 3px; + justify-content: center; + } + .badge { + font-size: 8px; + padding: 1px 5px; + border-radius: 3px; + background: var(--color-border-subtle); + color: var(--color-text-muted); + text-transform: lowercase; + letter-spacing: 0.04em; + } + .badge.signer { + background: color-mix(in srgb, var(--color-accent) 18%, transparent); + color: var(--color-accent-hover); + } + .badge.pda { + background: color-mix(in srgb, var(--color-warning) 22%, transparent); + color: var(--color-warning); + } + .badge.admin { + background: color-mix(in srgb, var(--color-danger) 22%, transparent); + color: var(--color-danger); + } </style> diff --git a/crates/ilold-web/frontend/src/lib/components/session/SessionSidebar.svelte b/crates/ilold-web/frontend/src/lib/components/session/SessionSidebar.svelte index 8e82630..55e26b3 100644 --- a/crates/ilold-web/frontend/src/lib/components/session/SessionSidebar.svelte +++ b/crates/ilold-web/frontend/src/lib/components/session/SessionSidebar.svelte @@ -8,7 +8,7 @@ import { promptScenarioName } from '$lib/scenarios/name'; import { dispatchScenarioAction } from '$lib/scenarios/dispatch'; import { getNodes } from '$lib/stores/graph.svelte'; - import type { ProgramDetail } from '$lib/api/rest'; + import type { ProgramView } from '$lib/api/rest'; import type { TraceNodeData } from '$lib/stores/graph.svelte'; let { @@ -45,7 +45,7 @@ onpathselect?: (funcName: string, path: any) => void; onexpandcfg?: (funcName: string, nodeId?: string) => void; kind?: 'solidity' | 'solana'; - program?: ProgramDetail | null; + program?: ProgramView | null; solanaUsers?: { name: string; pubkey: string; lamports: number }[]; onsolanarun?: (instruction: string) => void; onnewuser?: (name: string, lamports: number) => Promise<void>; diff --git a/crates/ilold-web/frontend/src/lib/stores/graph.svelte.ts b/crates/ilold-web/frontend/src/lib/stores/graph.svelte.ts index 87bae8c..5fca2be 100644 --- a/crates/ilold-web/frontend/src/lib/stores/graph.svelte.ts +++ b/crates/ilold-web/frontend/src/lib/stores/graph.svelte.ts @@ -1,4 +1,5 @@ import type { Node, Edge } from '@xyflow/svelte'; +import type { AccountKind, ArgView, FieldView } from '$lib/api/rest'; // ── Node data types ───────────────────────────────────────── @@ -57,10 +58,12 @@ export interface InstructionNodeData { label: string; programName: string; programId: string; - argsCount: number; + args: ArgView[]; accountsCount: number; hasPdas: boolean; signers: string[]; + adminGated: boolean; + discriminator_hex?: string; _dimmed?: boolean; } @@ -69,7 +72,14 @@ export interface AccountNodeData { _type: 'account'; label: string; programName: string; - fields?: { name: string; type: string }[]; + fields: FieldView[]; + discriminator_hex?: string; + account_type?: string; + signer?: boolean; + writable?: boolean; + pda?: boolean; + kind?: AccountKind; + parentInstruction?: string; _dimmed?: boolean; } diff --git a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte index fcb1bcc..b9d117c 100644 --- a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte +++ b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte @@ -1,7 +1,7 @@ <script lang="ts"> import { page } from '$app/state'; import { onMount, onDestroy, tick, untrack } from 'svelte'; - import { getContract, getCallGraph, getCfg, getPaths, getSequences, getSequenceAnalysis, getFunctionSource, getProjectMap, getProgram, type ContractDetail, type CytoscapeGraph, type SequenceAnalysis, type MapContract, type MapProgram, type ProgramDetail } from '$lib/api/rest'; + import { getContract, getCallGraph, getCfg, getPaths, getSequences, getSequenceAnalysis, getFunctionSource, getProjectMap, getProgramView, type ContractDetail, type CytoscapeGraph, type SequenceAnalysis, type MapContract, type MapProgram, type ProgramView, type IxView, type AccountView, type IxAccountView } from '$lib/api/rest'; import { goto } from '$app/navigation'; import { toggleTerminal } from '$lib/stores/terminal.svelte'; import { openInIde } from '$lib/utils/ide-links'; @@ -16,7 +16,6 @@ import { postCommand, postSolanaCommand } from '$lib/api/session'; import Legend from '$lib/components/contract/Legend.svelte'; import FunctionSidebar from '$lib/components/contract/FunctionSidebar.svelte'; - import { composeProgramGraph } from '$lib/canvas/program'; import TopBar from '$lib/components/contract/TopBar.svelte'; import StatusBar from '$lib/components/contract/StatusBar.svelte'; import ContextMenu from '$lib/components/contract/ContextMenu.svelte'; @@ -38,7 +37,7 @@ import type { Node, Edge } from '@xyflow/svelte'; let contract: ContractDetail | null = $state(null); - let solanaProgram: ProgramDetail | null = $state(null); + let solanaProgram: ProgramView | null = $state(null); let kind: 'solidity' | 'solana' = $state('solidity'); let solanaCanvasIxs: Set<string> = $state(new Set()); let solanaExpandedIxs: Set<string> = $state(new Set()); @@ -562,6 +561,23 @@ if (node) selectedNode = node; }); + function snakeToPascal(s: string): string { + return s + .split('_') + .filter((p) => p.length > 0) + .map((p) => p.charAt(0).toUpperCase() + p.slice(1)) + .join(''); + } + + function findAccountType(program: ProgramView, accountName: string): AccountView | undefined { + const target = snakeToPascal(accountName); + return program.accounts.find((a) => a.name === target); + } + + function isAdminGated(program: ProgramView, ixName: string): boolean { + return (program.admin_gated ?? []).includes(ixName); + } + function handleSolanaIxAdd(ixName: string) { if (!solanaProgram) return; if (solanaCanvasIxs.has(ixName)) { @@ -581,12 +597,14 @@ label: ixName, programName: solanaProgram.name, programId: solanaProgram.program_id, - argsCount: (ix.args ?? []).length, + args: ix.args ?? [], accountsCount: (ix.accounts ?? []).length, - hasPdas: (ix.accounts ?? []).some((a: any) => a.pda != null), - signers: (ix.accounts ?? []).filter((a: any) => a.signer).map((a: any) => a.name), + hasPdas: (ix.accounts ?? []).some((a) => a.pda != null), + signers: (ix.accounts ?? []).filter((a) => a.signer).map((a) => a.name), + adminGated: isAdminGated(solanaProgram, ixName), + discriminator_hex: ix.discriminator_hex, }, - } as any); + }); solanaCanvasIxs = new Set([...solanaCanvasIxs, ixName]); if (flowApi) flowApi.fitView({ nodes: [{ id: `ix:${ixName}` }], padding: 0.5, duration: 400 }); } @@ -725,8 +743,9 @@ const totalWidth = (accounts.length - 1) * 170; const newNodes: any[] = []; const newEdges: any[] = []; - accounts.forEach((acc: any, i: number) => { + accounts.forEach((acc: IxAccountView, i: number) => { const id = `ix:${ixName}:acc:${acc.name}`; + const matched = findAccountType(solanaProgram!, acc.name); newNodes.push({ id, type: 'account', @@ -736,12 +755,13 @@ label: acc.name, programName: solanaProgram!.name, parentInstruction: ixName, - fields: acc.signer || acc.writable - ? [ - ...(acc.signer ? [{ name: 'signer', type: 'true' }] : []), - ...(acc.writable ? [{ name: 'writable', type: 'true' }] : []), - ] - : [], + fields: matched?.fields ?? [], + discriminator_hex: matched?.discriminator_hex, + account_type: matched?.name, + signer: acc.signer, + writable: acc.writable, + pda: acc.pda != null, + kind: acc.kind, }, }); newEdges.push({ @@ -891,7 +911,7 @@ kind = pm.kind === 'solana' ? 'solana' : 'solidity'; if (kind === 'solana') { try { - const prog = await getProgram(contractName); + const prog = await getProgramView(contractName); if (!stillFresh()) return; solanaProgram = prog; } catch { @@ -1669,7 +1689,7 @@ run: () => handleSolanaRun(ix.name), }); } - for (const a of prog.account_types ?? []) { + for (const a of prog.accounts ?? []) { cmds.push({ id: `solana-acc:${a.name}`, label: a.name, From 24b69f6cda5693717b256efce751e51ca923315a Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Sun, 10 May 2026 17:47:40 +0200 Subject: [PATCH 096/115] feat(canvas): writable/read edges, hide-system toggle, ci cross-check - Solid arrow edges for writable accounts, dashed for read-only - Hide-system toggle in TopBar filters programView.system_accounts - Wire check:program-view script into package.json and CI workflow - Extend cross-check to all instructions and PDA seed shapes - Admin-gated red border already covered in T-R51b --- .github/workflows/test.yml | 17 +++++ crates/ilold-web/frontend/package.json | 3 +- .../frontend/scripts/check-program-view.mjs | 75 ++++++++++++++----- .../src/lib/components/contract/TopBar.svelte | 13 ++++ .../src/routes/contract/[name]/+page.svelte | 55 ++++++++++---- 5 files changed, 131 insertions(+), 32 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7d1dfae..779623f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -11,6 +11,7 @@ on: - 'Cargo.toml' - 'Cargo.lock' - '.github/workflows/**' + - 'crates/ilold-web/frontend/**' env: CARGO_TERM_COLOR: always @@ -63,3 +64,19 @@ jobs: - name: Run scenario suite run: bash tests/scenarios/run.sh + + frontend-cross-check: + name: frontend cross-check program-view + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Run program-view cross-check + working-directory: crates/ilold-web/frontend + run: npm run check:program-view diff --git a/crates/ilold-web/frontend/package.json b/crates/ilold-web/frontend/package.json index 083b029..1851d7c 100644 --- a/crates/ilold-web/frontend/package.json +++ b/crates/ilold-web/frontend/package.json @@ -9,7 +9,8 @@ "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", - "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch" + "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", + "check:program-view": "node scripts/check-program-view.mjs" }, "devDependencies": { "@sveltejs/adapter-auto": "^7.0.0", diff --git a/crates/ilold-web/frontend/scripts/check-program-view.mjs b/crates/ilold-web/frontend/scripts/check-program-view.mjs index 0ad50e1..fc9e28a 100644 --- a/crates/ilold-web/frontend/scripts/check-program-view.mjs +++ b/crates/ilold-web/frontend/scripts/check-program-view.mjs @@ -19,26 +19,63 @@ assert.ok(Array.isArray(view.instructions), 'instructions is array'); assert.ok(view.instructions.length > 0, 'instructions non-empty'); assert.ok(Array.isArray(view.accounts), 'accounts is array'); -const ix0 = view.instructions[0]; -assert.equal(typeof ix0.name, 'string', 'ix.name is string'); -assert.equal(typeof ix0.discriminator_hex, 'string', 'ix.discriminator_hex is string'); -assert.ok(Array.isArray(ix0.args), 'ix.args is array'); -assert.ok(Array.isArray(ix0.accounts), 'ix.accounts is array'); -for (const a of ix0.args) { - assert.equal(typeof a.name, 'string', 'arg.name is string'); - assert.equal(typeof a.ty, 'string', 'arg.ty is string'); +const KINDS = new Set(['program', 'system', 'sysvar', 'pda', 'other']); +const SEED_KINDS = new Set(['const', 'arg', 'account']); + +function checkSeed(seed, where) { + assert.equal(typeof seed, 'object', `${where}: seed is object`); + assert.ok(SEED_KINDS.has(seed.kind), `${where}: seed.kind unknown: ${seed.kind}`); + if (seed.kind === 'const') { + assert.equal(typeof seed.value_hex, 'string', `${where}: const.value_hex is string`); + if (seed.value_utf8 !== undefined) { + assert.equal(typeof seed.value_utf8, 'string', `${where}: const.value_utf8 is string`); + } + } else if (seed.kind === 'arg') { + assert.equal(typeof seed.name, 'string', `${where}: arg.name is string`); + assert.equal(typeof seed.ty, 'string', `${where}: arg.ty is string`); + } else if (seed.kind === 'account') { + assert.equal(typeof seed.path, 'string', `${where}: account.path is string`); + } } -for (const acc of ix0.accounts) { - assert.equal(typeof acc.name, 'string', 'ix-account.name is string'); - assert.equal(typeof acc.path, 'string', 'ix-account.path is string'); - assert.ok( - ['program', 'system', 'sysvar', 'pda', 'other'].includes(acc.kind), - `ix-account.kind unknown: ${acc.kind}`, - ); - assert.equal(typeof acc.signer, 'boolean', 'ix-account.signer is boolean'); - assert.equal(typeof acc.writable, 'boolean', 'ix-account.writable is boolean'); + +function checkPda(pda, where) { + assert.equal(typeof pda, 'object', `${where}: pda is object`); + assert.ok(Array.isArray(pda.seeds), `${where}: pda.seeds is array`); + pda.seeds.forEach((s, i) => checkSeed(s, `${where}.seeds[${i}]`)); + if (pda.program !== undefined) { + assert.equal(typeof pda.program, 'string', `${where}: pda.program is string`); + } + if (pda.bump_arg !== undefined) { + assert.equal(typeof pda.bump_arg, 'string', `${where}: pda.bump_arg is string`); + } } +let pdaCount = 0; +view.instructions.forEach((ix, ixIdx) => { + const where = `ix[${ixIdx}] (${ix.name})`; + assert.equal(typeof ix.name, 'string', `${where}: name is string`); + assert.equal(typeof ix.discriminator_hex, 'string', `${where}: discriminator_hex is string`); + assert.ok(Array.isArray(ix.args), `${where}: args is array`); + assert.ok(Array.isArray(ix.accounts), `${where}: accounts is array`); + for (const a of ix.args) { + assert.equal(typeof a.name, 'string', `${where}: arg.name is string`); + assert.equal(typeof a.ty, 'string', `${where}: arg.ty is string`); + } + ix.accounts.forEach((acc, accIdx) => { + const accWhere = `${where}.accounts[${accIdx}] (${acc.name})`; + assert.equal(typeof acc.name, 'string', `${accWhere}: name is string`); + assert.equal(typeof acc.path, 'string', `${accWhere}: path is string`); + assert.ok(KINDS.has(acc.kind), `${accWhere}: kind unknown: ${acc.kind}`); + assert.equal(typeof acc.signer, 'boolean', `${accWhere}: signer is boolean`); + assert.equal(typeof acc.writable, 'boolean', `${accWhere}: writable is boolean`); + assert.equal(typeof acc.optional, 'boolean', `${accWhere}: optional is boolean`); + if (acc.pda !== undefined) { + checkPda(acc.pda, `${accWhere}.pda`); + pdaCount += 1; + } + }); +}); + const pool = view.accounts.find((a) => a.name === 'Pool'); assert.ok(pool, 'Pool account-type present'); assert.ok(Array.isArray(pool.fields), 'Pool.fields is array'); @@ -62,4 +99,6 @@ if (view.system_accounts) { assert.ok(Array.isArray(view.system_accounts), 'system_accounts is array'); } -console.log(`ok: ProgramView snapshot matches TS contract (${view.instructions.length} ixs, ${view.accounts.length} account-types)`); +console.log( + `ok: ProgramView snapshot matches TS contract (${view.instructions.length} ixs, ${view.accounts.length} account-types, ${pdaCount} pda accounts)`, +); diff --git a/crates/ilold-web/frontend/src/lib/components/contract/TopBar.svelte b/crates/ilold-web/frontend/src/lib/components/contract/TopBar.svelte index c0c65aa..28472c6 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/TopBar.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/TopBar.svelte @@ -10,23 +10,27 @@ mode, seqDirection, kind = 'solidity', + hideSystem = false, onmodechange, onsearch, oncenter, onseqdirection, onsessionback, onsessionclear, + onhidesystem, }: { contractName: string; mode: 'cfg' | 'sequences' | 'session'; seqDirection: 'TB' | 'LR'; kind?: 'solidity' | 'solana'; + hideSystem?: boolean; onmodechange: (mode: 'cfg' | 'sequences' | 'session') => void; onsearch: () => void; oncenter: () => void; onseqdirection: (dir: 'TB' | 'LR') => void; onsessionback: () => void; onsessionclear: () => void; + onhidesystem?: (next: boolean) => void; } = $props(); // Detect Mac to show ⌘ vs Ctrl on the search shortcut chip. Safe on SSR @@ -127,6 +131,15 @@ title="Center all nodes in view" aria-label="Center canvas" >Center</button> + {#if kind === 'solana' && onhidesystem} + <button + class="tool-btn" + class:active={hideSystem} + onclick={() => onhidesystem(!hideSystem)} + title={hideSystem ? 'Show system_program / sysvars / token_program' : 'Hide system_program / sysvars / token_program'} + aria-pressed={hideSystem} + >{hideSystem ? 'Show system' : 'Hide system'}</button> + {/if} <span class="divider"></span> <button class="tool-btn mono" diff --git a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte index b9d117c..c2185a2 100644 --- a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte +++ b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte @@ -43,6 +43,7 @@ let solanaExpandedIxs: Set<string> = $state(new Set()); let solanaUsers: { name: string; pubkey: string; lamports: number }[] = $state([]); let solanaTraceCount = $state(0); + let hideSystem = $state(false); type SolanaRuntimeInfo = { computeUnits: number; diffsCount: number; @@ -722,24 +723,19 @@ }); } - function handleSolanaIxExpand(ixName: string) { + function isHiddenAccount(accName: string): boolean { + if (!hideSystem || !solanaProgram?.system_accounts) return false; + return solanaProgram.system_accounts.includes(accName); + } + + function paintIxAccounts(ixName: string) { if (!solanaProgram) return; - if (solanaExpandedIxs.has(ixName)) { - const ids = new Set<string>(); - for (const n of getNodes()) { - const data: any = n.data; - if (data?._type === 'account' && data.parentInstruction === ixName) ids.add(n.id); - } - if (ids.size > 0) removeNodesById(ids); - solanaExpandedIxs = new Set([...solanaExpandedIxs].filter((n) => n !== ixName)); - return; - } const ix = solanaProgram.instructions.find((i) => i.name === ixName); if (!ix) return; const parent = findNode(`ix:${ixName}`); const baseX = parent?.position?.x ?? 0; const baseY = (parent?.position?.y ?? 200) - 200; - const accounts = ix.accounts ?? []; + const accounts = (ix.accounts ?? []).filter((acc) => !isHiddenAccount(acc.name)); const totalWidth = (accounts.length - 1) * 170; const newNodes: any[] = []; const newEdges: any[] = []; @@ -764,20 +760,51 @@ kind: acc.kind, }, }); + const edgeColor = acc.writable ? 'var(--color-accent-hover)' : 'var(--color-text-muted)'; newEdges.push({ id: `e:${ixName}:${acc.name}`, source: `ix:${ixName}`, sourceHandle: 't', target: id, targetHandle: 'b', - markerEnd: { type: 'arrowclosed', color: 'var(--color-accent-hover)' }, + style: acc.writable ? `stroke: ${edgeColor};` : `stroke-dasharray: 5 3; stroke: ${edgeColor};`, + markerEnd: { type: MarkerType.ArrowClosed, width: 12, height: 12, color: edgeColor }, }); }); if (newNodes.length > 0) addNodes(newNodes); if (newEdges.length > 0) addEdges(newEdges); + } + + function clearIxAccounts(ixName: string) { + const ids = new Set<string>(); + for (const n of getNodes()) { + const data: any = n.data; + if (data?._type === 'account' && data.parentInstruction === ixName) ids.add(n.id); + } + if (ids.size > 0) removeNodesById(ids); + } + + function handleSolanaIxExpand(ixName: string) { + if (!solanaProgram) return; + if (solanaExpandedIxs.has(ixName)) { + clearIxAccounts(ixName); + solanaExpandedIxs = new Set([...solanaExpandedIxs].filter((n) => n !== ixName)); + return; + } + paintIxAccounts(ixName); solanaExpandedIxs = new Set([...solanaExpandedIxs, ixName]); } + function handleHideSystemToggle(next: boolean) { + if (hideSystem === next) return; + hideSystem = next; + if (kind !== 'solana' || !solanaProgram) return; + for (const ixName of solanaExpandedIxs) { + clearIxAccounts(ixName); + paintIxAccounts(ixName); + } + } + function handleSolanaRun(name: string) { if (!solanaProgram) return; const ix = solanaProgram.instructions.find((i) => i.name === name); @@ -1832,12 +1859,14 @@ {mode} {seqDirection} {kind} + {hideSystem} onmodechange={switchMode} onsearch={togglePalette} oncenter={() => flowApi?.fitView({ padding: 0.1 })} onseqdirection={(dir) => { seqDirection = dir; reorientAllSeqSubtrees(); }} onsessionback={handleSessionBack} onsessionclear={handleSessionClear} + onhidesystem={handleHideSystemToggle} /> <div class="flex-1 flex overflow-hidden h-full"> <FunctionSidebar From 018eef5c1b8afce1ea547b308f59437a10d49c46 Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Sun, 10 May 2026 18:01:00 +0200 Subject: [PATCH 097/115] refactor(view): remove legacy program-detail endpoint - Drop GET /api/program/:name handler and route - Adapt parity test to assert /view shape directly - Both consumers (frontend, CLI) already moved in T-R51a/b --- crates/ilold-web/src/api/project.rs | 7 ---- crates/ilold-web/src/lib.rs | 1 - .../tests/solana_command_pipeline.rs | 32 ++++--------------- 3 files changed, 6 insertions(+), 34 deletions(-) diff --git a/crates/ilold-web/src/api/project.rs b/crates/ilold-web/src/api/project.rs index 4587f67..e21befe 100644 --- a/crates/ilold-web/src/api/project.rs +++ b/crates/ilold-web/src/api/project.rs @@ -130,13 +130,6 @@ pub struct MapRelationship { pub kind: String, } -pub async fn get_program_detail( - State(state): State<Arc<AppState>>, - Path(name): Path<String>, -) -> Result<Json<ProgramDef>, (StatusCode, String)> { - find_solana_program(&state, &name).map(Json) -} - pub async fn get_program_view( State(state): State<Arc<AppState>>, Path(name): Path<String>, diff --git a/crates/ilold-web/src/lib.rs b/crates/ilold-web/src/lib.rs index 37d4a3e..8f51569 100644 --- a/crates/ilold-web/src/lib.rs +++ b/crates/ilold-web/src/lib.rs @@ -17,7 +17,6 @@ fn build_router(state: Arc<AppState>) -> Router { Router::new() .route("/api/project", get(api::project::get_project)) .route("/api/project/map", get(api::project::get_project_map)) - .route("/api/program/{name}", get(api::project::get_program_detail)) .route("/api/program/{name}/view", get(api::project::get_program_view)) .route("/api/contract/{name}", get(api::contract::get_contract)) .route("/api/contract/{name}/callgraph", get(api::contract::get_callgraph)) diff --git a/crates/ilold-web/tests/solana_command_pipeline.rs b/crates/ilold-web/tests/solana_command_pipeline.rs index 594830e..f13cbc1 100644 --- a/crates/ilold-web/tests/solana_command_pipeline.rs +++ b/crates/ilold-web/tests/solana_command_pipeline.rs @@ -614,6 +614,12 @@ async fn program_view_endpoint_returns_typed_view() { .any(|a| a.get("name").and_then(|v| v.as_str()) == Some("reward_rate") && a.get("ty").and_then(|v| v.as_str()) == Some("u64"))); + let init_ix_accounts = init + .get("accounts") + .and_then(|v| v.as_array()) + .expect("ix accounts array"); + assert!(!init_ix_accounts.is_empty()); + let accounts = view .get("accounts") .and_then(|v| v.as_array()) @@ -627,32 +633,6 @@ async fn program_view_endpoint_returns_typed_view() { assert!(view.get("state_coupling").is_some()); assert!(view.get("admin_gated").is_some()); assert!(view.get("system_accounts").is_some()); - - let legacy_res = client - .get(format!("http://127.0.0.1:{port}/api/program/staking")) - .send() - .await - .expect("GET legacy /api/program/staking"); - assert!(legacy_res.status().is_success()); - let legacy: serde_json::Value = legacy_res.json().await.expect("parse legacy body"); - - let legacy_ix_names: Vec<&str> = legacy - .get("instructions") - .and_then(|v| v.as_array()) - .expect("legacy instructions") - .iter() - .filter_map(|i| i.get("name").and_then(|v| v.as_str())) - .collect(); - assert_eq!(legacy_ix_names, ix_names); - - let legacy_account_names: Vec<&str> = legacy - .get("account_types") - .and_then(|v| v.as_array()) - .expect("legacy account_types") - .iter() - .filter_map(|a| a.get("name").and_then(|v| v.as_str())) - .collect(); - assert_eq!(legacy_account_names, account_names); } #[tokio::test] From fad212b114b1f894f1dce004ce6a76be63de17a9 Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Sun, 10 May 2026 22:25:45 +0200 Subject: [PATCH 098/115] feat(solana): RuntimeOverlay backend + coverage command + cpi capture - Capture meta.inner_instructions from LiteSVM (was discarded as empty vec) - Add RuntimeOverlay::from_session aggregating calls/failed/cu/cpi per ix - New SolanaCommand::Coverage and GET /api/program/:name/overlay - CLI coverage command renders typed overlay as table - Snapshot wire-format for empty overlay; unit tests for aggregation - Scenario 14 validates CPI capture against tests/fixtures/solana/cpi --- crates/ilold-cli/src/explore.rs | 102 +++++++ crates/ilold-cli/src/help.rs | 10 + .../src/exploration/add_step.rs | 69 ++++- .../src/exploration/commands.rs | 8 + .../src/exploration/execute.rs | 13 + .../ilold-solana-core/src/exploration/mod.rs | 8 +- crates/ilold-solana-core/src/lib.rs | 2 + crates/ilold-solana-core/src/overlay.rs | 260 ++++++++++++++++++ crates/ilold-solana-core/tests/e2e_lever.rs | 60 ++++ .../tests/program_view_snapshot.rs | 27 ++ .../tests/snapshots/staking_overlay.json | 8 + crates/ilold-web/src/api/project.rs | 33 ++- crates/ilold-web/src/api/session.rs | 11 +- crates/ilold-web/src/lib.rs | 1 + .../tests/solana_command_pipeline.rs | 61 ++++ tests/scenarios/14-coverage-overlay.sh | 56 ++++ 16 files changed, 705 insertions(+), 24 deletions(-) create mode 100644 crates/ilold-solana-core/src/overlay.rs create mode 100644 crates/ilold-solana-core/tests/snapshots/staking_overlay.json create mode 100755 tests/scenarios/14-coverage-overlay.sh diff --git a/crates/ilold-cli/src/explore.rs b/crates/ilold-cli/src/explore.rs index c2081a8..f778464 100644 --- a/crates/ilold-cli/src/explore.rs +++ b/crates/ilold-cli/src/explore.rs @@ -1207,6 +1207,14 @@ fn handle_solana_input( serde_json::json!("Coupling"), steps, ), + "coverage" | "cov" => dispatch_solana( + handle, + client, + base_url, + contract, + serde_json::json!("Coverage"), + steps, + ), "state" => dispatch_solana( handle, client, @@ -2462,6 +2470,9 @@ fn print_solana_result(result: &SolanaCommandResult) { SolanaCommandResult::AccountTypes { accounts } => { print_account_types(accounts); } + SolanaCommandResult::Coverage { overlay } => { + print_coverage_overlay(overlay); + } SolanaCommandResult::Error { message } => { eprintln!(" {} {}", c_danger("✗"), message); } @@ -2627,6 +2638,96 @@ fn print_account_types(accounts: &[ilold_solana_core::view::AccountView]) { } } +fn print_coverage_overlay(overlay: &ilold_solana_core::overlay::RuntimeOverlay) { + println!(); + println!( + " {} {} {}", + c_accent("Coverage for program"), + overlay.program, + c_muted(&format!("(scenario {})", overlay.scenario)), + ); + println!(); + + let mut ix_keys: std::collections::BTreeSet<&String> = + std::collections::BTreeSet::new(); + ix_keys.extend(overlay.calls_per_ix.keys()); + ix_keys.extend(overlay.failed_per_ix.keys()); + ix_keys.extend(overlay.cu_stats_per_ix.keys()); + + if ix_keys.is_empty() { + println!(" {}", c_muted("no calls recorded yet")); + return; + } + + let cpi_count_per_ix = |ix: &str| -> u32 { + overlay + .cpi_edges + .iter() + .filter(|e| e.from_ix == ix) + .map(|e| e.samples) + .sum() + }; + + let max_ix = ix_keys + .iter() + .map(|k| k.chars().count()) + .max() + .unwrap_or(0) + .max(11); + println!( + " {} {} {} {} {} {}", + fmt::pad_right("Instruction", max_ix), + c_muted("Calls"), + c_muted("Failed"), + c_muted("CU avg"), + c_muted("CU max"), + c_muted("CPIs"), + ); + let mut total_calls: u32 = 0; + let mut total_failed: u32 = 0; + for ix in &ix_keys { + let calls = overlay.calls_per_ix.get(*ix).copied().unwrap_or(0); + let failed = overlay.failed_per_ix.get(*ix).copied().unwrap_or(0); + total_calls += calls; + total_failed += failed; + let (cu_avg, cu_max) = match overlay.cu_stats_per_ix.get(*ix) { + Some(s) => (s.avg.to_string(), s.max.to_string()), + None => ("—".to_string(), "—".to_string()), + }; + let cpi = cpi_count_per_ix(ix); + println!( + " {} {} {} {} {} {}", + fmt::pad_right(ix, max_ix), + fmt::pad_right(&calls.to_string(), 5), + fmt::pad_right(&failed.to_string(), 6), + fmt::pad_right(&cu_avg, 6), + fmt::pad_right(&cu_max, 6), + cpi, + ); + } + if !overlay.cpi_edges.is_empty() { + println!(); + println!(" {}", c_accent("CPIs detected:")); + for edge in &overlay.cpi_edges { + println!( + " {} {} {} {} {}", + c_accent("·"), + edge.from_ix, + c_muted("→"), + edge.to_program, + c_muted(&format!("(depth {}, {}×)", edge.depth, edge.samples)), + ); + } + } + println!(); + println!( + " {} {} calls, {} failed", + c_muted("Total:"), + total_calls, + total_failed, + ); +} + #[allow(clippy::too_many_arguments)] fn print_who_list( target: &str, @@ -3802,6 +3903,7 @@ fn print_help_for(backend: BackendKind) { ("", "who <query>", "Resolve query: AccountType | Instruction | Field"), ("tl", "timeline <pubkey>", "Cross-step mutation history of an account, decoded"), ("cp", "coupling", "List ix pairs that share writable accounts"), + ("cov","coverage", "Aggregated runtime metrics for the active scenario"), ]), ("Findings", &[ ("fi", "finding <sev> <title>", "Record a security finding"), diff --git a/crates/ilold-cli/src/help.rs b/crates/ilold-cli/src/help.rs index 13b258e..eec20dc 100644 --- a/crates/ilold-cli/src/help.rs +++ b/crates/ilold-cli/src/help.rs @@ -252,6 +252,16 @@ pub const SOLANA_HELP_BLOCKS: &[HelpBlock] = &[ returns: "CouplingList { pairs: [{ ix_a, ix_b, shared_writable: [..] }] }.", see_also: &["who", "funcs-all", "info"], }, + HelpBlock { + title: "coverage | cov", + aliases: &["coverage", "cov"], + purpose: "Aggregated runtime metrics over the active scenario: calls, failures, CU stats, CPI edges (T-R52 RuntimeOverlay).", + syntax: &[("coverage", "")], + flags: &[], + examples: &[("cov", "Spot ix never called, ix that always fail, programs reached via CPI")], + returns: "Coverage { overlay: { program, scenario, calls_per_ix, failed_per_ix, cu_stats_per_ix, cpi_edges } }.", + see_also: &["session", "state", "funcs"], + }, HelpBlock { title: "fi | finding", aliases: &["fi", "finding"], diff --git a/crates/ilold-solana-core/src/exploration/add_step.rs b/crates/ilold-solana-core/src/exploration/add_step.rs index 11c8be7..9eec1be 100644 --- a/crates/ilold-solana-core/src/exploration/add_step.rs +++ b/crates/ilold-solana-core/src/exploration/add_step.rs @@ -5,7 +5,10 @@ use ilold_session_core::exploration::session::{ ExplorationSession, ExplorationStep, MutationScope, StateMutation, TraceConfig, }; use ilold_session_core::journal::types::JournalEntry; -use ilold_session_core::runtime_trace::{AccountDiff, RuntimeTrace}; +use ilold_session_core::runtime_trace::{ + AccountDiff, InnerInstruction as TraceInnerInstruction, RuntimeTrace, +}; +use litesvm::types::TransactionMetadata; use serde_json::Value; use solana_account::{Account, ReadableAccount}; use solana_address::Address; @@ -55,6 +58,7 @@ pub fn add_solana_step( let blockhash = vm.svm().latest_blockhash(); let tx = build_transaction(instruction.clone(), vm.payer(), extra_signers, blockhash)?; + let account_keys: Vec<Address> = tx.message.static_account_keys().to_vec(); let result = vm.svm_mut().send_transaction(tx); // LiteSVM does NOT rotate the blockhash automatically. Two Calls in the same // session would collide (BlockhashNotFound) and the second silently fails @@ -71,6 +75,7 @@ pub fn add_solana_step( let diffs = compute_diffs(&pre_state, &post_state, ix); let mutations = diffs_to_mutations(&diffs, step_index); + let inner = project_inner_instructions(&meta, &account_keys); let return_data = if meta.return_data.data.is_empty() { None } else { @@ -80,7 +85,7 @@ pub fn add_solana_step( RuntimeTrace { logs: meta.logs, compute_units: meta.compute_units_consumed, - inner_instructions: vec![], + inner_instructions: inner, account_diffs: diffs, return_data, error: None, @@ -88,17 +93,20 @@ pub fn add_solana_step( mutations, ) } - Err(failed) => ( - RuntimeTrace { - logs: failed.meta.logs, - compute_units: failed.meta.compute_units_consumed, - inner_instructions: vec![], - account_diffs: vec![], - return_data: None, - error: Some(format!("{:?}", failed.err)), - }, - vec![], - ), + Err(failed) => { + let inner = project_inner_instructions(&failed.meta, &account_keys); + ( + RuntimeTrace { + logs: failed.meta.logs, + compute_units: failed.meta.compute_units_consumed, + inner_instructions: inner, + account_diffs: vec![], + return_data: None, + error: Some(format!("{:?}", failed.err)), + }, + vec![], + ) + } }; // Failed Calls never reach the timeline: the auditor wanted to try the @@ -134,6 +142,41 @@ pub fn add_solana_step( } +// Maps the LiteSVM `inner_instructions` (Vec<Vec<InnerInstruction>>, one outer +// entry per top-level ix) into the trace shape consumed by the overlay. The +// `program_id_index` is into `tx.message.static_account_keys`, captured before +// `send_transaction` consumed the tx. +fn project_inner_instructions( + meta: &TransactionMetadata, + account_keys: &[Address], +) -> Vec<TraceInnerInstruction> { + let mut out = Vec::new(); + for level in &meta.inner_instructions { + for ii in level { + let program = account_keys + .get(ii.instruction.program_id_index as usize) + .map(|k| k.to_string()) + .unwrap_or_else(|| format!("idx:{}", ii.instruction.program_id_index)); + // Anchor instruction discriminators are the first 8 bytes; the + // first byte (or its bs58 head) is enough as a stable, legible + // disambiguator for the overlay aggregation key. Empty payload + // gets a placeholder so cpi_edges still aggregates by program. + let instruction = if ii.instruction.data.is_empty() { + "ix".to_string() + } else { + let take = ii.instruction.data.len().min(8); + bs58::encode(&ii.instruction.data[..take]).into_string() + }; + out.push(TraceInnerInstruction { + program, + instruction, + depth: ii.stack_height as u32, + }); + } + } + out +} + fn compute_diffs( pre: &[(Address, Option<Account>)], post: &[(Address, Option<Account>)], diff --git a/crates/ilold-solana-core/src/exploration/commands.rs b/crates/ilold-solana-core/src/exploration/commands.rs index 53f3d84..a186783 100644 --- a/crates/ilold-solana-core/src/exploration/commands.rs +++ b/crates/ilold-solana-core/src/exploration/commands.rs @@ -7,6 +7,7 @@ use ilold_session_core::journal::types::{ReviewStatus, Severity}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use crate::overlay::RuntimeOverlay; use crate::view::{AccountView, ArgView, CouplingPair, FieldView, IxView}; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -99,6 +100,10 @@ pub enum SolanaCommand { /// Account-type catalogue (`vars` in the REPL): name + discriminator + /// fields. Slice of `ProgramView::accounts`. Vars, + /// Aggregated runtime metrics (calls, failures, CU, CPI edges) over the + /// active scenario. Backend-only computation; clients consume the typed + /// `RuntimeOverlay` payload. + Coverage, } fn default_initial_lamports() -> u64 { @@ -284,6 +289,9 @@ pub enum SolanaCommandResult { AccountTypes { accounts: Vec<AccountView>, }, + Coverage { + overlay: RuntimeOverlay, + }, Error { message: String, }, diff --git a/crates/ilold-solana-core/src/exploration/execute.rs b/crates/ilold-solana-core/src/exploration/execute.rs index 2b22325..7cbf485 100644 --- a/crates/ilold-solana-core/src/exploration/execute.rs +++ b/crates/ilold-solana-core/src/exploration/execute.rs @@ -74,6 +74,19 @@ pub fn execute_vars(program: &ProgramDef) -> SolanaCommandResult { } } +pub fn execute_coverage( + program: &ProgramDef, + session: &ExplorationSession, + scenario: &str, +) -> SolanaCommandResult { + let mut overlay = crate::overlay::RuntimeOverlay::from_session(session); + if overlay.program.is_empty() { + overlay.program = program.name.clone(); + } + overlay.scenario = scenario.to_string(); + SolanaCommandResult::Coverage { overlay } +} + pub fn execute_users(users: &HashMap<String, Keypair>, vm: &VmHost) -> SolanaCommandResult { let mut entries: Vec<UserEntry> = users .iter() diff --git a/crates/ilold-solana-core/src/exploration/mod.rs b/crates/ilold-solana-core/src/exploration/mod.rs index a206b77..d5bc6c9 100644 --- a/crates/ilold-solana-core/src/exploration/mod.rs +++ b/crates/ilold-solana-core/src/exploration/mod.rs @@ -9,8 +9,8 @@ pub use commands::{ }; pub use execute::{ execute_airdrop, execute_back, execute_call, execute_clear, execute_coupling, - execute_export, execute_finding, execute_findings_list, execute_funcs, execute_info, - execute_inspect, execute_note, execute_pda, execute_session, execute_state, - execute_status, execute_step, execute_time_warp, execute_timeline, execute_users, - execute_users_new, execute_vars, execute_who, + execute_coverage, execute_export, execute_finding, execute_findings_list, execute_funcs, + execute_info, execute_inspect, execute_note, execute_pda, execute_session, + execute_state, execute_status, execute_step, execute_time_warp, execute_timeline, + execute_users, execute_users_new, execute_vars, execute_who, }; diff --git a/crates/ilold-solana-core/src/lib.rs b/crates/ilold-solana-core/src/lib.rs index 6c72676..75ce073 100644 --- a/crates/ilold-solana-core/src/lib.rs +++ b/crates/ilold-solana-core/src/lib.rs @@ -6,6 +6,8 @@ pub mod exploration; pub mod idl; pub mod ingest; pub mod model; +pub mod overlay; pub mod view; +pub use overlay::{CpiEdge, CuStats, RuntimeOverlay}; pub use view::ProgramView; diff --git a/crates/ilold-solana-core/src/overlay.rs b/crates/ilold-solana-core/src/overlay.rs new file mode 100644 index 0000000..534bc6c --- /dev/null +++ b/crates/ilold-solana-core/src/overlay.rs @@ -0,0 +1,260 @@ +use std::collections::BTreeMap; + +use ilold_session_core::exploration::session::ExplorationSession; +use ilold_session_core::runtime_trace::RuntimeTrace; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct RuntimeOverlay { + pub program: String, + pub scenario: String, + pub calls_per_ix: BTreeMap<String, u32>, + pub failed_per_ix: BTreeMap<String, u32>, + pub cu_stats_per_ix: BTreeMap<String, CuStats>, + pub cpi_edges: Vec<CpiEdge>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CuStats { + pub min: u64, + pub max: u64, + pub avg: u64, + pub samples: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CpiEdge { + pub from_ix: String, + pub to_program: String, + pub depth: u32, + pub samples: u32, +} + +impl RuntimeOverlay { + pub fn from_session(session: &ExplorationSession) -> Self { + let mut overlay = RuntimeOverlay { + program: session.contract.clone(), + scenario: String::new(), + calls_per_ix: BTreeMap::new(), + failed_per_ix: BTreeMap::new(), + cu_stats_per_ix: BTreeMap::new(), + cpi_edges: Vec::new(), + }; + + let mut cu_samples: BTreeMap<String, Vec<u64>> = BTreeMap::new(); + // Aggregation key for CPI edges: (from_ix, to_program, depth). Tracked + // separately so the final Vec<CpiEdge> can be deterministic-sorted. + let mut cpi_counts: BTreeMap<(String, String, u32), u32> = BTreeMap::new(); + + for step in &session.steps { + let trace: Option<RuntimeTrace> = step + .runtime_trace + .as_ref() + .and_then(|v| serde_json::from_value(v.clone()).ok()); + + let failed = trace + .as_ref() + .map(|t| t.error.is_some()) + .unwrap_or(false); + + if failed { + *overlay.failed_per_ix.entry(step.function.clone()).or_insert(0) += 1; + } else { + *overlay.calls_per_ix.entry(step.function.clone()).or_insert(0) += 1; + } + + if let Some(t) = trace.as_ref() { + cu_samples + .entry(step.function.clone()) + .or_default() + .push(t.compute_units); + + for inner in &t.inner_instructions { + let key = ( + step.function.clone(), + inner.program.clone(), + inner.depth, + ); + *cpi_counts.entry(key).or_insert(0) += 1; + } + } + } + + for (ix, samples) in cu_samples { + let count = samples.len() as u32; + if count == 0 { + continue; + } + let min = samples.iter().copied().min().unwrap_or(0); + let max = samples.iter().copied().max().unwrap_or(0); + let sum: u128 = samples.iter().map(|v| *v as u128).sum(); + let avg = (sum / count as u128) as u64; + overlay + .cu_stats_per_ix + .insert(ix, CuStats { min, max, avg, samples: count }); + } + + let mut edges: Vec<CpiEdge> = cpi_counts + .into_iter() + .map(|((from_ix, to_program, depth), samples)| CpiEdge { + from_ix, + to_program, + depth, + samples, + }) + .collect(); + edges.sort_by(|a, b| { + a.from_ix + .cmp(&b.from_ix) + .then_with(|| a.to_program.cmp(&b.to_program)) + .then_with(|| a.depth.cmp(&b.depth)) + }); + overlay.cpi_edges = edges; + + overlay + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ilold_session_core::exploration::session::{ + ExplorationSession, ExplorationStep, TraceConfig, + }; + use ilold_session_core::runtime_trace::{InnerInstruction, RuntimeTrace}; + + fn empty_session() -> ExplorationSession { + ExplorationSession::new("staking", "ilold") + } + + fn step_with_trace(name: &str, trace: RuntimeTrace) -> ExplorationStep { + ExplorationStep { + function: name.to_string(), + mutations: vec![], + flow_tree: None, + trace_config: TraceConfig::default(), + runtime_trace: Some(serde_json::to_value(&trace).unwrap()), + call_payload: None, + } + } + + fn ok_trace(cu: u64) -> RuntimeTrace { + RuntimeTrace { + logs: vec![], + compute_units: cu, + inner_instructions: vec![], + account_diffs: vec![], + return_data: None, + error: None, + } + } + + fn err_trace(cu: u64, msg: &str) -> RuntimeTrace { + RuntimeTrace { + logs: vec![], + compute_units: cu, + inner_instructions: vec![], + account_diffs: vec![], + return_data: None, + error: Some(msg.to_string()), + } + } + + #[test] + fn from_session_empty_returns_empty() { + let session = empty_session(); + let overlay = RuntimeOverlay::from_session(&session); + assert_eq!(overlay.program, "staking"); + assert!(overlay.calls_per_ix.is_empty()); + assert!(overlay.failed_per_ix.is_empty()); + assert!(overlay.cu_stats_per_ix.is_empty()); + assert!(overlay.cpi_edges.is_empty()); + } + + #[test] + fn from_session_aggregates_calls_per_ix() { + let mut session = empty_session(); + session.steps.push(step_with_trace("stake", ok_trace(10_000))); + session.steps.push(step_with_trace("stake", ok_trace(14_000))); + session.steps.push(step_with_trace("unstake", ok_trace(12_000))); + + let overlay = RuntimeOverlay::from_session(&session); + assert_eq!(overlay.calls_per_ix.get("stake").copied(), Some(2)); + assert_eq!(overlay.calls_per_ix.get("unstake").copied(), Some(1)); + assert!(overlay.failed_per_ix.is_empty()); + + let stake = overlay.cu_stats_per_ix.get("stake").expect("stake stats"); + assert_eq!(stake.samples, 2); + assert_eq!(stake.min, 10_000); + assert_eq!(stake.max, 14_000); + assert_eq!(stake.avg, 12_000); + } + + #[test] + fn from_session_separates_failed() { + let mut session = empty_session(); + session.steps.push(step_with_trace("stake", ok_trace(11_000))); + session + .steps + .push(step_with_trace("stake", err_trace(0, "AnchorError"))); + session + .steps + .push(step_with_trace("unstake", err_trace(0, "panicked"))); + + let overlay = RuntimeOverlay::from_session(&session); + assert_eq!(overlay.calls_per_ix.get("stake").copied(), Some(1)); + assert_eq!(overlay.failed_per_ix.get("stake").copied(), Some(1)); + assert_eq!(overlay.failed_per_ix.get("unstake").copied(), Some(1)); + } + + #[test] + fn from_session_collects_cpi_edges() { + let mut session = empty_session(); + let mut trace_a = ok_trace(15_000); + trace_a.inner_instructions = vec![ + InnerInstruction { + program: "11111111111111111111111111111111".into(), + instruction: "abc".into(), + depth: 1, + }, + InnerInstruction { + program: "11111111111111111111111111111111".into(), + instruction: "abc".into(), + depth: 1, + }, + InnerInstruction { + program: "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA".into(), + instruction: "xyz".into(), + depth: 2, + }, + ]; + let mut trace_b = ok_trace(13_000); + trace_b.inner_instructions = vec![InnerInstruction { + program: "11111111111111111111111111111111".into(), + instruction: "abc".into(), + depth: 1, + }]; + session.steps.push(step_with_trace("stake", trace_a)); + session.steps.push(step_with_trace("unstake", trace_b)); + + let overlay = RuntimeOverlay::from_session(&session); + assert_eq!(overlay.cpi_edges.len(), 3); + // Sorted by (from_ix, to_program, depth). + let stake_sys = &overlay.cpi_edges[0]; + assert_eq!(stake_sys.from_ix, "stake"); + assert_eq!(stake_sys.to_program, "11111111111111111111111111111111"); + assert_eq!(stake_sys.depth, 1); + assert_eq!(stake_sys.samples, 2); + let stake_token = &overlay.cpi_edges[1]; + assert_eq!(stake_token.from_ix, "stake"); + assert_eq!( + stake_token.to_program, + "TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA" + ); + assert_eq!(stake_token.depth, 2); + assert_eq!(stake_token.samples, 1); + let unstake_sys = &overlay.cpi_edges[2]; + assert_eq!(unstake_sys.from_ix, "unstake"); + assert_eq!(unstake_sys.samples, 1); + } +} diff --git a/crates/ilold-solana-core/tests/e2e_lever.rs b/crates/ilold-solana-core/tests/e2e_lever.rs index 8af6fb8..3aad688 100644 --- a/crates/ilold-solana-core/tests/e2e_lever.rs +++ b/crates/ilold-solana-core/tests/e2e_lever.rs @@ -24,6 +24,66 @@ fn read_lever_so() -> Vec<u8> { ) } +#[test] +fn add_solana_step_initialize_captures_cpi_inner_instructions() { + // Anchor's `init` constraint CPIs into system_program::create_account. + // The patch in add_step.rs must surface those inner ixs in the trace. + let idl = parse_idl(LEVER_JSON).expect("parse lever idl"); + let program = ProgramDef::from_idl(idl).expect("build ProgramDef"); + let so_bytes = read_lever_so(); + let mut vm = VmHost::boot(vec![(program.program_id, so_bytes)]).expect("boot vm"); + + let admin = Keypair::new(); + vm.svm_mut() + .airdrop(&admin.pubkey(), 5_000_000_000) + .expect("airdrop admin"); + let power = Keypair::new(); + + let mut accounts: HashMap<String, Address> = HashMap::new(); + accounts.insert("power".into(), power.pubkey()); + accounts.insert("user".into(), admin.pubkey()); + + let init_ix = program + .instructions + .iter() + .find(|i| i.name == "initialize") + .expect("initialize ix"); + + let mut session = ExplorationSession::new("lever", "ilold"); + let outcome = add_solana_step( + &mut session, + &program, + init_ix, + &mut vm, + serde_json::json!({}), + accounts, + &[&admin, &power], + "2026-05-09T00:00:00Z", + None, + ) + .expect("add_solana_step initialize"); + + assert!( + outcome.trace.error.is_none(), + "initialize errored: {:?}", + outcome.trace.error + ); + assert!( + !outcome.trace.inner_instructions.is_empty(), + "expected inner_instructions to be populated by Anchor `init` CPI; got empty" + ); + let sys = outcome + .trace + .inner_instructions + .iter() + .find(|i| i.program == "11111111111111111111111111111111"); + assert!( + sys.is_some(), + "expected at least one inner ix targeting system_program; got {:?}", + outcome.trace.inner_instructions, + ); +} + #[test] fn add_solana_step_runs_switch_power_against_real_program() { let idl = parse_idl(LEVER_JSON).expect("parse lever idl"); diff --git a/crates/ilold-solana-core/tests/program_view_snapshot.rs b/crates/ilold-solana-core/tests/program_view_snapshot.rs index 78ade8d..3b20a6a 100644 --- a/crates/ilold-solana-core/tests/program_view_snapshot.rs +++ b/crates/ilold-solana-core/tests/program_view_snapshot.rs @@ -1,11 +1,13 @@ use std::fs; use std::path::{Path, PathBuf}; +use ilold_session_core::exploration::session::ExplorationSession; use ilold_solana_core::exploration::{ execute_funcs, execute_pda, execute_who, SolanaCommandResult, }; use ilold_solana_core::idl::parse_idl; use ilold_solana_core::model::ProgramDef; +use ilold_solana_core::overlay::RuntimeOverlay; const LEVER_JSON: &str = include_str!("fixtures/lever.json"); const RELATIONS_JSON: &str = include_str!("fixtures/relations.json"); @@ -129,3 +131,28 @@ fn program_view_wire_format_staking() { "ProgramView wire-format drift; if intentional, regen with ILOLD_REGEN_SNAPSHOTS=1" ); } + +#[test] +fn runtime_overlay_wire_format_empty_staking() { + let session = ExplorationSession::new("staking", "ilold"); + let mut overlay = RuntimeOverlay::from_session(&session); + overlay.scenario = "main".to_string(); + let json = serde_json::to_string_pretty(&overlay).expect("serialize RuntimeOverlay"); + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("snapshots") + .join("staking_overlay.json"); + if regen_enabled() { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, format!("{json}\n")).unwrap(); + return; + } + let expected = fs::read_to_string(&path) + .unwrap_or_else(|e| panic!("missing wire-format snapshot {}: {e}", path.display())); + let expected_trimmed = expected.trim_end_matches('\n'); + let json_trimmed = json.trim_end_matches('\n'); + assert_eq!( + json_trimmed, expected_trimmed, + "RuntimeOverlay wire-format drift; if intentional, regen with ILOLD_REGEN_SNAPSHOTS=1" + ); +} diff --git a/crates/ilold-solana-core/tests/snapshots/staking_overlay.json b/crates/ilold-solana-core/tests/snapshots/staking_overlay.json new file mode 100644 index 0000000..b75158c --- /dev/null +++ b/crates/ilold-solana-core/tests/snapshots/staking_overlay.json @@ -0,0 +1,8 @@ +{ + "program": "staking", + "scenario": "main", + "calls_per_ix": {}, + "failed_per_ix": {}, + "cu_stats_per_ix": {}, + "cpi_edges": [] +} diff --git a/crates/ilold-web/src/api/project.rs b/crates/ilold-web/src/api/project.rs index e21befe..ea14085 100644 --- a/crates/ilold-web/src/api/project.rs +++ b/crates/ilold-web/src/api/project.rs @@ -1,11 +1,12 @@ use std::sync::Arc; -use axum::extract::{Path, State}; +use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::Json; use ilold_solana_core::model::ProgramDef; +use ilold_solana_core::overlay::RuntimeOverlay; use ilold_solana_core::view::ProgramView; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use crate::state::{require_solidity_msg, AppState, Backend}; @@ -138,6 +139,34 @@ pub async fn get_program_view( Ok(Json(program.compute_view())) } +#[derive(Deserialize, Default)] +pub struct OverlayQuery { + pub scenario: Option<String>, +} + +pub async fn get_program_overlay( + State(state): State<Arc<AppState>>, + Path(name): Path<String>, + Query(params): Query<OverlayQuery>, +) -> Result<Json<RuntimeOverlay>, (StatusCode, String)> { + let program = find_solana_program(&state, &name)?; + let scenarios = state.scenarios.read().unwrap(); + let scenario_name = params + .scenario + .clone() + .unwrap_or_else(|| scenarios.active().to_string()); + let session = scenarios.get(&scenario_name).ok_or(( + StatusCode::NOT_FOUND, + format!("scenario '{scenario_name}' not found"), + ))?; + let mut overlay = RuntimeOverlay::from_session(session); + if overlay.program.is_empty() { + overlay.program = program.name.clone(); + } + overlay.scenario = scenario_name; + Ok(Json(overlay)) +} + pub async fn get_project_map( State(state): State<Arc<AppState>>, ) -> Json<ProjectMap> { diff --git a/crates/ilold-web/src/api/session.rs b/crates/ilold-web/src/api/session.rs index 6e75d06..94ecfc1 100644 --- a/crates/ilold-web/src/api/session.rs +++ b/crates/ilold-web/src/api/session.rs @@ -21,11 +21,11 @@ use ilold_core::slicing::{build_slice_result, SliceDirection, SliceResult}; use ilold_session_core::exploration::scenario::ScenarioAction as SharedScenarioAction; use ilold_solana_core::exploration::{ canvas_patch_from_solana, execute_airdrop, execute_back, execute_call, execute_clear, - execute_coupling, execute_export, execute_findings_list, execute_info, execute_step, - execute_timeline, execute_vars, execute_who, execute_finding, execute_funcs, - execute_inspect, execute_note, execute_pda, execute_session, execute_state, - execute_status, execute_time_warp, execute_users, execute_users_new, SolanaCommand, - SolanaCommandResult, + execute_coupling, execute_coverage, execute_export, execute_findings_list, execute_info, + execute_step, execute_timeline, execute_vars, execute_who, execute_finding, + execute_funcs, execute_inspect, execute_note, execute_pda, execute_session, + execute_state, execute_status, execute_time_warp, execute_users, execute_users_new, + SolanaCommand, SolanaCommandResult, }; use solana_keypair::Keypair; @@ -673,6 +673,7 @@ async fn handle_solana_command( SolanaCommand::Info { ix } => execute_info(&program, &ix), SolanaCommand::Coupling => execute_coupling(&program), SolanaCommand::Vars => execute_vars(&program), + SolanaCommand::Coverage => execute_coverage(&program, session, &active_scenario), SolanaCommand::SaveSession { .. } | SolanaCommand::LoadSession { .. } | SolanaCommand::Scenario { .. } => unreachable!("handled above"), diff --git a/crates/ilold-web/src/lib.rs b/crates/ilold-web/src/lib.rs index 8f51569..1ea39da 100644 --- a/crates/ilold-web/src/lib.rs +++ b/crates/ilold-web/src/lib.rs @@ -18,6 +18,7 @@ fn build_router(state: Arc<AppState>) -> Router { .route("/api/project", get(api::project::get_project)) .route("/api/project/map", get(api::project::get_project_map)) .route("/api/program/{name}/view", get(api::project::get_program_view)) + .route("/api/program/{name}/overlay", get(api::project::get_program_overlay)) .route("/api/contract/{name}", get(api::contract::get_contract)) .route("/api/contract/{name}/callgraph", get(api::contract::get_callgraph)) .route("/api/contract/{name}/{func}/cfg", get(api::contract::get_cfg)) diff --git a/crates/ilold-web/tests/solana_command_pipeline.rs b/crates/ilold-web/tests/solana_command_pipeline.rs index f13cbc1..bd483d9 100644 --- a/crates/ilold-web/tests/solana_command_pipeline.rs +++ b/crates/ilold-web/tests/solana_command_pipeline.rs @@ -645,3 +645,64 @@ async fn program_view_endpoint_returns_404_for_missing_program() { .expect("GET /view ghost"); assert_eq!(res.status().as_u16(), 404); } + +#[tokio::test] +async fn coverage_command_returns_overlay() { + let (client, port) = start_staking().await; + let result = cmd(&client, port, "staking", serde_json::json!("Coverage")).await; + let overlay = result + .get("Coverage") + .and_then(|v| v.get("overlay")) + .expect("Coverage.overlay variant"); + assert_eq!( + overlay.get("program").and_then(|v| v.as_str()), + Some("staking"), + ); + let scenario = overlay + .get("scenario") + .and_then(|v| v.as_str()) + .expect("scenario field"); + assert!(!scenario.is_empty(), "scenario name should be set"); + let calls = overlay + .get("calls_per_ix") + .and_then(|v| v.as_object()) + .expect("calls_per_ix object"); + assert!(calls.is_empty(), "fresh session has no calls yet"); + let edges = overlay + .get("cpi_edges") + .and_then(|v| v.as_array()) + .expect("cpi_edges array"); + assert!(edges.is_empty()); +} + +#[tokio::test] +async fn program_overlay_endpoint_returns_typed_overlay() { + let (client, port) = start_staking().await; + let res = client + .get(format!("http://127.0.0.1:{port}/api/program/staking/overlay")) + .send() + .await + .expect("GET /overlay"); + assert!(res.status().is_success()); + let body: serde_json::Value = res.json().await.expect("json"); + assert_eq!( + body.get("program").and_then(|v| v.as_str()), + Some("staking"), + ); + assert!(body.get("scenario").and_then(|v| v.as_str()).is_some()); + assert!(body.get("calls_per_ix").is_some()); + assert!(body.get("failed_per_ix").is_some()); + assert!(body.get("cu_stats_per_ix").is_some()); + assert!(body.get("cpi_edges").is_some()); +} + +#[tokio::test] +async fn program_overlay_endpoint_returns_404_for_missing_program() { + let (client, port) = start_staking().await; + let res = client + .get(format!("http://127.0.0.1:{port}/api/program/ghost/overlay")) + .send() + .await + .expect("GET /overlay ghost"); + assert_eq!(res.status().as_u16(), 404); +} diff --git a/tests/scenarios/14-coverage-overlay.sh b/tests/scenarios/14-coverage-overlay.sh new file mode 100755 index 0000000..f47636b --- /dev/null +++ b/tests/scenarios/14-coverage-overlay.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash +# Scenario 14: validates RuntimeOverlay (T-R52) end-to-end via the Coverage +# command and the GET /api/program/{name}/overlay endpoint. +# +# Default fixture (tests/fixtures/solana/staking) exercises Anchor `init`, +# which CPIs into system_program (11111…) — so cpi_edges should be non-empty +# after initialize_pool / stake. To exercise a hand→lever CPI explicitly, +# rerun with `ILOLD_FIXTURE=tests/fixtures/solana/cpi tests/scenarios/run.sh`. +set -uo pipefail +DIR="$(cd "$(dirname "$0")" && pwd)" +. "$DIR/_lib.sh" + +echo "scenario 14: coverage overlay" + +setup_users +init_pool >/dev/null +stake_as alice alice_stake 1000 >/dev/null +stake_as alice alice_stake 500 >/dev/null +stake_as bob bob_stake 2000 >/dev/null + +# Coverage via /api/cmd +cov_response=$(post '{"contract":"staking","command":"Coverage"}') +overlay_json=$(echo "$cov_response" | jq '.Coverage.overlay') + +prog=$(echo "$overlay_json" | jq -r '.program') +expect_eq "coverage.program is staking" "staking" "$prog" + +scn=$(echo "$overlay_json" | jq -r '.scenario') +if [ -n "$scn" ] && [ "$scn" != "null" ]; then has_scn=1; else has_scn=0; fi +expect_true "coverage.scenario is set" "$has_scn" + +stake_calls=$(echo "$overlay_json" | jq -r '.calls_per_ix.stake // 0') +expect_eq "stake called three times" "3" "$stake_calls" + +init_calls=$(echo "$overlay_json" | jq -r '.calls_per_ix.initialize_pool // 0') +expect_eq "initialize_pool called once" "1" "$init_calls" + +cu_samples=$(echo "$overlay_json" | jq -r '.cu_stats_per_ix.stake.samples // 0') +expect_eq "cu_stats.stake.samples == 3" "3" "$cu_samples" + +# Anchor `init` (used by initialize_pool / stake's user_stake init) CPIs to +# system_program. The overlay must surface those edges. +sys_edges=$(echo "$overlay_json" \ + | jq -r '[.cpi_edges[] | select(.to_program=="11111111111111111111111111111111")] | length') +if [ "${sys_edges:-0}" -ge 1 ]; then has_sys=1; else has_sys=0; fi +expect_true "cpi_edges contains system_program" "$has_sys" + +# REST endpoint roundtrip — same shape, served via GET. +rest_overlay=$(curl -sf "$BASE/api/program/staking/overlay") +rest_prog=$(echo "$rest_overlay" | jq -r '.program') +expect_eq "REST /overlay program" "staking" "$rest_prog" + +rest_stake=$(echo "$rest_overlay" | jq -r '.calls_per_ix.stake // 0') +expect_eq "REST /overlay stake==3" "3" "$rest_stake" + +scenario_summary "14-coverage-overlay" From 0c6b898ef877488bc6269d09616a1fff6f961d9c Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Sun, 10 May 2026 23:01:35 +0200 Subject: [PATCH 099/115] feat(solana): persist failed calls + broadcast incremental overlay - Add ExplorationSession.failed_calls_per_ix counter persisted off session.steps - Increment counter from execute_call CallFailed branch - RuntimeOverlay::from_session reads counter so /overlay reflects rejected calls - New CanvasPatch::OverlayUpdate broadcast after each StepAdded and CallFailed - Frontend RuntimeOverlay types, getProgramOverlay client, store with merge - InstructionNode shows called Nx, ~CU avg, rejected Nx badges - Snapshot test rewritten to exercise real CallFailed flow --- .../src/exploration/canvas.rs | 12 ++ .../src/exploration/session.rs | 17 ++- .../src/exploration/commands.rs | 94 ++++++++----- .../src/exploration/execute.rs | 21 +-- .../ilold-solana-core/src/exploration/mod.rs | 2 +- crates/ilold-solana-core/src/overlay.rs | 41 ++---- .../tests/overlay_callfailed.rs | 87 ++++++++++++ crates/ilold-web/frontend/package.json | 3 +- .../scripts/check-runtime-overlay.mjs | 52 +++++++ crates/ilold-web/frontend/src/lib/api/rest.ts | 35 +++++ .../ilold-web/frontend/src/lib/api/types.ts | 15 ++ crates/ilold-web/frontend/src/lib/api/ws.ts | 1 + .../contract/nodes/InstructionNode.svelte | 43 ++++++ .../src/lib/stores/runtimeOverlay.svelte.ts | 129 ++++++++++++++++++ .../src/routes/contract/[name]/+page.svelte | 23 +++- crates/ilold-web/src/api/session.rs | 37 ++++- crates/ilold-web/src/ws/handler.rs | 25 ++++ tests/scenarios/14-coverage-overlay.sh | 11 ++ tests/scenarios/ws-broadcast.py | 1 + 19 files changed, 568 insertions(+), 81 deletions(-) create mode 100644 crates/ilold-solana-core/tests/overlay_callfailed.rs create mode 100644 crates/ilold-web/frontend/scripts/check-runtime-overlay.mjs create mode 100644 crates/ilold-web/frontend/src/lib/stores/runtimeOverlay.svelte.ts diff --git a/crates/ilold-session-core/src/exploration/canvas.rs b/crates/ilold-session-core/src/exploration/canvas.rs index 6925ac9..a963dac 100644 --- a/crates/ilold-session-core/src/exploration/canvas.rs +++ b/crates/ilold-session-core/src/exploration/canvas.rs @@ -19,6 +19,18 @@ pub enum CanvasPatch { Highlight { scenario: String, function: String }, ScenarioEvent(ScenarioEvent), SolanaUsersChanged { scenario: String }, + /// Incremental runtime overlay delta emitted right after a Call resolves + /// (StepAdded or CallFailed). The frontend store merges these against the + /// snapshot fetched from `/api/program/{name}/overlay` so badges stay in + /// sync without re-fetching the full overlay on every tick. + OverlayUpdate { + scenario: String, + ix_name: String, + calls_added: u32, + failed_added: u32, + cu: Option<u64>, + cpi_targets_added: Vec<String>, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/crates/ilold-session-core/src/exploration/session.rs b/crates/ilold-session-core/src/exploration/session.rs index 4ca9221..adca080 100644 --- a/crates/ilold-session-core/src/exploration/session.rs +++ b/crates/ilold-session-core/src/exploration/session.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use serde::{Deserialize, Serialize}; @@ -12,6 +12,12 @@ pub struct ExplorationSession { pub journal: AuditJournal, #[serde(default)] pub forked_from: Option<ForkOrigin>, + /// Counts CallFailed outcomes per instruction. Lives off `steps` because + /// failed Calls deliberately do not push a step (Solidity-aligned model) + /// — without this counter the runtime overlay could never surface + /// "rejected Nx" badges. + #[serde(default)] + pub failed_calls_per_ix: BTreeMap<String, u32>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -88,6 +94,7 @@ impl ExplorationSession { steps: Vec::new(), journal: AuditJournal::new(project, contract, ""), forked_from: None, + failed_calls_per_ix: BTreeMap::new(), } } @@ -97,6 +104,14 @@ impl ExplorationSession { pub fn clear(&mut self) { self.steps.clear(); + self.failed_calls_per_ix.clear(); + } + + pub fn record_failed_call(&mut self, ix: &str) { + *self + .failed_calls_per_ix + .entry(ix.to_string()) + .or_insert(0) += 1; } pub fn current_sequence(&self) -> Vec<&str> { diff --git a/crates/ilold-solana-core/src/exploration/commands.rs b/crates/ilold-solana-core/src/exploration/commands.rs index a186783..76e1407 100644 --- a/crates/ilold-solana-core/src/exploration/commands.rs +++ b/crates/ilold-solana-core/src/exploration/commands.rs @@ -369,10 +369,11 @@ pub struct TimelineEntry { pub after_decoded: Option<Value>, } -pub fn canvas_patch_from_solana( +pub fn canvas_patches_from_solana( result: &SolanaCommandResult, active_scenario: &str, -) -> Option<CanvasPatch> { + cpi_targets: &[String], +) -> Vec<CanvasPatch> { use ilold_session_core::exploration::canvas::RuntimeMeta; match result { SolanaCommandResult::StepAdded { @@ -382,58 +383,83 @@ pub fn canvas_patch_from_solana( account_diffs_count, compute_units, error, - } => Some(CanvasPatch::AddNode { + } => vec![ + // AddNode first, OverlayUpdate after — clients that paint badges + // off the canvas node need the node to exist before the delta + // arrives. + CanvasPatch::AddNode { + scenario: active_scenario.to_string(), + function: instruction.clone(), + access: AccessLevel::Public, + step_index: *step_index, + runtime: Some(RuntimeMeta { + compute_units: *compute_units, + diffs_count: *account_diffs_count, + logs_excerpt: logs_excerpt.clone(), + error: error.clone(), + trace: None, + }), + }, + CanvasPatch::OverlayUpdate { + scenario: active_scenario.to_string(), + ix_name: instruction.clone(), + calls_added: 1, + failed_added: 0, + cu: Some(*compute_units), + cpi_targets_added: cpi_targets.to_vec(), + }, + ], + SolanaCommandResult::CallFailed { + instruction, + compute_units, + .. + } => vec![CanvasPatch::OverlayUpdate { scenario: active_scenario.to_string(), - function: instruction.clone(), - access: AccessLevel::Public, - step_index: *step_index, - runtime: Some(RuntimeMeta { - compute_units: *compute_units, - diffs_count: *account_diffs_count, - logs_excerpt: logs_excerpt.clone(), - error: error.clone(), - trace: None, - }), - }), - SolanaCommandResult::StepRemoved { .. } => Some(CanvasPatch::RemoveLastNode { + ix_name: instruction.clone(), + calls_added: 0, + failed_added: 1, + cu: Some(*compute_units), + cpi_targets_added: Vec::new(), + }], + SolanaCommandResult::StepRemoved { .. } => vec![CanvasPatch::RemoveLastNode { scenario: active_scenario.to_string(), - }), - SolanaCommandResult::Cleared => Some(CanvasPatch::ClearAll { + }], + SolanaCommandResult::Cleared => vec![CanvasPatch::ClearAll { scenario: active_scenario.to_string(), - }), - SolanaCommandResult::ScenarioCreated { name } => Some(CanvasPatch::ScenarioEvent( + }], + SolanaCommandResult::ScenarioCreated { name } => vec![CanvasPatch::ScenarioEvent( ScenarioEvent::Created { name: name.clone() }, - )), + )], SolanaCommandResult::ScenarioSwitched { from, to } => { if from == to { - None + Vec::new() } else { - Some(CanvasPatch::ScenarioEvent(ScenarioEvent::Switched { + vec![CanvasPatch::ScenarioEvent(ScenarioEvent::Switched { from: from.clone(), to: to.clone(), - })) + })] } } - SolanaCommandResult::ScenarioDeleted { name } => Some(CanvasPatch::ScenarioEvent( + SolanaCommandResult::ScenarioDeleted { name } => vec![CanvasPatch::ScenarioEvent( ScenarioEvent::Deleted { name: name.clone() }, - )), - SolanaCommandResult::ScenarioForked { from, to, at_step } => Some( - CanvasPatch::ScenarioEvent(ScenarioEvent::Forked { + )], + SolanaCommandResult::ScenarioForked { from, to, at_step } => { + vec![CanvasPatch::ScenarioEvent(ScenarioEvent::Forked { from: from.clone(), to: to.clone(), at_step: *at_step, - }), - ), - SolanaCommandResult::SessionLoaded { .. } => Some(CanvasPatch::ScenarioEvent( + })] + } + SolanaCommandResult::SessionLoaded { .. } => vec![CanvasPatch::ScenarioEvent( ScenarioEvent::Reloaded { active: active_scenario.to_string(), }, - )), + )], SolanaCommandResult::UserCreated { .. } | SolanaCommandResult::Airdropped { .. } => { - Some(CanvasPatch::SolanaUsersChanged { + vec![CanvasPatch::SolanaUsersChanged { scenario: active_scenario.to_string(), - }) + }] } - _ => None, + _ => Vec::new(), } } diff --git a/crates/ilold-solana-core/src/exploration/execute.rs b/crates/ilold-solana-core/src/exploration/execute.rs index 7cbf485..7f2ebd1 100644 --- a/crates/ilold-solana-core/src/exploration/execute.rs +++ b/crates/ilold-solana-core/src/exploration/execute.rs @@ -354,15 +354,18 @@ pub fn execute_call( compute_units, error: None, }, - None => SolanaCommandResult::CallFailed { - instruction: ix.name.clone(), - logs_excerpt, - compute_units, - error: trace - .error - .clone() - .unwrap_or_else(|| "unknown VM error".to_string()), - }, + None => { + session.record_failed_call(&ix.name); + SolanaCommandResult::CallFailed { + instruction: ix.name.clone(), + logs_excerpt, + compute_units, + error: trace + .error + .clone() + .unwrap_or_else(|| "unknown VM error".to_string()), + } + } } } diff --git a/crates/ilold-solana-core/src/exploration/mod.rs b/crates/ilold-solana-core/src/exploration/mod.rs index d5bc6c9..13434df 100644 --- a/crates/ilold-solana-core/src/exploration/mod.rs +++ b/crates/ilold-solana-core/src/exploration/mod.rs @@ -4,7 +4,7 @@ pub mod execute; pub use add_step::add_solana_step; pub use commands::{ - canvas_patch_from_solana, AccountSummary, InstructionEntry, PdaEntry, SolanaCommand, + canvas_patches_from_solana, AccountSummary, InstructionEntry, PdaEntry, SolanaCommand, SolanaCommandResult, UserEntry, WhoEntry, WhoIxAccount, WhoQueryKind, }; pub use execute::{ diff --git a/crates/ilold-solana-core/src/overlay.rs b/crates/ilold-solana-core/src/overlay.rs index 534bc6c..11e77e7 100644 --- a/crates/ilold-solana-core/src/overlay.rs +++ b/crates/ilold-solana-core/src/overlay.rs @@ -47,22 +47,13 @@ impl RuntimeOverlay { let mut cpi_counts: BTreeMap<(String, String, u32), u32> = BTreeMap::new(); for step in &session.steps { + *overlay.calls_per_ix.entry(step.function.clone()).or_insert(0) += 1; + let trace: Option<RuntimeTrace> = step .runtime_trace .as_ref() .and_then(|v| serde_json::from_value(v.clone()).ok()); - let failed = trace - .as_ref() - .map(|t| t.error.is_some()) - .unwrap_or(false); - - if failed { - *overlay.failed_per_ix.entry(step.function.clone()).or_insert(0) += 1; - } else { - *overlay.calls_per_ix.entry(step.function.clone()).or_insert(0) += 1; - } - if let Some(t) = trace.as_ref() { cu_samples .entry(step.function.clone()) @@ -80,6 +71,10 @@ impl RuntimeOverlay { } } + for (ix, count) in &session.failed_calls_per_ix { + overlay.failed_per_ix.insert(ix.clone(), *count); + } + for (ix, samples) in cu_samples { let count = samples.len() as u32; if count == 0 { @@ -149,17 +144,6 @@ mod tests { } } - fn err_trace(cu: u64, msg: &str) -> RuntimeTrace { - RuntimeTrace { - logs: vec![], - compute_units: cu, - inner_instructions: vec![], - account_diffs: vec![], - return_data: None, - error: Some(msg.to_string()), - } - } - #[test] fn from_session_empty_returns_empty() { let session = empty_session(); @@ -191,20 +175,19 @@ mod tests { } #[test] - fn from_session_separates_failed() { + fn from_session_reads_failed_calls_counter() { let mut session = empty_session(); session.steps.push(step_with_trace("stake", ok_trace(11_000))); - session - .steps - .push(step_with_trace("stake", err_trace(0, "AnchorError"))); - session - .steps - .push(step_with_trace("unstake", err_trace(0, "panicked"))); + // Failed Calls never push a step (they go through record_failed_call + // in execute_call::CallFailed). Mirror that real flow here. + session.record_failed_call("stake"); + session.record_failed_call("unstake"); let overlay = RuntimeOverlay::from_session(&session); assert_eq!(overlay.calls_per_ix.get("stake").copied(), Some(1)); assert_eq!(overlay.failed_per_ix.get("stake").copied(), Some(1)); assert_eq!(overlay.failed_per_ix.get("unstake").copied(), Some(1)); + assert_eq!(overlay.calls_per_ix.get("unstake"), None); } #[test] diff --git a/crates/ilold-solana-core/tests/overlay_callfailed.rs b/crates/ilold-solana-core/tests/overlay_callfailed.rs new file mode 100644 index 0000000..954eab0 --- /dev/null +++ b/crates/ilold-solana-core/tests/overlay_callfailed.rs @@ -0,0 +1,87 @@ +use std::collections::HashMap; +use std::path::PathBuf; + +use ilold_session_core::exploration::session::ExplorationSession; +use ilold_solana_core::execute::VmHost; +use ilold_solana_core::exploration::{execute_call, SolanaCommandResult}; +use ilold_solana_core::idl::parse_idl; +use ilold_solana_core::model::ProgramDef; +use ilold_solana_core::overlay::RuntimeOverlay; +use solana_keypair::Keypair; +use solana_signer::Signer; + +const LEVER_JSON: &str = include_str!("fixtures/lever.json"); + +fn lever_so_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/programs/lever.so") +} + +fn read_lever_so() -> Vec<u8> { + std::fs::read(lever_so_path()).expect( + "lever.so missing — run `cd tests/fixtures/solana/cpi && anchor build` and copy \ + target/deploy/lever.so to crates/ilold-solana-core/tests/programs/lever.so", + ) +} + +#[test] +fn overlay_failed_per_ix_increments_on_real_callfailed_flow() { + let idl = parse_idl(LEVER_JSON).expect("parse lever idl"); + let program = ProgramDef::from_idl(idl).expect("build ProgramDef"); + let so_bytes = read_lever_so(); + let mut vm = VmHost::boot(vec![(program.program_id, so_bytes)]).expect("boot vm"); + + let admin = Keypair::new(); + vm.svm_mut() + .airdrop(&admin.pubkey(), 5_000_000_000) + .expect("airdrop admin"); + + let mut users: HashMap<String, Keypair> = HashMap::new(); + users.insert("admin".into(), admin.insecure_clone()); + + let mut session = ExplorationSession::new("lever", "ilold"); + + // switch_power against an uninitialized `power` account — Anchor rejects + // because the account holds zero bytes (no PowerStatus discriminator), so + // add_solana_step returns step_index: None and execute_call must surface + // CallFailed. Real flow exercising the CallFailed branch end-to-end. + let stray = Keypair::new(); + let mut accounts_in: HashMap<String, String> = HashMap::new(); + accounts_in.insert("power".into(), stray.pubkey().to_string()); + + let result = execute_call( + &program, + "switch_power", + serde_json::json!({"name": "claude"}), + accounts_in, + vec![], + &users, + &mut session, + &mut vm, + "2026-05-09T00:00:00Z", + ); + + assert!( + matches!(result, SolanaCommandResult::CallFailed { .. }), + "expected CallFailed, got {result:?}" + ); + assert!( + session.steps.is_empty(), + "failed Call must not push a session step" + ); + assert_eq!( + session.failed_calls_per_ix.get("switch_power").copied(), + Some(1), + "failed_calls_per_ix must be incremented from execute_call CallFailed branch" + ); + + let overlay = RuntimeOverlay::from_session(&session); + assert_eq!( + overlay.failed_per_ix.get("switch_power").copied(), + Some(1), + "RuntimeOverlay::from_session must surface failed_calls_per_ix" + ); + assert!( + overlay.calls_per_ix.is_empty(), + "no successful Call happened, calls_per_ix should stay empty" + ); +} diff --git a/crates/ilold-web/frontend/package.json b/crates/ilold-web/frontend/package.json index 1851d7c..dffa32f 100644 --- a/crates/ilold-web/frontend/package.json +++ b/crates/ilold-web/frontend/package.json @@ -10,7 +10,8 @@ "prepare": "svelte-kit sync || echo ''", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", - "check:program-view": "node scripts/check-program-view.mjs" + "check:program-view": "node scripts/check-program-view.mjs && node scripts/check-runtime-overlay.mjs", + "check:overlay": "node scripts/check-runtime-overlay.mjs" }, "devDependencies": { "@sveltejs/adapter-auto": "^7.0.0", diff --git a/crates/ilold-web/frontend/scripts/check-runtime-overlay.mjs b/crates/ilold-web/frontend/scripts/check-runtime-overlay.mjs new file mode 100644 index 0000000..abb0a43 --- /dev/null +++ b/crates/ilold-web/frontend/scripts/check-runtime-overlay.mjs @@ -0,0 +1,52 @@ +#!/usr/bin/env node +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import assert from 'node:assert/strict'; + +const here = dirname(fileURLToPath(import.meta.url)); +const snapshotPath = resolve( + here, + '../../../ilold-solana-core/tests/snapshots/staking_overlay.json', +); + +const raw = readFileSync(snapshotPath, 'utf8'); +const overlay = JSON.parse(raw); + +assert.equal(typeof overlay.program, 'string', 'program is string'); +assert.equal(typeof overlay.scenario, 'string', 'scenario is string'); + +assert.equal(typeof overlay.calls_per_ix, 'object', 'calls_per_ix is object'); +assert.ok(!Array.isArray(overlay.calls_per_ix), 'calls_per_ix is map, not array'); +for (const [k, v] of Object.entries(overlay.calls_per_ix)) { + assert.equal(typeof k, 'string', `calls_per_ix key is string`); + assert.equal(typeof v, 'number', `calls_per_ix["${k}"] is number`); +} + +assert.equal(typeof overlay.failed_per_ix, 'object', 'failed_per_ix is object'); +assert.ok(!Array.isArray(overlay.failed_per_ix), 'failed_per_ix is map, not array'); +for (const [k, v] of Object.entries(overlay.failed_per_ix)) { + assert.equal(typeof k, 'string', `failed_per_ix key is string`); + assert.equal(typeof v, 'number', `failed_per_ix["${k}"] is number`); +} + +assert.equal(typeof overlay.cu_stats_per_ix, 'object', 'cu_stats_per_ix is object'); +assert.ok(!Array.isArray(overlay.cu_stats_per_ix), 'cu_stats_per_ix is map, not array'); +for (const [k, stats] of Object.entries(overlay.cu_stats_per_ix)) { + assert.equal(typeof stats.min, 'number', `${k}.min is number`); + assert.equal(typeof stats.max, 'number', `${k}.max is number`); + assert.equal(typeof stats.avg, 'number', `${k}.avg is number`); + assert.equal(typeof stats.samples, 'number', `${k}.samples is number`); +} + +assert.ok(Array.isArray(overlay.cpi_edges), 'cpi_edges is array'); +overlay.cpi_edges.forEach((edge, i) => { + assert.equal(typeof edge.from_ix, 'string', `cpi_edges[${i}].from_ix is string`); + assert.equal(typeof edge.to_program, 'string', `cpi_edges[${i}].to_program is string`); + assert.equal(typeof edge.depth, 'number', `cpi_edges[${i}].depth is number`); + assert.equal(typeof edge.samples, 'number', `cpi_edges[${i}].samples is number`); +}); + +console.log( + `ok: RuntimeOverlay snapshot matches TS contract (program=${overlay.program}, scenario=${overlay.scenario}, ${overlay.cpi_edges.length} cpi edges)`, +); diff --git a/crates/ilold-web/frontend/src/lib/api/rest.ts b/crates/ilold-web/frontend/src/lib/api/rest.ts index 1fdda02..e46f718 100644 --- a/crates/ilold-web/frontend/src/lib/api/rest.ts +++ b/crates/ilold-web/frontend/src/lib/api/rest.ts @@ -161,6 +161,29 @@ export interface ProgramView { system_accounts?: string[]; } +export interface CuStats { + min: number; + max: number; + avg: number; + samples: number; +} + +export interface CpiEdge { + from_ix: string; + to_program: string; + depth: number; + samples: number; +} + +export interface RuntimeOverlay { + program: string; + scenario: string; + calls_per_ix: Record<string, number>; + failed_per_ix: Record<string, number>; + cu_stats_per_ix: Record<string, CuStats>; + cpi_edges: CpiEdge[]; +} + export interface MapContract { name: string; kind: string; @@ -211,6 +234,18 @@ export async function getProgramView(name: string): Promise<ProgramView> { return res.json(); } +export async function getProgramOverlay( + name: string, + scenario?: string, +): Promise<RuntimeOverlay> { + const qs = scenario ? `?scenario=${encodeURIComponent(scenario)}` : ''; + const res = await fetch( + `${BASE}/api/program/${encodeURIComponent(name)}/overlay${qs}`, + ); + if (!res.ok) throw new Error(`Overlay for ${name} not found`); + return res.json(); +} + export async function getCallGraph(contractName: string): Promise<CytoscapeGraph> { const res = await fetch(`${BASE}/api/contract/${contractName}/callgraph`); return res.json(); diff --git a/crates/ilold-web/frontend/src/lib/api/types.ts b/crates/ilold-web/frontend/src/lib/api/types.ts index 9608064..d6da823 100644 --- a/crates/ilold-web/frontend/src/lib/api/types.ts +++ b/crates/ilold-web/frontend/src/lib/api/types.ts @@ -146,6 +146,7 @@ export type ServerMessage = | ScenarioForked | ScenarioStoreReloaded | SolanaUsersChanged + | SessionOverlayUpdate | SearchResult | SearchComplete | SearchError; @@ -155,6 +156,19 @@ export interface SolanaUsersChanged { scenario: string; } +/** Incremental runtime-overlay delta. Backend emits this right after + * each StepAdded / CallFailed so the badges (called Nx, ~CU avg, + * rejected Nx) stay live without re-fetching /overlay. */ +export interface SessionOverlayUpdate { + type: 'session_overlay_update'; + scenario: string; + ix_name: string; + calls_added: number; + failed_added: number; + cu?: number; + cpi_targets_added: string[]; +} + // ── Connection events (synthetic, frontend-only) ──────────────────────────── export interface ConnectionEvent { @@ -174,6 +188,7 @@ export interface TopicMap { session_remove_node: SessionRemoveNode; session_clear: SessionClear; session_highlight: SessionHighlight; + session_overlay_update: SessionOverlayUpdate; scenario_created: ScenarioCreated; scenario_switched: ScenarioSwitched; scenario_deleted: ScenarioDeleted; diff --git a/crates/ilold-web/frontend/src/lib/api/ws.ts b/crates/ilold-web/frontend/src/lib/api/ws.ts index 2077b77..28f73d4 100644 --- a/crates/ilold-web/frontend/src/lib/api/ws.ts +++ b/crates/ilold-web/frontend/src/lib/api/ws.ts @@ -32,6 +32,7 @@ const knownTopics: ReadonlySet<string> = new Set([ 'session_remove_node', 'session_clear', 'session_highlight', + 'session_overlay_update', 'scenario_created', 'scenario_switched', 'scenario_deleted', diff --git a/crates/ilold-web/frontend/src/lib/components/contract/nodes/InstructionNode.svelte b/crates/ilold-web/frontend/src/lib/components/contract/nodes/InstructionNode.svelte index edba024..f8112a1 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/nodes/InstructionNode.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/nodes/InstructionNode.svelte @@ -1,6 +1,11 @@ <script lang="ts"> import { Handle, Position } from '@xyflow/svelte'; import type { InstructionNodeData } from '$lib/stores/graph.svelte'; + import { + getCallsPerIx, + getCuStatsPerIx, + getFailedPerIx, + } from '$lib/stores/runtimeOverlay.svelte'; let { data }: { data: InstructionNodeData } = $props(); @@ -22,6 +27,17 @@ ? `admin-gated (heuristic)${discriminatorTooltip ? ' · ' + discriminatorTooltip : ''}` : discriminatorTooltip, ); + + let callsCount = $derived(getCallsPerIx()[data.label] ?? 0); + let failedCount = $derived(getFailedPerIx()[data.label] ?? 0); + let cuStats = $derived(getCuStatsPerIx()[data.label]); + let cuLabel = $derived(cuStats ? formatCu(cuStats.avg) : ''); + + function formatCu(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1).replace(/\.0$/, '')}k`; + return `${n}`; + } </script> <div @@ -65,6 +81,21 @@ {#if data.adminGated} <span class="badge admin">admin</span> {/if} + {#if callsCount > 0} + <span class="badge runtime-calls" title={`called ${callsCount} time${callsCount > 1 ? 's' : ''}`}> + called {callsCount}x + </span> + {/if} + {#if cuLabel} + <span class="badge runtime-cu" title={cuStats ? `min ${cuStats.min} · avg ${cuStats.avg} · max ${cuStats.max} (${cuStats.samples} samples)` : ''}> + ~{cuLabel} CU + </span> + {/if} + {#if failedCount > 0} + <span class="badge runtime-failed" title={`rejected ${failedCount} time${failedCount > 1 ? 's' : ''}`}> + rejected {failedCount}x + </span> + {/if} </div> </div> <Handle type="target" id="t" position={Position.Top} /> @@ -160,4 +191,16 @@ background: color-mix(in srgb, var(--color-danger) 22%, transparent); color: var(--color-danger); } + .badge.runtime-calls { + background: color-mix(in srgb, var(--color-success, #4ade80) 22%, transparent); + color: var(--color-success, #4ade80); + } + .badge.runtime-cu { + background: color-mix(in srgb, var(--color-info, #60a5fa) 18%, transparent); + color: var(--color-info, #60a5fa); + } + .badge.runtime-failed { + background: color-mix(in srgb, var(--color-danger) 28%, transparent); + color: var(--color-danger); + } </style> diff --git a/crates/ilold-web/frontend/src/lib/stores/runtimeOverlay.svelte.ts b/crates/ilold-web/frontend/src/lib/stores/runtimeOverlay.svelte.ts new file mode 100644 index 0000000..d6a2a19 --- /dev/null +++ b/crates/ilold-web/frontend/src/lib/stores/runtimeOverlay.svelte.ts @@ -0,0 +1,129 @@ +import { getProgramOverlay, type CpiEdge, type CuStats, type RuntimeOverlay } from '$lib/api/rest'; +import type { SessionOverlayUpdate } from '$lib/api/types'; + +let program = $state<string>(''); +let scenario = $state<string>(''); +let callsPerIx = $state<Record<string, number>>({}); +let failedPerIx = $state<Record<string, number>>({}); +let cuStatsPerIx = $state<Record<string, CuStats>>({}); +let cpiEdges = $state<CpiEdge[]>([]); + +export function getCallsPerIx(): Record<string, number> { + return callsPerIx; +} + +export function getFailedPerIx(): Record<string, number> { + return failedPerIx; +} + +export function getCuStatsPerIx(): Record<string, CuStats> { + return cuStatsPerIx; +} + +export function getCpiEdges(): CpiEdge[] { + return cpiEdges; +} + +export function getOverlayProgram(): string { + return program; +} + +export function getOverlayScenario(): string { + return scenario; +} + +export function clearOverlay(): void { + program = ''; + scenario = ''; + callsPerIx = {}; + failedPerIx = {}; + cuStatsPerIx = {}; + cpiEdges = []; +} + +function applySnapshot(overlay: RuntimeOverlay): void { + program = overlay.program; + scenario = overlay.scenario; + callsPerIx = { ...overlay.calls_per_ix }; + failedPerIx = { ...overlay.failed_per_ix }; + cuStatsPerIx = { ...overlay.cu_stats_per_ix }; + cpiEdges = overlay.cpi_edges.map((e) => ({ ...e })); +} + +export async function loadInitialOverlay(name: string, scenarioName?: string): Promise<void> { + try { + const overlay = await getProgramOverlay(name, scenarioName); + applySnapshot(overlay); + } catch (err) { + console.warn('runtimeOverlay loadInitial failed:', err); + clearOverlay(); + program = name; + if (scenarioName) scenario = scenarioName; + } +} + +function recomputeStats(prev: CuStats | undefined, sample: number): CuStats { + if (!prev) { + return { min: sample, max: sample, avg: sample, samples: 1 }; + } + const samples = prev.samples + 1; + const sum = prev.avg * prev.samples + sample; + return { + min: Math.min(prev.min, sample), + max: Math.max(prev.max, sample), + avg: Math.round(sum / samples), + samples, + }; +} + +export function applyOverlayUpdate(patch: SessionOverlayUpdate): void { + if (scenario && patch.scenario !== scenario) return; + + const ix = patch.ix_name; + + if (patch.calls_added > 0) { + callsPerIx = { + ...callsPerIx, + [ix]: (callsPerIx[ix] ?? 0) + patch.calls_added, + }; + } + + if (patch.failed_added > 0) { + failedPerIx = { + ...failedPerIx, + [ix]: (failedPerIx[ix] ?? 0) + patch.failed_added, + }; + } + + if (typeof patch.cu === 'number' && patch.calls_added > 0) { + cuStatsPerIx = { + ...cuStatsPerIx, + [ix]: recomputeStats(cuStatsPerIx[ix], patch.cu), + }; + } + + if (patch.cpi_targets_added.length > 0) { + const map = new Map<string, CpiEdge>(); + for (const e of cpiEdges) { + map.set(`${e.from_ix}|${e.to_program}|${e.depth}`, { ...e }); + } + for (const to of patch.cpi_targets_added) { + // Depth = 1 by default for incremental adds; the REST snapshot preserves + // the precise depth captured in the trace. Avoiding depth here keeps the + // wire-format minimal — accurate aggregation lands on the next reload. + const key = `${ix}|${to}|1`; + const prev = map.get(key); + if (prev) { + prev.samples += 1; + map.set(key, prev); + } else { + map.set(key, { from_ix: ix, to_program: to, depth: 1, samples: 1 }); + } + } + cpiEdges = Array.from(map.values()).sort((a, b) => { + if (a.from_ix !== b.from_ix) return a.from_ix.localeCompare(b.from_ix); + if (a.to_program !== b.to_program) return a.to_program.localeCompare(b.to_program); + return a.depth - b.depth; + }); + } +} diff --git a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte index c2185a2..9783efb 100644 --- a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte +++ b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte @@ -2,6 +2,11 @@ import { page } from '$app/state'; import { onMount, onDestroy, tick, untrack } from 'svelte'; import { getContract, getCallGraph, getCfg, getPaths, getSequences, getSequenceAnalysis, getFunctionSource, getProjectMap, getProgramView, type ContractDetail, type CytoscapeGraph, type SequenceAnalysis, type MapContract, type MapProgram, type ProgramView, type IxView, type AccountView, type IxAccountView } from '$lib/api/rest'; + import { + applyOverlayUpdate as applyRuntimeOverlayUpdate, + clearOverlay as clearRuntimeOverlay, + loadInitialOverlay as loadRuntimeOverlay, + } from '$lib/stores/runtimeOverlay.svelte'; import { goto } from '$app/navigation'; import { toggleTerminal } from '$lib/stores/terminal.svelte'; import { openInIde } from '$lib/utils/ide-links'; @@ -845,7 +850,15 @@ }); solanaRuntimeByStep = next; }); - return () => { unsub(); unsubAdd(); }; + const unsubOverlay = subscribeWs('session_overlay_update', (msg) => { + applyRuntimeOverlayUpdate(msg); + }); + const unsubScenarioSwitch = subscribeWs('scenario_switched', (msg) => { + if (solanaProgram) { + loadRuntimeOverlay(solanaProgram.name, msg.to); + } + }); + return () => { unsub(); unsubAdd(); unsubOverlay(); unsubScenarioSwitch(); }; }); async function refreshSolanaUsers() { @@ -915,7 +928,10 @@ // data. mountToken is captured before the first await; if the user // navigates we bump cancel via onDestroy and the guard short-circuits. let mountCancelled = $state(false); - onDestroy(() => { mountCancelled = true; }); + onDestroy(() => { + mountCancelled = true; + clearRuntimeOverlay(); + }); onMount(async () => { const contractName = page.params.name; @@ -931,6 +947,7 @@ seqExpanded = new Map(); selectedNode = null; selectedPath = null; + clearRuntimeOverlay(); setSearchContext(contractName); try { const pm = await getProjectMap(); @@ -947,6 +964,8 @@ } projectMap = []; await refreshSolanaUsers(); + const scenario = getActiveScenario(); + await loadRuntimeOverlay(contractName, scenario); return; } projectMap = pm.contracts ?? []; diff --git a/crates/ilold-web/src/api/session.rs b/crates/ilold-web/src/api/session.rs index 94ecfc1..149c3df 100644 --- a/crates/ilold-web/src/api/session.rs +++ b/crates/ilold-web/src/api/session.rs @@ -20,7 +20,7 @@ use ilold_core::narrative::types::{FunctionNarrative, SequenceNarrative}; use ilold_core::slicing::{build_slice_result, SliceDirection, SliceResult}; use ilold_session_core::exploration::scenario::ScenarioAction as SharedScenarioAction; use ilold_solana_core::exploration::{ - canvas_patch_from_solana, execute_airdrop, execute_back, execute_call, execute_clear, + canvas_patches_from_solana, execute_airdrop, execute_back, execute_call, execute_clear, execute_coupling, execute_coverage, execute_export, execute_findings_list, execute_info, execute_step, execute_timeline, execute_vars, execute_who, execute_finding, execute_funcs, execute_inspect, execute_note, execute_pda, execute_session, @@ -182,6 +182,9 @@ fn fork_scenario( } let from = store.active().to_string(); let mut cloned = store.active_session().clone(); + // Failed Calls are scenario-local observations; the fresh fork has not + // rejected anything yet so the runtime overlay starts clean. + cloned.failed_calls_per_ix.clear(); // Resolve effective step count. None (legacy) → keep all steps. // Some(N) → truncate to first N; error if N > current length. @@ -370,7 +373,7 @@ async fn handle_solana_command( ×tamp, &program.name, ); - if let Some(patch) = canvas_patch_from_solana(&result, &active_before) { + for patch in canvas_patches_from_solana(&result, &active_before, &[]) { state.session_tx.send(patch).ok(); } return Ok(Json(serde_json::to_value(result).unwrap_or(Value::Null))); @@ -552,7 +555,7 @@ async fn handle_solana_command( Err(message) => SolanaCommandResult::Error { message }, }; let active_after = scenarios.active().to_string(); - if let Some(patch) = canvas_patch_from_solana(&result, &active_after) { + for patch in canvas_patches_from_solana(&result, &active_after, &[]) { state.session_tx.send(patch).ok(); } return Ok(Json(serde_json::to_value(result).unwrap_or(Value::Null))); @@ -685,7 +688,30 @@ async fn handle_solana_command( stack.push(snap); } - if let Some(patch) = canvas_patch_from_solana(&result, &active_scenario) { + let cpi_targets = match &result { + SolanaCommandResult::StepAdded { .. } => scenarios + .active_session() + .steps + .last() + .and_then(|s| s.runtime_trace.as_ref()) + .and_then(|v| { + v.get("inner_instructions") + .and_then(|ii| ii.as_array()) + .map(|arr| { + let mut seen = std::collections::BTreeSet::new(); + for entry in arr { + if let Some(p) = entry.get("program").and_then(|p| p.as_str()) { + seen.insert(p.to_string()); + } + } + seen.into_iter().collect::<Vec<String>>() + }) + }) + .unwrap_or_default(), + _ => Vec::new(), + }; + + for patch in canvas_patches_from_solana(&result, &active_scenario, &cpi_targets) { state.session_tx.send(patch).ok(); } Ok(Json(serde_json::to_value(result).unwrap_or(Value::Null))) @@ -769,6 +795,9 @@ fn solana_scenario_action( } let from = active_before.to_string(); let mut cloned = scenarios.active_session().clone(); + // Failed Calls are scenario-local observations: a fork hasn't + // rejected anything yet, so the runtime overlay starts clean. + cloned.failed_calls_per_ix.clear(); let len = cloned.steps.len(); let effective = match at_step { None => len, diff --git a/crates/ilold-web/src/ws/handler.rs b/crates/ilold-web/src/ws/handler.rs index f842837..8615340 100644 --- a/crates/ilold-web/src/ws/handler.rs +++ b/crates/ilold-web/src/ws/handler.rs @@ -55,6 +55,16 @@ enum ServerMessage { ScenarioStoreReloaded { active: String }, #[serde(rename = "solana_users_changed")] SolanaUsersChanged { scenario: String }, + #[serde(rename = "session_overlay_update")] + SessionOverlayUpdate { + scenario: String, + ix_name: String, + calls_added: u32, + failed_added: u32, + #[serde(skip_serializing_if = "Option::is_none")] + cu: Option<u64>, + cpi_targets_added: Vec<String>, + }, } pub async fn ws_handler( @@ -88,6 +98,21 @@ fn server_message_from_patch(patch: CanvasPatch) -> ServerMessage { CanvasPatch::SolanaUsersChanged { scenario } => { ServerMessage::SolanaUsersChanged { scenario } } + CanvasPatch::OverlayUpdate { + scenario, + ix_name, + calls_added, + failed_added, + cu, + cpi_targets_added, + } => ServerMessage::SessionOverlayUpdate { + scenario, + ix_name, + calls_added, + failed_added, + cu, + cpi_targets_added, + }, } } diff --git a/tests/scenarios/14-coverage-overlay.sh b/tests/scenarios/14-coverage-overlay.sh index f47636b..85b89e7 100755 --- a/tests/scenarios/14-coverage-overlay.sh +++ b/tests/scenarios/14-coverage-overlay.sh @@ -18,6 +18,14 @@ stake_as alice alice_stake 1000 >/dev/null stake_as alice alice_stake 500 >/dev/null stake_as bob bob_stake 2000 >/dev/null +# Trigger a real CallFailed via Anchor's init re-use guard. Pool was +# already initialized at the top, so a second initialize_pool runs through +# add_solana_step and gets rejected by the VM. failed_calls_per_ix must +# tick — that is exactly the persistence path T-R52b restored. +fail_resp=$(init_pool) +fail_kind=$(echo "$fail_resp" | jq -r 'keys[0] // empty') +expect_eq "re-initialize_pool rejected as CallFailed" "CallFailed" "$fail_kind" + # Coverage via /api/cmd cov_response=$(post '{"contract":"staking","command":"Coverage"}') overlay_json=$(echo "$cov_response" | jq '.Coverage.overlay') @@ -38,6 +46,9 @@ expect_eq "initialize_pool called once" "1" "$init_calls" cu_samples=$(echo "$overlay_json" | jq -r '.cu_stats_per_ix.stake.samples // 0') expect_eq "cu_stats.stake.samples == 3" "3" "$cu_samples" +failed_init=$(echo "$overlay_json" | jq -r '.failed_per_ix.initialize_pool // 0') +expect_eq "failed_per_ix.initialize_pool == 1" "1" "$failed_init" + # Anchor `init` (used by initialize_pool / stake's user_stake init) CPIs to # system_program. The overlay must surface those edges. sys_edges=$(echo "$overlay_json" \ diff --git a/tests/scenarios/ws-broadcast.py b/tests/scenarios/ws-broadcast.py index ec2d26a..f2b2a09 100644 --- a/tests/scenarios/ws-broadcast.py +++ b/tests/scenarios/ws-broadcast.py @@ -81,6 +81,7 @@ async def main(): expected_topics = { "solana_users_changed": 4, # 4 UsersNew "session_add_node": 2, # 2 Calls + "session_overlay_update": 2, # one per Call (StepAdded path) "scenario_forked": 1, "scenario_switched": 2, # main→branch, branch→main "session_remove_node": 1, # Back From 3e4562e81e5f7c2ed5a6a458309dbb28e890f051 Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Sun, 10 May 2026 23:36:25 +0200 Subject: [PATCH 100/115] feat(canvas): runtime cpi edges + authority labels + cleanup - Paint dashed cpi edges from instruction nodes to target programs - Persist edges across session, refresh on scenario switch - Authority resolution: GET /api/users/:scenario/labels resolves pubkey to user name - Add userLabels store with labelForPubkey helper for future UI surfaces - Refactor: extract_cpi_programs and reset_scenario_local_observations helpers - Store guard: initialized flag prevents pre-snapshot patches - Sync spec.md OverlayUpdate shape to delta-incremental --- .../src/exploration/session.rs | 29 ++++ crates/ilold-solana-core/src/overlay.rs | 41 ++++++ crates/ilold-web/frontend/src/lib/api/rest.ts | 12 ++ .../src/lib/stores/runtimeOverlay.svelte.ts | 10 ++ .../src/lib/stores/userLabels.svelte.ts | 33 +++++ .../src/routes/contract/[name]/+page.svelte | 134 +++++++++++++++++- crates/ilold-web/src/api/project.rs | 25 ++++ crates/ilold-web/src/api/session.rs | 19 +-- crates/ilold-web/src/lib.rs | 1 + .../tests/solana_command_pipeline.rs | 42 ++++++ 10 files changed, 329 insertions(+), 17 deletions(-) create mode 100644 crates/ilold-web/frontend/src/lib/stores/userLabels.svelte.ts diff --git a/crates/ilold-session-core/src/exploration/session.rs b/crates/ilold-session-core/src/exploration/session.rs index adca080..992a423 100644 --- a/crates/ilold-session-core/src/exploration/session.rs +++ b/crates/ilold-session-core/src/exploration/session.rs @@ -114,6 +114,14 @@ impl ExplorationSession { .or_insert(0) += 1; } + /// Reset scenario-local observations that must not survive a fork. + /// Currently only `failed_calls_per_ix`, but kept as a single helper so + /// future scenario-local counters land in one place instead of fanning + /// out across every fork site. + pub fn reset_scenario_local_observations(&mut self) { + self.failed_calls_per_ix.clear(); + } + pub fn current_sequence(&self) -> Vec<&str> { self.steps.iter().map(|s| s.function.as_str()).collect() } @@ -181,6 +189,27 @@ mod tests { assert!(!s.remove_last_step()); } + #[test] + fn reset_scenario_local_observations_clears_failed_calls() { + let mut s = ExplorationSession::new("Staking", "myproject"); + s.record_failed_call("stake"); + s.record_failed_call("stake"); + s.record_failed_call("unstake"); + s.steps.push(ExplorationStep { + function: "deposit".into(), + mutations: vec![], + flow_tree: None, + trace_config: TraceConfig::default(), + runtime_trace: None, + call_payload: None, + }); + assert_eq!(s.failed_calls_per_ix.get("stake").copied(), Some(2)); + s.reset_scenario_local_observations(); + assert!(s.failed_calls_per_ix.is_empty()); + // Steps stay; the reset is scoped to scenario-local observations. + assert_eq!(s.steps.len(), 1); + } + #[test] fn clear_resets() { let mut s = ExplorationSession::new("Staking", "myproject"); diff --git a/crates/ilold-solana-core/src/overlay.rs b/crates/ilold-solana-core/src/overlay.rs index 11e77e7..a919abd 100644 --- a/crates/ilold-solana-core/src/overlay.rs +++ b/crates/ilold-solana-core/src/overlay.rs @@ -4,6 +4,22 @@ use ilold_session_core::exploration::session::ExplorationSession; use ilold_session_core::runtime_trace::RuntimeTrace; use serde::{Deserialize, Serialize}; +/// Extract the deduplicated, insertion-ordered list of CPI program IDs +/// invoked by a RuntimeTrace. Lifted out so both the overlay aggregator +/// and the WS broadcast site share one decoder for `inner_instructions`. +/// Order mirrors `inner_instructions` (first hit wins) so the resulting +/// list reflects the CPI call sequence the program actually emitted. +pub fn extract_cpi_programs(trace: &RuntimeTrace) -> Vec<String> { + let mut seen = std::collections::HashSet::new(); + let mut out = Vec::new(); + for ii in &trace.inner_instructions { + if seen.insert(ii.program.clone()) { + out.push(ii.program.clone()); + } + } + out +} + #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] pub struct RuntimeOverlay { pub program: String, @@ -190,6 +206,31 @@ mod tests { assert_eq!(overlay.calls_per_ix.get("unstake"), None); } + #[test] + fn extract_cpi_programs_dedups_in_insertion_order() { + let trace = RuntimeTrace { + logs: vec![], + compute_units: 0, + inner_instructions: vec![ + InnerInstruction { program: "B".into(), instruction: "x".into(), depth: 1 }, + InnerInstruction { program: "A".into(), instruction: "x".into(), depth: 1 }, + InnerInstruction { program: "B".into(), instruction: "y".into(), depth: 2 }, + InnerInstruction { program: "A".into(), instruction: "z".into(), depth: 1 }, + ], + account_diffs: vec![], + return_data: None, + error: None, + }; + let programs = extract_cpi_programs(&trace); + assert_eq!(programs, vec!["B".to_string(), "A".to_string()]); + } + + #[test] + fn extract_cpi_programs_empty_trace_is_empty() { + let trace = ok_trace(1_000); + assert!(extract_cpi_programs(&trace).is_empty()); + } + #[test] fn from_session_collects_cpi_edges() { let mut session = empty_session(); diff --git a/crates/ilold-web/frontend/src/lib/api/rest.ts b/crates/ilold-web/frontend/src/lib/api/rest.ts index e46f718..191040e 100644 --- a/crates/ilold-web/frontend/src/lib/api/rest.ts +++ b/crates/ilold-web/frontend/src/lib/api/rest.ts @@ -246,6 +246,18 @@ export async function getProgramOverlay( return res.json(); } +/** Per-scenario pubkey -> user-name map (e.g. "Bxk7..." -> "alice"). Used by + * the canvas to render authority labels instead of raw base58 pubkeys. */ +export async function getUserLabels( + scenario: string, +): Promise<Record<string, string>> { + const res = await fetch( + `${BASE}/api/users/${encodeURIComponent(scenario)}/labels`, + ); + if (!res.ok) throw new Error(`User labels for ${scenario} not found`); + return res.json(); +} + export async function getCallGraph(contractName: string): Promise<CytoscapeGraph> { const res = await fetch(`${BASE}/api/contract/${contractName}/callgraph`); return res.json(); diff --git a/crates/ilold-web/frontend/src/lib/stores/runtimeOverlay.svelte.ts b/crates/ilold-web/frontend/src/lib/stores/runtimeOverlay.svelte.ts index d6a2a19..e775c5c 100644 --- a/crates/ilold-web/frontend/src/lib/stores/runtimeOverlay.svelte.ts +++ b/crates/ilold-web/frontend/src/lib/stores/runtimeOverlay.svelte.ts @@ -7,6 +7,9 @@ let callsPerIx = $state<Record<string, number>>({}); let failedPerIx = $state<Record<string, number>>({}); let cuStatsPerIx = $state<Record<string, CuStats>>({}); let cpiEdges = $state<CpiEdge[]>([]); +// Tracks whether the initial REST snapshot has landed; protects against WS +// patches arriving before scenario context is known and corrupting state. +let initialized = $state<boolean>(false); export function getCallsPerIx(): Record<string, number> { return callsPerIx; @@ -39,6 +42,7 @@ export function clearOverlay(): void { failedPerIx = {}; cuStatsPerIx = {}; cpiEdges = []; + initialized = false; } function applySnapshot(overlay: RuntimeOverlay): void { @@ -48,6 +52,7 @@ function applySnapshot(overlay: RuntimeOverlay): void { failedPerIx = { ...overlay.failed_per_ix }; cuStatsPerIx = { ...overlay.cu_stats_per_ix }; cpiEdges = overlay.cpi_edges.map((e) => ({ ...e })); + initialized = true; } export async function loadInitialOverlay(name: string, scenarioName?: string): Promise<void> { @@ -59,6 +64,7 @@ export async function loadInitialOverlay(name: string, scenarioName?: string): P clearOverlay(); program = name; if (scenarioName) scenario = scenarioName; + initialized = true; } } @@ -77,6 +83,10 @@ function recomputeStats(prev: CuStats | undefined, sample: number): CuStats { } export function applyOverlayUpdate(patch: SessionOverlayUpdate): void { + // Drop patches that arrive before the initial REST snapshot. Without this + // guard, a WS event landing during page load would seed the store under + // scenario === '' and the next snapshot would silently overwrite it. + if (!initialized) return; if (scenario && patch.scenario !== scenario) return; const ix = patch.ix_name; diff --git a/crates/ilold-web/frontend/src/lib/stores/userLabels.svelte.ts b/crates/ilold-web/frontend/src/lib/stores/userLabels.svelte.ts new file mode 100644 index 0000000..6e3d458 --- /dev/null +++ b/crates/ilold-web/frontend/src/lib/stores/userLabels.svelte.ts @@ -0,0 +1,33 @@ +import { getUserLabels as fetchUserLabels } from '$lib/api/rest'; + +let scenario = $state<string>(''); +let labels = $state<Record<string, string>>({}); + +export function getUserLabelsScenario(): string { + return scenario; +} + +export function getUserLabelsMap(): Record<string, string> { + return labels; +} + +export function labelForPubkey(pubkey: string): string | null { + return labels[pubkey] ?? null; +} + +export function clearUserLabels(): void { + scenario = ''; + labels = {}; +} + +export async function loadUserLabels(scenarioName: string): Promise<void> { + try { + const map = await fetchUserLabels(scenarioName); + scenario = scenarioName; + labels = { ...map }; + } catch (err) { + console.warn('userLabels loadUserLabels failed:', err); + clearUserLabels(); + scenario = scenarioName; + } +} diff --git a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte index 9783efb..1770f1e 100644 --- a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte +++ b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte @@ -6,7 +6,12 @@ applyOverlayUpdate as applyRuntimeOverlayUpdate, clearOverlay as clearRuntimeOverlay, loadInitialOverlay as loadRuntimeOverlay, + getCpiEdges, } from '$lib/stores/runtimeOverlay.svelte'; + import { + loadUserLabels, + clearUserLabels, + } from '$lib/stores/userLabels.svelte'; import { goto } from '$app/navigation'; import { toggleTerminal } from '$lib/stores/terminal.svelte'; import { openInIde } from '$lib/utils/ide-links'; @@ -612,6 +617,9 @@ }, }); solanaCanvasIxs = new Set([...solanaCanvasIxs, ixName]); + // Paint CPI edges that the overlay already knows about for this ix — + // happens when the auditor added an ix node after Calls had already run. + paintCpiEdges(); if (flowApi) flowApi.fitView({ nodes: [{ id: `ix:${ixName}` }], padding: 0.5, duration: 400 }); } @@ -625,9 +633,30 @@ if (data?._type === 'account' && data.parentInstruction === ixName) ids.add(n.id); } removeNodesById(ids); + // Drop CPI edges that started at this ix; placeholder externals that lose + // their last incoming cpi edge are pruned by orphanCleanup below. + const edgePrefix = `cpi:${ixName}->`; + const filtered = getEdges().filter((e) => !e.id.startsWith(edgePrefix)); + if (filtered.length !== getEdges().length) setEdges(filtered); + pruneOrphanExternals(); solanaExpandedIxs = new Set([...solanaExpandedIxs].filter((n) => n !== ixName)); } + /** Remove external-program placeholder nodes that have no incoming cpi edge + * left. Keeps the canvas clean after an ix is removed or after the overlay + * drops samples for a target. */ + function pruneOrphanExternals() { + const usedTargets = new Set<string>(); + for (const e of getEdges()) { + if (e.id.startsWith('cpi:')) usedTargets.add(e.target); + } + const orphans = new Set<string>(); + for (const n of getNodes()) { + if (n.id.startsWith('external:') && !usedTargets.has(n.id)) orphans.add(n.id); + } + if (orphans.size > 0) removeNodesById(orphans); + } + function paintSolanaScenarioTree( scenarios: Map<string, any[]>, forkOrigins: Map<string, any>, @@ -780,6 +809,99 @@ if (newEdges.length > 0) addEdges(newEdges); } + /** Friendly labels for well-known Solana programs. Anything not in this map + * falls back to a base58-truncated id rendered by ExternalProgramNode. */ + const KNOWN_PROGRAMS: Record<string, string> = { + '11111111111111111111111111111111': 'system_program', + 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA': 'token_program', + 'TokenzQdBNbLqP5VEhdkAS6EPFLC1PHnBqCXEpPxuEb': 'token_program_2022', + 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL': 'associated_token_program', + 'SysvarRent111111111111111111111111111111111': 'sysvar:rent', + 'SysvarC1ock11111111111111111111111111111111': 'sysvar:clock', + }; + + function externalNodeId(programId: string): string { + return `external:${programId}`; + } + function cpiEdgeId(fromIx: string, programId: string): string { + return `cpi:${fromIx}->${programId}`; + } + + /** Paint dashed `cpi` edges from on-canvas instruction nodes to placeholder + * external-program nodes. Reads from the runtime overlay store, so the + * same handler covers both the initial REST snapshot and incremental WS + * updates. Edges persist until the user clears the canvas or switches + * scenario; cleanup happens in clearGraph / scenario_switched. */ + function paintCpiEdges() { + if (!solanaProgram) return; + const edges = getCpiEdges(); + if (edges.length === 0) return; + + const existing = new Set(getEdges().map((e) => e.id)); + const liveNodes = new Set(getNodes().map((n) => n.id)); + const usedExternals = new Set<string>(); + const newNodes: any[] = []; + const newEdges: any[] = []; + + for (const e of edges) { + const fromId = `ix:${e.from_ix}`; + if (!liveNodes.has(fromId)) continue; + + const extId = externalNodeId(e.to_program); + const label = KNOWN_PROGRAMS[e.to_program] ?? e.to_program; + + if (!liveNodes.has(extId) && !usedExternals.has(extId)) { + const parent = findNode(fromId); + const baseX = (parent?.position?.x ?? 0) + 220; + const baseY = (parent?.position?.y ?? 200) + 80; + newNodes.push({ + id: extId, + type: 'function', + position: { x: baseX, y: baseY }, + data: { + _type: 'function', + label, + is_external: true, + }, + }); + usedExternals.add(extId); + } + + const edgeId = cpiEdgeId(e.from_ix, e.to_program); + if (existing.has(edgeId)) continue; + newEdges.push({ + id: edgeId, + source: fromId, + sourceHandle: 'r', + target: extId, + targetHandle: 'l', + label: `cpi (${e.samples}x)`, + style: 'stroke-dasharray: 5 3; stroke: var(--color-warning);', + markerEnd: { type: MarkerType.ArrowClosed, width: 12, height: 12, color: 'var(--color-warning)' }, + labelBgStyle: { fill: 'var(--color-surface)', fillOpacity: 0.85 }, + labelBgPadding: [3, 5] as [number, number], + labelStyle: 'font-size: 9px; fill: var(--color-warning);', + data: { _type: 'cpi-edge' }, + }); + } + + if (newNodes.length > 0) addNodes(newNodes); + if (newEdges.length > 0) addEdges(newEdges); + } + + /** Drop existing CPI placeholder nodes and edges. Used when switching + * scenarios or reloading the overlay snapshot — the new overlay state + * determines what gets repainted via paintCpiEdges. */ + function clearCpiEdges() { + const ids = new Set<string>(); + for (const n of getNodes()) { + if (n.id.startsWith('external:')) ids.add(n.id); + } + if (ids.size > 0) removeNodesById(ids); + const remainingEdges = getEdges().filter((e) => !e.id.startsWith('cpi:')); + if (remainingEdges.length !== getEdges().length) setEdges(remainingEdges); + } + function clearIxAccounts(ixName: string) { const ids = new Set<string>(); for (const n of getNodes()) { @@ -852,10 +974,14 @@ }); const unsubOverlay = subscribeWs('session_overlay_update', (msg) => { applyRuntimeOverlayUpdate(msg); + paintCpiEdges(); }); - const unsubScenarioSwitch = subscribeWs('scenario_switched', (msg) => { + const unsubScenarioSwitch = subscribeWs('scenario_switched', async (msg) => { if (solanaProgram) { - loadRuntimeOverlay(solanaProgram.name, msg.to); + clearCpiEdges(); + await loadRuntimeOverlay(solanaProgram.name, msg.to); + await loadUserLabels(msg.to); + paintCpiEdges(); } }); return () => { unsub(); unsubAdd(); unsubOverlay(); unsubScenarioSwitch(); }; @@ -931,6 +1057,7 @@ onDestroy(() => { mountCancelled = true; clearRuntimeOverlay(); + clearUserLabels(); }); onMount(async () => { @@ -948,6 +1075,7 @@ selectedNode = null; selectedPath = null; clearRuntimeOverlay(); + clearUserLabels(); setSearchContext(contractName); try { const pm = await getProjectMap(); @@ -966,6 +1094,8 @@ await refreshSolanaUsers(); const scenario = getActiveScenario(); await loadRuntimeOverlay(contractName, scenario); + if (scenario) await loadUserLabels(scenario); + paintCpiEdges(); return; } projectMap = pm.contracts ?? []; diff --git a/crates/ilold-web/src/api/project.rs b/crates/ilold-web/src/api/project.rs index ea14085..91ad8d5 100644 --- a/crates/ilold-web/src/api/project.rs +++ b/crates/ilold-web/src/api/project.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::sync::Arc; use axum::extract::{Path, Query, State}; @@ -7,6 +8,7 @@ use ilold_solana_core::model::ProgramDef; use ilold_solana_core::overlay::RuntimeOverlay; use ilold_solana_core::view::ProgramView; use serde::{Deserialize, Serialize}; +use solana_keypair::Signer; use crate::state::{require_solidity_msg, AppState, Backend}; @@ -139,6 +141,29 @@ pub async fn get_program_view( Ok(Json(program.compute_view())) } +/// Resolve the per-scenario `pubkey -> user name` map. Lets the canvas show +/// "alice" instead of "Bxk7…" when a runtime field matches a known user. +/// Lives in its own endpoint (not on `RuntimeOverlay`) because users mutate +/// independently of the overlay; see design.md §"authority_resolutions". +pub async fn get_user_labels( + State(state): State<Arc<AppState>>, + Path(scenario): Path<String>, +) -> Result<Json<HashMap<String, String>>, (StatusCode, String)> { + let solana = state + .solana() + .ok_or((StatusCode::BAD_REQUEST, "endpoint is Solana-only".into()))?; + let users_lock = solana.users.read().unwrap(); + let scn_users = users_lock.get(&scenario).ok_or(( + StatusCode::NOT_FOUND, + format!("scenario '{scenario}' has no user registry"), + ))?; + let map: HashMap<String, String> = scn_users + .iter() + .map(|(name, kp)| (kp.pubkey().to_string(), name.clone())) + .collect(); + Ok(Json(map)) +} + #[derive(Deserialize, Default)] pub struct OverlayQuery { pub scenario: Option<String>, diff --git a/crates/ilold-web/src/api/session.rs b/crates/ilold-web/src/api/session.rs index 149c3df..367e9b2 100644 --- a/crates/ilold-web/src/api/session.rs +++ b/crates/ilold-web/src/api/session.rs @@ -184,7 +184,7 @@ fn fork_scenario( let mut cloned = store.active_session().clone(); // Failed Calls are scenario-local observations; the fresh fork has not // rejected anything yet so the runtime overlay starts clean. - cloned.failed_calls_per_ix.clear(); + cloned.reset_scenario_local_observations(); // Resolve effective step count. None (legacy) → keep all steps. // Some(N) → truncate to first N; error if N > current length. @@ -694,19 +694,8 @@ async fn handle_solana_command( .steps .last() .and_then(|s| s.runtime_trace.as_ref()) - .and_then(|v| { - v.get("inner_instructions") - .and_then(|ii| ii.as_array()) - .map(|arr| { - let mut seen = std::collections::BTreeSet::new(); - for entry in arr { - if let Some(p) = entry.get("program").and_then(|p| p.as_str()) { - seen.insert(p.to_string()); - } - } - seen.into_iter().collect::<Vec<String>>() - }) - }) + .and_then(|v| serde_json::from_value::<ilold_session_core::runtime_trace::RuntimeTrace>(v.clone()).ok()) + .map(|t| ilold_solana_core::overlay::extract_cpi_programs(&t)) .unwrap_or_default(), _ => Vec::new(), }; @@ -797,7 +786,7 @@ fn solana_scenario_action( let mut cloned = scenarios.active_session().clone(); // Failed Calls are scenario-local observations: a fork hasn't // rejected anything yet, so the runtime overlay starts clean. - cloned.failed_calls_per_ix.clear(); + cloned.reset_scenario_local_observations(); let len = cloned.steps.len(); let effective = match at_step { None => len, diff --git a/crates/ilold-web/src/lib.rs b/crates/ilold-web/src/lib.rs index 1ea39da..3ace9e2 100644 --- a/crates/ilold-web/src/lib.rs +++ b/crates/ilold-web/src/lib.rs @@ -19,6 +19,7 @@ fn build_router(state: Arc<AppState>) -> Router { .route("/api/project/map", get(api::project::get_project_map)) .route("/api/program/{name}/view", get(api::project::get_program_view)) .route("/api/program/{name}/overlay", get(api::project::get_program_overlay)) + .route("/api/users/{scenario}/labels", get(api::project::get_user_labels)) .route("/api/contract/{name}", get(api::contract::get_contract)) .route("/api/contract/{name}/callgraph", get(api::contract::get_callgraph)) .route("/api/contract/{name}/{func}/cfg", get(api::contract::get_cfg)) diff --git a/crates/ilold-web/tests/solana_command_pipeline.rs b/crates/ilold-web/tests/solana_command_pipeline.rs index bd483d9..dd6380b 100644 --- a/crates/ilold-web/tests/solana_command_pipeline.rs +++ b/crates/ilold-web/tests/solana_command_pipeline.rs @@ -706,3 +706,45 @@ async fn program_overlay_endpoint_returns_404_for_missing_program() { .expect("GET /overlay ghost"); assert_eq!(res.status().as_u16(), 404); } + +#[tokio::test] +async fn user_labels_endpoint_returns_pubkey_to_name_map() { + let (client, port) = start_staking().await; + let _ = cmd( + &client, + port, + "staking", + serde_json::json!({"UsersNew": {"name": "alice", "lamports": 1_000_000_000u64}}), + ) + .await; + let listed = cmd(&client, port, "staking", serde_json::json!("Users")).await; + let alice_pk = listed + .get("UserList") + .and_then(|v| v.get("users")) + .and_then(|v| v.as_array()) + .and_then(|arr| arr.first()) + .and_then(|u| u.get("pubkey")) + .and_then(|v| v.as_str()) + .expect("alice pubkey") + .to_string(); + + let res = client + .get(format!("http://127.0.0.1:{port}/api/users/main/labels")) + .send() + .await + .expect("GET /labels"); + assert!(res.status().is_success(), "got {}", res.status()); + let map: serde_json::Value = res.json().await.expect("json"); + assert_eq!(map.get(&alice_pk).and_then(|v| v.as_str()), Some("alice")); +} + +#[tokio::test] +async fn user_labels_endpoint_returns_404_for_missing_scenario() { + let (client, port) = start_staking().await; + let res = client + .get(format!("http://127.0.0.1:{port}/api/users/ghost/labels")) + .send() + .await + .expect("GET /labels ghost"); + assert_eq!(res.status().as_u16(), 404); +} From ce12ffde5c4fd7ac04f0660c52f63ea8dcfce9e7 Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Mon, 11 May 2026 09:29:17 +0200 Subject: [PATCH 101/115] docs(guide): split solidity and solana with Elozer roadmap - Split commands and workflows into solidity/ and solana/ trees - Add concepts overview and architecture pages - Per-backend roadmap with Open to ideas section, Solana leverages Elozer - Solana: 29 commands across 8 groups, audit walkthrough mirror - Solidity: scenarios family page, cli-analyze and cli-context docs - Reference: api-endpoints, websocket events, split limitations - Fix variant names against commands.rs and example numbers - Drop internal task identifiers from public docs - Remove em-dashes from prose, drop BPF wording - mdbook build clean --- docs/guide/src/SUMMARY.md | 51 +++-- docs/guide/src/commands/contract.md | 116 ---------- docs/guide/src/concepts/architecture.md | 64 ++++++ docs/guide/src/concepts/overview.md | 20 ++ docs/guide/src/getting-started.md | 124 ++++++----- docs/guide/src/introduction.md | 48 ++--- docs/guide/src/reference/api-endpoints.md | 26 ++- docs/guide/src/reference/limitations.md | 40 +--- docs/guide/src/reference/websocket.md | 43 ++++ docs/guide/src/roadmap/cross-cutting.md | 14 ++ docs/guide/src/roadmap/solana.md | 46 ++++ docs/guide/src/roadmap/solidity.md | 29 +++ docs/guide/src/solana-support.md | 111 ---------- docs/guide/src/solana-testing.md | 199 ------------------ docs/guide/src/solana/limitations.md | 47 +++++ docs/guide/src/solana/overview.md | 53 +++++ docs/guide/src/solana/repl/analysis.md | 131 ++++++++++++ docs/guide/src/solana/repl/findings.md | 106 ++++++++++ docs/guide/src/solana/repl/help.md | 78 +++++++ docs/guide/src/solana/repl/programs.md | 120 +++++++++++ docs/guide/src/solana/repl/runtime.md | 94 +++++++++ docs/guide/src/solana/repl/scenarios.md | 80 +++++++ docs/guide/src/solana/repl/session.md | 134 ++++++++++++ docs/guide/src/solana/repl/workspace.md | 64 ++++++ .../src/solana/workflows/audit-walkthrough.md | 185 ++++++++++++++++ docs/guide/src/solana/workflows/scenarios.md | 114 ++++++++++ docs/guide/src/solidity/cli-analyze.md | 74 +++++++ docs/guide/src/solidity/cli-context.md | 67 ++++++ docs/guide/src/solidity/limitations.md | 40 ++++ docs/guide/src/solidity/overview.md | 39 ++++ .../{commands => solidity/repl}/analysis.md | 10 +- docs/guide/src/solidity/repl/contract.md | 122 +++++++++++ .../{commands => solidity/repl}/findings.md | 18 +- docs/guide/src/solidity/repl/scenarios.md | 81 +++++++ .../{commands => solidity/repl}/session.md | 18 +- .../{commands => solidity/repl}/workspace.md | 8 +- .../workflows/audit-walkthrough.md | 6 +- .../workflows/taint-analysis.md | 2 +- 38 files changed, 2014 insertions(+), 608 deletions(-) delete mode 100644 docs/guide/src/commands/contract.md create mode 100644 docs/guide/src/concepts/architecture.md create mode 100644 docs/guide/src/concepts/overview.md create mode 100644 docs/guide/src/reference/websocket.md create mode 100644 docs/guide/src/roadmap/cross-cutting.md create mode 100644 docs/guide/src/roadmap/solana.md create mode 100644 docs/guide/src/roadmap/solidity.md delete mode 100644 docs/guide/src/solana-support.md delete mode 100644 docs/guide/src/solana-testing.md create mode 100644 docs/guide/src/solana/limitations.md create mode 100644 docs/guide/src/solana/overview.md create mode 100644 docs/guide/src/solana/repl/analysis.md create mode 100644 docs/guide/src/solana/repl/findings.md create mode 100644 docs/guide/src/solana/repl/help.md create mode 100644 docs/guide/src/solana/repl/programs.md create mode 100644 docs/guide/src/solana/repl/runtime.md create mode 100644 docs/guide/src/solana/repl/scenarios.md create mode 100644 docs/guide/src/solana/repl/session.md create mode 100644 docs/guide/src/solana/repl/workspace.md create mode 100644 docs/guide/src/solana/workflows/audit-walkthrough.md create mode 100644 docs/guide/src/solana/workflows/scenarios.md create mode 100644 docs/guide/src/solidity/cli-analyze.md create mode 100644 docs/guide/src/solidity/cli-context.md create mode 100644 docs/guide/src/solidity/limitations.md create mode 100644 docs/guide/src/solidity/overview.md rename docs/guide/src/{commands => solidity/repl}/analysis.md (94%) create mode 100644 docs/guide/src/solidity/repl/contract.md rename docs/guide/src/{commands => solidity/repl}/findings.md (86%) create mode 100644 docs/guide/src/solidity/repl/scenarios.md rename docs/guide/src/{commands => solidity/repl}/session.md (81%) rename docs/guide/src/{commands => solidity/repl}/workspace.md (66%) rename docs/guide/src/{ => solidity}/workflows/audit-walkthrough.md (98%) rename docs/guide/src/{ => solidity}/workflows/taint-analysis.md (98%) diff --git a/docs/guide/src/SUMMARY.md b/docs/guide/src/SUMMARY.md index 12edcaa..3fd5ed2 100644 --- a/docs/guide/src/SUMMARY.md +++ b/docs/guide/src/SUMMARY.md @@ -3,22 +3,49 @@ [Introduction](./introduction.md) [Getting Started](./getting-started.md) -# Commands +# Concepts -- [Session](./commands/session.md) -- [Analysis](./commands/analysis.md) -- [Contract](./commands/contract.md) -- [Findings](./commands/findings.md) -- [Workspace](./commands/workspace.md) +- [What ilold does](./concepts/overview.md) +- [Architecture](./concepts/architecture.md) -# Workflows +# Solidity Backend -- [Audit Walkthrough](./workflows/audit-walkthrough.md) -- [Taint Analysis](./workflows/taint-analysis.md) -- [Solana Support](./solana-support.md) -- [Solana Testing Guide](./solana-testing.md) +- [Overview](./solidity/overview.md) +- [CLI: analyze](./solidity/cli-analyze.md) +- [CLI: context](./solidity/cli-context.md) +- [REPL: Session](./solidity/repl/session.md) +- [REPL: Analysis](./solidity/repl/analysis.md) +- [REPL: Contract](./solidity/repl/contract.md) +- [REPL: Findings](./solidity/repl/findings.md) +- [REPL: Scenarios](./solidity/repl/scenarios.md) +- [REPL: Workspace](./solidity/repl/workspace.md) +- [Workflow: Audit walkthrough](./solidity/workflows/audit-walkthrough.md) +- [Workflow: Taint analysis](./solidity/workflows/taint-analysis.md) +- [Limitations](./solidity/limitations.md) + +# Solana Backend + +- [Overview](./solana/overview.md) +- [REPL: Session](./solana/repl/session.md) +- [REPL: Programs and IDL](./solana/repl/programs.md) +- [REPL: Solana runtime](./solana/repl/runtime.md) +- [REPL: Analysis](./solana/repl/analysis.md) +- [REPL: Findings](./solana/repl/findings.md) +- [REPL: Scenarios](./solana/repl/scenarios.md) +- [REPL: Workspace](./solana/repl/workspace.md) +- [REPL: Help and control](./solana/repl/help.md) +- [Workflow: Audit walkthrough](./solana/workflows/audit-walkthrough.md) +- [Workflow: Scenarios and forks](./solana/workflows/scenarios.md) +- [Limitations](./solana/limitations.md) # Reference - [HTTP API](./reference/api-endpoints.md) -- [Known Limitations](./reference/limitations.md) +- [WebSocket events](./reference/websocket.md) +- [Known limitations](./reference/limitations.md) + +# Roadmap + +- [Solidity: future work](./roadmap/solidity.md) +- [Solana: Phase 2](./roadmap/solana.md) +- [Cross-cutting](./roadmap/cross-cutting.md) diff --git a/docs/guide/src/commands/contract.md b/docs/guide/src/commands/contract.md deleted file mode 100644 index 7eb4169..0000000 --- a/docs/guide/src/commands/contract.md +++ /dev/null @@ -1,116 +0,0 @@ -# Contract Commands - -Contract commands inspect the structure of the loaded contracts without modifying the session. - -## functions - -`f` or `functions` - -Lists the callable functions in the active contract with their access level and tags. - -``` -ilold[Staking]> f - - [public] deposit writes state - [public] withdraw writes state, external calls - [public] claimRewards writes state, external calls - [public] getStakeInfo view - [restricted(onlyOwner)] pause writes state - [restricted(onlyOwner)] unpause writes state -``` - -Tags indicate `writes state`, `external calls`, or `view` (read-only, no external calls). - -## funcs-all - -`fa` or `funcs-all` - -Lists all accessible functions including those inherited from parent contracts. - -``` -ilold[Staking]> fa - - [public] deposit writes state - [public] withdraw writes state, external calls - [public] claimRewards writes state, external calls - [public] getStakeInfo view - [restricted(onlyOwner)] pause writes state - [restricted(onlyOwner)] unpause writes state - - inherited: - [public] owner from Ownable - [public] transferOwnership from Ownable -``` - -Inherited functions are listed separately with their origin contract. - -## vars - -`v` or `vars` - -Lists the state variables of the active contract with their type and mutability tag. - -``` -ilold[Staking]> v - - mutable balances mapping(address => uint256) - mutable totalStaked uint256 - mutable rewardDebt mapping(address => uint256) - mutable paused bool - const MIN_STAKE uint256 - immutable rewardToken address -``` - -Tags are `mutable`, `const`, or `immutable`. - -## vars-all - -`va` or `vars-all` - -Lists all accessible state variables including inherited ones. - -``` -ilold[Staking]> va - - mutable balances mapping(address => uint256) - mutable totalStaked uint256 - mutable rewardDebt mapping(address => uint256) - mutable paused bool - const MIN_STAKE uint256 - immutable rewardToken address - - inherited: - mutable _owner address from Ownable -``` - -## contracts - -`ct` or `contracts` - -Lists all contracts in the loaded project with their type, function count, state variable count, and inheritance. - -``` -ilold[Staking]> ct - - [C] Staking 6 functions, 6 state vars, inherits Ownable, ReentrancyGuard ← current - [C] Ownable 3 functions, 1 state vars - [A] ReentrancyGuard 0 functions, 1 state vars - [I] IERC20 6 functions, 0 state vars -``` - -Type badges: `[C]` contract, `[I]` interface, `[L]` library, `[A]` abstract. - -## use - -`use <contract>` - -Switches the active contract. Clears the current session steps. - -``` -ilold[Staking]> use Ownable - - ✓ Now using: Ownable - Cleared 2 step(s) from previous contract -``` - -After switching, all session and analysis commands operate on the new contract. diff --git a/docs/guide/src/concepts/architecture.md b/docs/guide/src/concepts/architecture.md new file mode 100644 index 0000000..3dfefb4 --- /dev/null +++ b/docs/guide/src/concepts/architecture.md @@ -0,0 +1,64 @@ +# Architecture + +ilold is split into a handful of crates with clear responsibilities. The diagram below shows the pipeline for each backend; both meet at the shared web layer (`ilold-web`) and the REPL frontend (`ilold-cli`). + +## Solidity pipeline + +``` +.sol files + │ + ▼ +ilold-core::parse::solar_frontend (SolarParser) + │ + ▼ +ilold-core::model (Project, ContractDef, FunctionDef, ...) + │ + ▼ +ilold-core::cfg::builder (CfgBuilder) + │ + ▼ +ilold-core::pathtree::walker (build_path_tree) + │ + ▼ +ilold-core::sequence::analysis (analyze_sequences, analyze_project) + │ + ▼ +ilold-core::narrative + slicing (info, trace, slice, timeline) +``` + +`analyze` and `context` run this pipeline once and print to stdout. `serve` and `explore` keep the model in memory and expose it through the HTTP/WS API in `ilold-web`. + +## Solana pipeline + +``` +Anchor.toml + idls/<program>.json + target/deploy/<program>.so + │ + ▼ +ilold-solana-core::ingest (detect, AnchorProject) + │ + ▼ +ilold-solana-core::runtime (LiteSVM-backed engine) + │ + ▼ +ilold-solana-core::exploration (SolanaCommand → SolanaCommandResult) + │ per-step CU, logs, account diffs, decoded timelines + ▼ +ilold-web (shared HTTP/WS layer) (/api/cmd, /api/program/*, /ws) + │ + ▼ +ilold-cli::explore (REPL, parses input, prints results) +``` + +There is no static CFG or path tree on Solana today: programs are bytecode at this point. Anything that requires control-flow analysis (`slice`, `trace`, `sequence` narrative) is listed in [Solana: Limitations](../solana/limitations.md) and tracked under Phase 2 in the [Roadmap](../roadmap/solana.md). + +## Shared layer + +| Crate | Role | +| --- | --- | +| `ilold-cli` | Argument parsing, REPL, output formatting, key bindings (`crates/ilold-cli/src/main.rs`, `explore.rs`, `help.rs`) | +| `ilold-web` | HTTP + WebSocket API consumed by both the REPL (via `--attach`) and the web canvas | +| `ilold-session-core` | Shared session abstractions (steps, scenarios, canvas patches) | +| `ilold-core` | Solidity model, CFG, slicer, narrative, sequence analysis | +| `ilold-solana-core` | Anchor IDL ingest, LiteSVM runtime, instruction execution, timeline reconstruction | + +The web canvas (`crates/ilold-web/frontend`) subscribes to the `/ws` stream and stays in sync with whatever the REPL does. diff --git a/docs/guide/src/concepts/overview.md b/docs/guide/src/concepts/overview.md new file mode 100644 index 0000000..4ddf8b9 --- /dev/null +++ b/docs/guide/src/concepts/overview.md @@ -0,0 +1,20 @@ +# What ilold does + +ilold is a smart-contract audit explorer. It loads a project, builds an internal model, and exposes that model through an interactive REPL. The auditor adds entry-point calls to a **session**, the tool tracks accumulated effects, and analysis commands answer questions about the code without requiring a separate run. + +## Two backends, one shell + +| Backend | Input | Execution model | What you get | +| --- | --- | --- | --- | +| Solidity | `.sol` sources (file or directory) | Symbolic (parser, CFG, path tree, slicer) | Function narratives, execution trees with modifier inlining, backward/forward dataflow slices, cross-step timelines | +| Solana | Project root with `Anchor.toml`, `idls/<program>.json`, `target/deploy/<program>.so` | Concrete (in-process execution via LiteSVM) | Per-call CU and logs, account diffs, decoded timelines, scenario-isolated VMs, time-warp on the `Clock` sysvar | + +The REPL command surface is the same shell. Backend-specific commands are documented in [Solana: Solana runtime](../solana/repl/runtime.md) and [Solana: Scenarios](../solana/repl/scenarios.md). + +## Sessions and scenarios + +A **session** is the active scenario inside the active project. Adding a step means calling an entry point and recording its effects. A **scenario** is a named branch of the session timeline; scenarios can be created from scratch (`scenario new`) or forked from an existing one at a step boundary (`scenario fork`). On Solana, each scenario owns its own VM and user keypairs, so forks produce independent state. + +## What the tool does not do + +ilold has no built-in vulnerability detectors. There is no checklist that fires "this is a reentrancy" or "this is a missing access control" automatically. The auditor uses `who`, `info`, `trace`, `slice`, `timeline`, `state`, `step` to investigate, and records findings via `finding` and `note`. See [Roadmap](../roadmap/solana.md) for the Phase 2 detector engine and AST extractor. diff --git a/docs/guide/src/getting-started.md b/docs/guide/src/getting-started.md index 31d9213..ec3df85 100644 --- a/docs/guide/src/getting-started.md +++ b/docs/guide/src/getting-started.md @@ -10,17 +10,24 @@ cd ilold cargo build --release ``` -The binary is at `target/release/ilold`. +The binary is at `target/release/ilold`. The four subcommands are `analyze`, `context`, `serve`, and `explore` (see `crates/ilold-cli/src/main.rs`). -## Running +## Backend detection -Point ilold at a Solidity file or directory: +`serve` and `explore` auto-detect the backend from the path: + +- A directory or file containing `.sol` sources is treated as a Solidity project. +- A directory containing `Anchor.toml` (with `idls/<program>.json`) is treated as a Solana project. + +`analyze` and `context` are Solidity-only. + +## Running against a Solidity project ``` -cargo run -- explore contracts/staking.sol +cargo run -- explore tests/fixtures/staking.sol ``` -ilold parses the files, builds the model and CFGs, and drops you into the REPL: +ilold parses the files, builds the model and per-function CFGs, and drops the auditor into the REPL: ``` ╭──────────────────────────────────────────╮ @@ -32,87 +39,74 @@ ilold parses the files, builds the model and CFGs, and drops you into the REPL: ilold[Staking]> ``` -The port is assigned automatically unless you pass `--port`. +The port defaults to `0` (auto-assigned) for `explore` and `8080` for `serve`. Override with `--port`. -## First Session - -A typical first exploration of a staking contract: - -**1. Add a function call to the session:** +## Running against a Solana project ``` -ilold[Staking]> c deposit - - + Step 0: deposit [public] external - State writes: - · balances - · totalStaked - Sequence: deposit +cargo run -- explore tests/fixtures/solana/staking ``` -**2. Check accumulated state:** +ilold loads the IDL under `idls/<program>.json`, boots a LiteSVM with the program binary from `target/deploy/<program>.so` (or `bin/<program>.so`), and opens the REPL: ``` -ilold[→ deposit]> s - - ═══════════════════[ STATE ]═══════════════════ - balances - balances[msg.sender] += msg.value (deposit) - totalStaked - totalStaked += msg.value (deposit) +ilold[staking]> ``` -**3. See who else touches a variable:** +Without a compiled `.so` the REPL still starts and IDL navigation (`f`, `i`, `pda`, `vars`) works, but commands that drive the VM (`call`, `state`, `inspect`) fail until the program is built. -``` -ilold[→ deposit]> who totalStaked - - who: totalStaked - Writers: - [public] deposit - [public] withdraw - Readers: - [public] getStakeInfo - → sl deposit totalStaked, sl withdraw totalStaked - → tl totalStaked -``` +## First session -**4. Slice the data flow:** +A typical first exploration of the Solidity staking contract: ``` -ilold[→ deposit]> sl deposit totalStaked +ilold[Staking]> f - deposit · totalStaked — dataflow slice - ════════════════════════════════════════════════════════════ - [backward] - L42 require(msg.value > 0, "Zero deposit") - L45 totalStaked += msg.value - [forward] - L47 emit Deposited(msg.sender, msg.value) - → tr deposit | tl totalStaked + [P] deposit writes state, external calls + [P] withdraw writes state, external calls + [P] claimRewards writes state, external calls + [R] setRewardRate writes state + [R] pause writes state + [R] unpause writes state + [P] rewardPerToken view + [P] earned view ``` -**5. Trace the full execution flow:** +``` +ilold[Staking]> c deposit + + Step 0: deposit [P] external + State writes: + · balances[msg.sender] + · lastUpdateTime + · rewardPerTokenStored + · rewards[account] + · totalStaked + · userRewardPerTokenPaid[account] + Sequence: deposit ``` -ilold[→ deposit]> tr deposit - ╭──────────────────────────────────────╮ - │ Staking::deposit() │ - │ modifiers: whenNotPaused │ - │ max inlining depth: 2 │ - ╰──────────────────────────────────────╯ +``` +ilold[Staking → deposit]> s - 001 │ ▶ deposit() - 002 │ ├─ ◇ require(!paused, "Paused") [from: whenNotPaused] - 003 │ ├─ ◇ require(msg.value > 0, "Zero deposit") - 004 │ ├─ ✏ balances[msg.sender] += msg.value - 005 │ ├─ ✏ totalStaked += msg.value - 006 │ └─ ◆ emit Deposited(msg.sender, msg.value) - → sl deposit balances, sl deposit totalStaked + ════════════════════════════════════════════[ STATE ]═════════════════════════════════════════════ + balances[msg.sender] + += amount (step 0:15, deposit) + lastUpdateTime + = block.timestamp (step 0:8, deposit) + rewardPerTokenStored + = rewardPerToken() (step 0:7, deposit) + rewards[account] + = earned(account) (step 0:11, deposit) + totalStaked + += amount (step 0:16, deposit) + userRewardPerTokenPaid[account] + = rewardPerTokenStored (step 0:12, deposit) ``` -## Inline Help +The full audit flow (`who`, `tr`, `sl`, `tl`, scenarios, findings, export) is covered in [Solidity: Audit walkthrough](./solidity/workflows/audit-walkthrough.md). For the Solana equivalent (`users new`, `call <ix>`, `state`, `step`, `timeline <pubkey>`) see [Solana: Audit walkthrough](./solana/workflows/audit-walkthrough.md). + +## Inline help Type `?` at the prompt for the full command reference. Append `?` to any command for its usage: @@ -120,3 +114,5 @@ Type `?` at the prompt for the full command reference. Append `?` to any command ilold[Staking]> sl? slice <func> <var> [--backward] Dataflow slice. Example: sl deposit totalStaked --backward ``` + +On Solana, appending `?` renders the structured help block for the command, including syntax, flags, examples, return shape, and related commands. diff --git a/docs/guide/src/introduction.md b/docs/guide/src/introduction.md index 04f9d9e..f4e0097 100644 --- a/docs/guide/src/introduction.md +++ b/docs/guide/src/introduction.md @@ -1,39 +1,31 @@ # Introduction -ilold is a static analysis tool for Solidity smart contracts that provides an interactive REPL for exploring execution paths, state mutations, and data flow. Instead of producing a batch report and leaving the auditor to interpret it, ilold builds an in-memory model of the contract and lets you query it interactively, building up call sequences step by step. +ilold is an execution path analyzer and interactive security workbench for smart contracts. It maps every possible path through a protocol (every branch, function combination, and state mutation) and lets users navigate them visually, branch by branch, with an LLM reasoning over each path. -## Core Concept +The tool loads a project, builds an in-memory model, and drops the auditor into a REPL backed by a live canvas. Each command answers a question about the protocol: list entry points, add a call to a session, inspect state changes, trace execution flow, slice data dependencies, record findings, export a report. The canvas reflects the same state in a visual graph that the user navigates by clicking, expanding, and forking. -A security audit is a conversation with the code. ilold models that conversation as a **session**: you add function calls one at a time, and the tool tracks how state accumulates across the sequence. At any point you can ask questions -- who writes this variable? what does the data flow look like? what is the full execution tree? -- and get answers scoped to the contract's actual structure. +ilold supports two backends: -The session is the central abstraction. Every analysis command operates either on a single function or on the accumulated session state, so the results stay grounded in realistic execution scenarios rather than theoretical possibilities. +- **Solidity**: static analysis on top of `solar-compiler`. Contracts are parsed into a typed model; per-function CFGs and path trees drive `info`, `trace`, `slice`, `timeline`, and sequence narratives. +- **Solana**: concrete execution on top of LiteSVM. Programs run inside an in-process VM, accounts are decoded from the program IDL, and timelines are reconstructed from per-step account diffs. -## Pipeline +The REPL surface is the same shell for both backends, with backend-specific commands documented in their respective sections. -``` -.sol files - | - v -Parser (solar-compiler) - | - v -Model (contracts, functions, modifiers, state variables) - | - v -CFG (control flow graph per function) - | - v -Analysis - ├── trace — execution flow tree with modifier inlining - ├── slice — backward/forward dataflow analysis - ├── timeline — cross-step mutation history - └── narrative — function and sequence summaries -``` +## Core concept -The parser produces a typed model of each contract. From the model, ilold builds a control flow graph per function, then layers analysis passes on top. The REPL exposes these passes as individual commands. +An audit is a conversation with the code. ilold models that conversation as a **session**: the auditor adds entry-point calls one at a time, and the tool tracks how state accumulates across the sequence. Every analysis command operates either on a single entry point or on the accumulated session state. -## Key Differentiator +Sessions can be branched into named **scenarios**. A scenario is an independent timeline with its own state; on Solana each scenario also owns its own VM and user keypairs. -Traditional static analyzers run a fixed set of detectors and produce a flat list of warnings. ilold does not detect vulnerabilities automatically. Instead, it gives the auditor tools to explore the contract interactively: build a call sequence, inspect state changes, trace execution flow, slice data dependencies. The auditor drives the analysis and records findings as they go. +## Key differentiator -This is closer to how manual audits actually work -- following a thread through the code, checking what happens when functions are called in a specific order, and documenting what you find. +ilold does not detect vulnerabilities automatically. It gives the auditor primitives to drive the analysis: build a sequence, inspect state changes, trace execution flow, slice dependencies, and record findings. The auditor leads, the tool answers questions grounded in the actual structure of the code (Solidity) or the actual runtime behaviour (Solana). + +## Where to start + +- [Getting Started](./getting-started.md): install and first session. +- [Concepts](./concepts/overview.md): what the tool does and the data pipeline. +- [Solidity Backend](./solidity/overview.md): Solidity REPL, CLI, workflows. +- [Solana Backend](./solana/overview.md): Solana REPL, runtime commands, workflows. +- [Reference](./reference/api-endpoints.md): HTTP API and WebSocket events. +- [Roadmap](./roadmap/solidity.md): known gaps and future work. diff --git a/docs/guide/src/reference/api-endpoints.md b/docs/guide/src/reference/api-endpoints.md index 9338228..c5d41be 100644 --- a/docs/guide/src/reference/api-endpoints.md +++ b/docs/guide/src/reference/api-endpoints.md @@ -163,17 +163,29 @@ Returns the sequence analysis for a contract: per-function behavior summaries (s Returns search suggestions for the contract: function names, state variable names, event names, external call targets, and predefined categories (revert, return, assembly). -## WebSocket +## Solana-specific endpoints + +Solana shares `/api/cmd`, `/api/project`, `/api/project/map`, `/ws`, and most session endpoints with the Solidity backend. The following routes are Solana-only: + +| Endpoint | Description | +| --- | --- | +| `GET /api/program/{name}/view` | Full `ProgramView` for the named program: instructions (with args, accounts, signers, PDAs, admin-gated flag, coupling hints), account types, discriminators. | +| `GET /api/program/{name}/overlay` | Runtime overlay aggregated over the active scenario: calls-per-instruction, failures, CU stats, CPI edges. | +| `GET /api/users/{scenario}/labels` | Returns the keypair labels for a given scenario (used by the web canvas to render `users new <name>` aliases). | +| `GET /api/scenarios` | Scenario list for the active program (active marker, step counts). | +| `GET /api/scenarios/all` | Scenario list across every program in the workspace. | -### GET /ws +`POST /api/cmd` carries a `SolanaCommand` payload (`Call`, `Users`, `UsersNew`, `Airdrop`, `TimeWarp`, `Pda`, `Inspect`, `Scenario`, `SaveSession`, `LoadSession`, …; see `crates/ilold-solana-core/src/exploration/commands.rs`). The response is a `SolanaCommandResult` variant (`StepAdded`, `CallFailed`, `StateView`, `Timeline`, `Coverage`, etc.). + +## WebSocket -Upgrades to a WebSocket connection. The server pushes `CanvasPatch` messages when session state changes: +`GET /ws` upgrades to a WebSocket connection. See [WebSocket events](./websocket.md) for the full event vocabulary and payload shapes. -- `AddNode`: a step was added (function, access level, step index) -- `RemoveLastNode`: the last step was removed -- `ClearAll`: all steps were cleared +A second WebSocket route `GET /ws/pty` provides a PTY bridge used by the embedded REPL in the web canvas. ## Related pages -- [Session commands](../commands/session.md) +- [WebSocket events](./websocket.md) +- [Solidity REPL: Session](../solidity/repl/session.md) +- [Solana REPL: Session](../solana/repl/session.md) - [Known Limitations](./limitations.md) diff --git a/docs/guide/src/reference/limitations.md b/docs/guide/src/reference/limitations.md index 69b6c67..54fd247 100644 --- a/docs/guide/src/reference/limitations.md +++ b/docs/guide/src/reference/limitations.md @@ -1,40 +1,8 @@ # Known Limitations -This page documents the current analysis boundaries of ilold. Understanding these limitations is necessary for interpreting slice, timeline, and trace results correctly. +Limitations are documented per backend, since the boundaries are very different: -## Intraprocedural slicing only +- [Solidity: Limitations](../solidity/limitations.md): intraprocedural slicing, assignment-only DEF extraction, modifier placeholder split, tuple destructuring, etc. +- [Solana: Limitations](../solana/limitations.md): no static CFG (no `slice` / `trace` yet), heuristic `who`, `time-warp` semantics, keypair persistence, CPI visibility. -The dataflow slicer operates within a single function body (plus its inlined modifiers). It does not follow values across function call boundaries. If `deposit` calls an internal helper `_updateBalance(amount)`, the slice for `amount` in `deposit` will show the call site but not the writes inside `_updateBalance`. Use `tr <func>` to inspect internal call bodies separately. - -## Assignment-only DEF extraction - -Only `Assignment` expressions (`x = ...`, `x += ...`, `x -= ...`) produce DEF entries in the slicer's use-def analysis. Solidity mutations that are not modeled as assignments -- specifically `x++`, `x--`, `++x`, `--x`, `delete x`, `arr.push(v)`, and `arr.pop()` -- are captured as USEs of the target variable but not as DEFs. A backward slice on a variable mutated exclusively through `.push()` will miss the mutating statement as a definition point. - -## Modifier placeholder split - -Modifier bodies are split at the first top-level `_;` (placeholder) statement to separate "before" code from "after" code. If the placeholder appears inside a nested block (e.g., inside an `if` branch), the entire modifier body is treated as "before" code. This is over-inclusive: statements that should execute after the function body will appear before it in the flattened view. In practice, most modifiers place `_;` at the top level, so this rarely triggers. - -## Forward slice over-tainting via ancestor merge - -When a statement is included in a forward slice, its lexical ancestors (enclosing `if`, `for`, `while` blocks) are also included so that the rendered slice shows control-flow context. The ancestor's condition variables are merged into the tainted set. This means an `if (unrelatedCondition)` enclosing a tainted write will add `unrelatedCondition` to the taint set, potentially pulling in unrelated statements in subsequent iterations. The result is a conservative (larger) slice rather than a precise one. - -## Tuple destructuring - -Tuple destructuring assignments such as `(a, b) = foo()` may not be recognized as DEFs depending on how the Solidity frontend lowers them. If the frontend does not emit a top-level `Assignment` node, the individual targets (`a`, `b`) are treated as USEs only. This can cause a backward slice to miss the destructuring as a definition point for `a` or `b`. - -## Timeline tracks state mutations only - -The `timeline` command tracks writes to state variables across session steps. Local variable assignments within a function body are recorded separately (`local_entries`) but are not visible in the default timeline output. If you need to trace a local variable, use `slice` within the specific function instead. - -## Session requires at least one call - -The `timeline`, `state`, and `sequence` endpoints require an active session with at least one `Call` step. The `timeline` and `state` commands return empty results if no steps have been added. The `sequence` command requires at least two steps. Use `tr <func>` for read-only inspection of a function's flow without adding it to the session. - -## Internal and private functions cannot be session entry points - -Session steps model real external transactions. Functions with `internal` or `private` visibility cannot be called from outside the contract, so they cannot be added as session steps via `c <func>`. Use `tr <func>` to inspect their execution flow, or call a public/external function that invokes them to see their effects through the modifier and internal-call inlining in the trace. - -## Related pages - -- [Taint Analysis](../workflows/taint-analysis.md) -- forward slice caveats in practice -- [HTTP API Reference](./api-endpoints.md) +For the planned remediations, see the [Roadmap](../roadmap/solidity.md). diff --git a/docs/guide/src/reference/websocket.md b/docs/guide/src/reference/websocket.md new file mode 100644 index 0000000..62a809d --- /dev/null +++ b/docs/guide/src/reference/websocket.md @@ -0,0 +1,43 @@ +# WebSocket Events + +The `/ws` route emits `ServerMessage` events (JSON, tagged on a `type` field) whenever the active session changes. The full mapping from internal `CanvasPatch` variants to wire events lives in `crates/ilold-web/src/ws/handler.rs`. + +## Session events + +| Event | Fields | Trigger | +| --- | --- | --- | +| `session_add_node` | `scenario`, `function`, `access`, `step_index`, optional `runtime` (CU + diffs + logs excerpt on Solana) | `call` adds a step | +| `session_remove_node` | `scenario` | `back` rewinds the last step | +| `session_clear` | `scenario` | `clear` wipes the scenario | +| `session_highlight` | `scenario`, `function` | Auditor selects a step (web canvas) | + +## Scenario events + +| Event | Fields | Trigger | +| --- | --- | --- | +| `scenario_created` | `name` | `scenario new` | +| `scenario_switched` | `from`, `to` | `scenario switch` | +| `scenario_deleted` | `name` | `scenario delete` | +| `scenario_forked` | `from`, `to`, `at_step` | `scenario fork` | +| `scenario_store_reloaded` | `active` | After `load`, when the entire scenario tree is rehydrated | + +## Solana-only events + +| Event | Fields | Trigger | +| --- | --- | --- | +| `solana_users_changed` | `scenario` | `users new`, `airdrop`, or anything that mutates the keypair set | +| `session_overlay_update` | `scenario`, `ix_name`, `calls_added`, `failed_added`, optional `cu`, `cpi_targets_added` | Runtime overlay aggregates updated after a `call` | + +## Client → server + +The only message the client can send is a `search` query consumed by `crates/ilold-web/src/ws/search.rs`. Responses come back as `search_result` (one per match) and `search_complete` (`total` count). + +## PTY bridge + +`GET /ws/pty` opens a PTY for the embedded REPL in the web canvas. The protocol is binary-passthrough; the wire format is documented inline in `crates/ilold-web/src/ws/pty.rs`. + +## Related pages + +- [HTTP API](./api-endpoints.md) +- [Solana REPL: Scenarios](../solana/repl/scenarios.md): the source of the scenario events. +- [Solidity REPL: Scenarios](../solidity/repl/scenarios.md): same events on the Solidity side. diff --git a/docs/guide/src/roadmap/cross-cutting.md b/docs/guide/src/roadmap/cross-cutting.md new file mode 100644 index 0000000..8e571c2 --- /dev/null +++ b/docs/guide/src/roadmap/cross-cutting.md @@ -0,0 +1,14 @@ +# Cross-cutting Roadmap + +## MCP server for LLM agent integration + +Expose the REPL surface as an MCP (Model Context Protocol) server so an LLM agent can drive an audit programmatically. Each REPL command maps to a typed MCP tool reusing the existing HTTP API. With the server in place, an agent maps a project, proposes call sequences, inspects state, and records findings without a human translating between the model and the REPL. + +## Elozer integration + +Elozer is our in-house static analyzer. It produces a typed AST for smart-contract source today; a CFG layer needs to be built on top before slicing, taint analysis, and detectors can run on the Solana side. Wiring Elozer into ilold provides that AST foundation and unblocks the items listed in the [Solana roadmap](./solana.md). + +## Related + +- [Solidity: future work](./solidity.md) +- [Solana: future work](./solana.md) diff --git a/docs/guide/src/roadmap/solana.md b/docs/guide/src/roadmap/solana.md new file mode 100644 index 0000000..c3bf976 --- /dev/null +++ b/docs/guide/src/roadmap/solana.md @@ -0,0 +1,46 @@ +# Solana Roadmap + +Items below are tracked work without committed dates. + +## AST via Elozer + +Plug Elozer, our in-house static analyzer, into ilold to produce a typed AST for the program source: account validation, state writes, constraints, CPI sites. Foundation for everything below. + +## CFG on top of the AST + +Build the control-flow graph layer on the Elozer AST. Brings Solana to parity with the Solidity CFG view and unlocks `slice`, `trace`, and structural narratives. + +## Detector engine + +Detectors for known Sealevel attack patterns (missing signer checks, missing owner checks, account confusion, arithmetic overflow, reinit, PDA seed collision) measured against the public sealevel-attacks corpus. Depends on AST + CFG. + +## LiteSVM register-tracing bridge + +Record concrete values at each VM instruction boundary so the dynamic trace can confirm or refute hypotheses produced by the static layer. + +## CFG visual parity on the canvas + +The web canvas renders Solana state today as a flat bipartite graph (instructions ↔ accounts). The redesigned view will mirror the Solidity CFG: per-instruction control flow, branch nodes, constraint annotations. + +## CPI graph in the UI + +The runtime already records CPI edges (`coverage` surfaces them in text). A dedicated CPI view in the canvas is the next visual step. + +## Sequence narrative + +`sequence` is aliased to `session` on Solana today. A true narrative engine reuses the existing `coupling` aggregate plus a renderer mirroring the Solidity output. + +## Open to ideas + +The Solana side is younger and the roadmap above is the current shape, not a fixed plan. Examples of directions we are open to: + +- New analysis passes once Elozer's AST and the CFG layer are in place. +- Integrations with other Solana tooling (anchor-cli, sealevel-attacks corpus consumers, custom IDL extensions). +- Alternative VMs or replay engines beyond LiteSVM if a use case justifies it. + +If you have a concrete use case the current backend does not cover, open an issue or reach out. + +## Related + +- [Solana: Limitations](../solana/limitations.md) +- [Cross-cutting](./cross-cutting.md) diff --git a/docs/guide/src/roadmap/solidity.md b/docs/guide/src/roadmap/solidity.md new file mode 100644 index 0000000..ba80299 --- /dev/null +++ b/docs/guide/src/roadmap/solidity.md @@ -0,0 +1,29 @@ +# Solidity Roadmap + +Solidity covers the MVP scope. The items below are tracked enhancements; the section after them is open to ideas. + +## Slicer precision + +The slicer is intraprocedural and assignment-only. Mutations via `x++`, `delete x`, `arr.push(v)` show up as USEs but not DEFs; tuple destructuring may not surface; forward slices include lexical ancestors and can over-taint. + +## Cross-function dataflow + +The slicer stops at call boundaries. Following a value through a helper requires `tr <func>` (manual inlining) or a separate run on the helper. + +## Modifier placeholder split + +Modifier bodies are split at the first top-level `_;`. Nested placeholders fall back to "before" code. + +## Sequence depth bound + +`--max-seq-depth` defaults to 3. Deeper bounds grow combinatorially; no change planned. + +## Open to ideas + +The roadmap is not closed. Examples of integrations we have considered but not started: + +- **Foundry**: today ilold reads Foundry projects (the `multi/` and `recursive/` fixtures are Foundry layouts) but does not invoke `forge build` or `forge test`. Possible directions include using `forge build` artefacts as an alternative ingest path, replaying PoCs from findings via `forge test --debug`, or cross-linking traces. +- **Cross-tool reports**: emitting findings in a format consumable by other audit pipelines. +- **New analysis passes**: anything that fits the CFG + path-tree model. + +If you have a use case the current backend does not cover, open an issue or reach out. diff --git a/docs/guide/src/solana-support.md b/docs/guide/src/solana-support.md deleted file mode 100644 index acd98b4..0000000 --- a/docs/guide/src/solana-support.md +++ /dev/null @@ -1,111 +0,0 @@ -# Solana Support - -Ilold also analyzes Solana programs (Anchor IDL based) through the same REPL, -with a different execution backend. This page explains what works, what does -not, and how the auditor workflow maps from Solidity to Solana. - -## Loading a Solana program - -Point ilold at a directory that contains `Anchor.toml`, `idls/<program>.json` -and (optionally) a compiled `target/deploy/<program>.so`: - -``` -ilold serve tests/fixtures/solana/staking -``` - -Without the `.so` the REPL still loads — IDL navigation works — but anything -that requires VM execution (`call`, `state`, `inspect`) fails until the -program is built. - -## Mental model: how Solana differs from Solidity - -| Concept | Solidity | Solana (Anchor) | -| --- | --- | --- | -| Entry point | function on a contract | instruction on a program | -| Persistent state | contract state variables | accounts owned by the program | -| Caller identity | `msg.sender` | signers passed by the client | -| Side-effect surface | storage writes / events | account mutations + logs | -| Execution | symbolic (CFG + paths) | concrete (LiteSVM with real bytecode) | - -Because the Solana side runs the real BPF program, every Call needs the -auditor to provide accounts (often new keypairs created with `users new`) -and signers. PDAs are derived on demand, not declared up front. - -## Quickstart - -```text -ilold[staking]> users new admin 100000000 -ilold[staking]> users new pool 2000000 -ilold[staking]> users new alice 50000000 -ilold[staking]> users new alice_stake 2000000 -ilold[staking]> call initialize_pool reward_rate=10 pool=pool admin=admin -ilold[staking → initialize_pool]> call stake amount=1000 pool=pool user_stake=alice_stake user=alice -ilold[staking → initialize_pool → stake]> state -ilold[staking → initialize_pool → stake]> step 1 -ilold[staking → initialize_pool → stake]> who Pool -ilold[staking → initialize_pool → stake]> timeline <pool-pubkey> -ilold[staking → initialize_pool → stake]> finding High "missing reentrancy guard" -ilold[staking → initialize_pool → stake]> export -``` - -`call` accepts two forms: a concise key-value form (`arg=value`, -`account_field=user_name`) where unmapped names are turned into local -keypairs, or a fully explicit JSON payload `call <ix> {"args":..., -"accounts":...,"signers":...}`. - -## Command parity - -Everything documented under [Session](./commands/session.md), [Contract](./commands/contract.md), -[Findings](./commands/findings.md) and [Workspace](./commands/workspace.md) -works against Solana with the same syntax. The Analysis surface is partial: - -| Command | Status | Notes | -| --- | --- | --- | -| `info <ix>` | works | args + account flags + discriminator | -| `who <account_type>` | works | maps `snake_case` field names to PascalCase types | -| `timeline <pubkey>` | works | cross-step mutation history with before/after decoded | -| `step <index>` | works | re-prints CU, logs, account diffs | -| `findings` / `export` | works | Markdown report includes scenario, sequence, findings | -| `slice` | not implemented | requires Anchor handler AST — Phase 2 | -| `trace` | not implemented | requires per-instruction CFG — Phase 2 | -| `sequence` | aliased to `session` | full narrative with CPI dependencies needs CFG | - -## Solana-only commands - -| Command | Use | -| --- | --- | -| `users new <name> [lamports]` | create a keypair and airdrop SOL into it | -| `airdrop <name> <lamports>` | top up an existing keypair | -| `time-warp <delta_seconds>` | advance the `Clock` sysvar (positive forward, negative back) | -| `pda <ix>` | list PDAs declared by the instruction in the IDL | -| `inspect <pubkey>` | decode an account by Anchor discriminator | - -## Scenarios and VM rewind - -Scenarios (`scenario new`, `scenario fork`, `scenario switch`) keep an -independent VM per branch. `back` and `clear` rewind the VM to the -pre-step snapshot, so re-issuing the same Call after `back` produces -fresh CU and diffs (instead of replaying stale state). `time-warp` is a -global side-effect on the Clock and is not undone by `back` — it is the -auditor's responsibility to reset the clock if needed. - -## Save and load - -`save <name>` writes the entire scenario tree (steps, runtime traces, -findings, fork origins, original Call inputs) to -`~/.ilold/sessions/<name>.json`. `load <name>` reboots a fresh VM per -scenario, re-airdrops the in-memory users, and replays each Call from -the persisted payload. The reconstructed VM state matches the saved -snapshot for typical Anchor flows; programs with non-deterministic -behaviour or balances above the default replay cap may diverge. - -## Known limitations - -- No static control-flow graph or path-tree analysis (Solana programs - are bytecode at this point, not parsed Rust). -- `who` is heuristic: it matches account field names against IDL account - types via snake_case → PascalCase, so unconventional naming will miss. -- `time-warp` advances `unix_timestamp` linearly but does not move the - slot counter backwards on negative deltas. -- LoadSession is best-effort for legacy saves without `call_payload` - (timeline restores, VM stays at genesis). diff --git a/docs/guide/src/solana-testing.md b/docs/guide/src/solana-testing.md deleted file mode 100644 index 5961d23..0000000 --- a/docs/guide/src/solana-testing.md +++ /dev/null @@ -1,199 +0,0 @@ -# Testing Ilold against a Solana program - -This page is the practical guide for running, exercising and validating -Ilold's Solana support. It complements `solana-support.md` (which -explains the conceptual model) and is meant to be read by someone who -wants to break the tool, contribute fixes, or audit a real program. - -## 1. Build + serve - -```bash -cargo build --release --bin ilold -p ilold-cli -./target/release/ilold serve --port 8080 tests/fixtures/solana/staking -``` - -`tests/fixtures/solana/staking` is the canonical Anchor fixture: a -toy staking program with `initialize_pool`, `stake`, `unstake`, -`add_rewards`, `claim_rewards`. The IDL lives in `idls/staking.json` -and the compiled `.so` is committed to `bin/staking.so` so the suite -runs even on machines without the Anchor toolchain. - -## 2. The REPL - -In a second terminal: - -```bash -./target/release/ilold explore --base-url http://127.0.0.1:8080 --contract staking -``` - -Type `?` for the help. Two-terminal flows (one for `serve`, one for -`explore`) are the supported mode — both stay in sync via REST + WS. - -## 3. Command surface - -### Session - -| Command | Description | -| --- | --- | -| `call <ix> arg=val acc=user` | Concise key-value call. Unmapped names become local keypairs. | -| `call <ix> {json}` | Full payload `{args, accounts, signers}` form. | -| `back` / `b` | Pop the last step and rewind the VM to its pre-Call snapshot. | -| `clear` / `cl` | Drop all steps in the active scenario; rewinds the VM to genesis. | -| `state` / `s` | Decoded view of accounts mutated this session. | -| `session` / `s` | Active scenario summary (steps + findings count). | -| `step <i>` / `st <i>` | Re-inspect step `i`: CU, logs, decoded diffs. | - -### Scenarios - -| Command | Description | -| --- | --- | -| `scenario new <name>` / `sc new` | Create an empty scenario. | -| `scenario fork <name> [step]` | Fork at step N (or HEAD). The branch VM is rewound to that step's pre-Call snapshot. | -| `scenario switch <name>` | Activate a scenario. Each scenario has its own VM and users. | -| `scenario list` | Show all scenarios with active marker + step count. | -| `scenario delete <name>` | Remove a scenario (cannot delete the active one). | - -### Solana runtime (no Solidity counterpart) - -| Command | Description | -| --- | --- | -| `users new <name> [lamports]` | Create a keypair and airdrop SOL into it. Default: 10 SOL. | -| `airdrop <name> <lamports>` | Top up an existing keypair. | -| `time-warp <delta_seconds>` / `tw` | Advance the `Clock` sysvar. | -| `pda <ix>` | List PDAs declared by an instruction in the IDL. | -| `inspect <pubkey>` | Decode an account by Anchor discriminator. | - -### Analysis - -| Command | Description | -| --- | --- | -| `info <ix>` / `i <ix>` | Args, accounts, flags, discriminator. | -| `funcs` / `f` / `funcs-all` / `fa` | Instruction list (compact / verbose). | -| `vars` / `v` / `vars-all` / `va` | Account types declared in IDL. | -| `who <account_type>` | Instructions referencing this type (heuristic snake_case → PascalCase). | -| `timeline <pubkey>` / `tl` | Cross-step mutation history with before/after decoded. | - -### Findings & workspace - -| Command | Description | -| --- | --- | -| `finding <severity> <title> [--rec="..."]` / `fi` | Record a finding. Severities: critical, high, medium, low, info. The optional `--rec=` flag attaches a remediation suggestion that the export renders as its own block. | -| `note <text>` / `n` | Annotation on the active sequence. | -| `status <ix> <state>` | Mark instruction reviewed / suspicious / etc. | -| `findings` / `fl` | List recorded findings. | -| `export [--auditor="..."] [--version="..."] [--date=YYYY-MM-DD]` / `ex` | Markdown report aggregating audit metadata, severity matrix, methodology, findings (with step index + recommendation when set) and per-scenario steps from ALL scenarios. | -| `save <name> [--with-keypairs]` / `load <name>` | Persist / restore a session JSON in `~/.ilold/sessions/`. The optional `--with-keypairs` flag embeds the per-scenario user keypairs in plaintext so the next `load` reproduces the exact same pubkeys (and any PDAs derived from them). Default OFF; the CLI prints a warning at save and load when the bundle carries secrets. | - -## 4. Differences vs Solidity - -The REPL is the same shell but the backends are very different. This -table says exactly what overlaps, what diverges and what is not yet -implemented. - -| Concept | Solidity | Solana | -| --- | --- | --- | -| Entry point | function on a contract | instruction on a program | -| Persistent state | contract state variables | accounts owned by the program | -| Caller identity | `msg.sender` (implicit) | signers passed by the client | -| `who <X>` | finds writers / readers of a state variable (uses CFG) | finds instructions referencing an account type (heuristic on the IDL) | -| `timeline <X>` | mutation history of a state variable | mutation history of an account pubkey, decoded | -| `step <i>` | reads the step's narrative from the saved CFG path | re-prints the runtime trace (CU, logs, account diffs) of step i | -| `slice <fn> <var>` | backward / forward dataflow on the function CFG | **not implemented** — needs a handler AST extractor (Phase 2) | -| `trace <fn>` | full execution flow with modifier inlining | **not implemented** — same reason | -| `sequence` | narrative of cross-step dependencies | aliased to `session` (no narrative engine yet) | -| Execution model | symbolic (CFG + paths) | concrete (LiteSVM with the real BPF binary) | -| Save/Load | restores step list + paths; no VM | restores step list + replays Calls against a fresh VM (T-R39) | -| `Back` | drops the last step from the timeline only | drops the step AND restores the VM to the pre-Call snapshot (T-R33) | - -## 5. Smoke test suite - -```bash -bash tests/scenarios/run.sh -``` - -13 scenarios under `tests/scenarios/` (12 bash + 1 optional python WebSocket -test that runs when `python3` and `pip install websockets` are available). -Each spawns a fresh `ilold serve` -on port 8081 (override with `ILOLD_TEST_PORT`) and aggregates pass/fail. -Adding a scenario: - -1. Create `tests/scenarios/NN-name.sh` (use `01-happy-path.sh` as - template, source `_lib.sh`). -2. Make it executable: `chmod +x …`. -3. Re-run the runner; it picks up `[0-9][0-9]-*.sh` automatically. -4. Optionally provide a python websockets script next to the bash - files; if the runner finds a `.py` it will run it after the bash - suite when `python3 + websockets` is installed. - -The current set covers: - -- Happy path with state accumulation. -- Four negative attack vectors that must be rejected by the program - (re-init, claim without stake, unstake overflow, non-admin - add_rewards). -- Fork isolation: a branch VM rewinds to the fork point and changes - there do not leak to main. -- `Back` rewinds the VM (T-R33). -- 50 consecutive Calls execute (T-R40 blockhash rotation). -- `Save → Clear → Load` reconstructs the VM (T-R39). -- Findings recorded in any scenario surface in the markdown export. -- WebSocket broadcast count + payload (T-R37 runtime metadata). - -## 6. Reproducing an audit walkthrough manually - -```text -ilold[staking]> users new admin 100000000 -ilold[staking]> users new pool 2000000 -ilold[staking]> users new alice 50000000 -ilold[staking]> users new alice_stake 2000000 -ilold[staking]> call initialize_pool reward_rate=10 pool=pool admin=admin -ilold[staking → initialize_pool]> call stake amount=1000 pool=pool user_stake=alice_stake user=alice -ilold[staking → … → stake]> step 1 -ilold[staking → … → stake]> who Pool -ilold[staking → … → stake]> timeline <pool-pubkey> -ilold[staking → … → stake]> finding High "missing reentrancy guard" --rec="Apply checks-effects-interactions" -ilold[staking → … → stake]> findings -ilold[staking → … → stake]> export --auditor="Demo Auditor" --version="v0.1.0" --date=2026-05-09 -ilold[staking → … → stake]> save my-audit --with-keypairs -ilold[staking → … → stake]> clear -ilold[staking]> load my-audit -``` - -## 7. Frontend testing - -```bash -cd crates/ilold-web/frontend -npm install -npm run dev # vite proxy points /api and /ws to http://localhost:8080 -``` - -In another terminal `./target/release/ilold serve --port 8080 …` and -open `http://localhost:5173/contract/staking`. The page subscribes to -the same WS stream the CLI consumes, so any command run from the REPL -shows up live (with full CU / diffs / logs after T-R37). - -To stress the UI specifically (race fix in audit round 10): - -1. Open `/contract/staking` (Solana). -2. Click a different contract from the project map mid-load (before - the page renders). -3. The new contract must render — no `kind=solidity` regression on a - Solana program. - -## 8. Known limitations as of this writing - -- `slice`, `trace`, `sequence` analysis for Solana require a handler - AST and are deferred (Phase 2 in the SDD roadmap). -- By default `Save → Load` regenerates user keypairs, so PDAs that - depend on a signer pubkey come back at different addresses. Pass - `--with-keypairs` to `save` to opt into deterministic reload — the - resulting JSON embeds the test keypairs in plaintext, so do NOT - commit those bundles to public repositories. The CLI prints a - reminder both when saving and when loading a bundle with the - `keypairs_present: true` header. -- `time-warp` advances `unix_timestamp` linearly; negative deltas do - not reverse `slot`. -- `who` uses snake_case → PascalCase heuristic to map account fields - to types; non-conventional naming will miss. -- The CFG visual layout for Solana is a flat bipartite (instructions - vs accounts). A redesign is queued pending design feedback. diff --git a/docs/guide/src/solana/limitations.md b/docs/guide/src/solana/limitations.md new file mode 100644 index 0000000..7d28382 --- /dev/null +++ b/docs/guide/src/solana/limitations.md @@ -0,0 +1,47 @@ +# Solana Known Limitations + +These boundaries reflect the current Solana backend. The corresponding [Roadmap entry](../roadmap/solana.md) tracks the Phase 2 work that will lift each of them. + +## No static control-flow analysis + +Solana programs are loaded as compiled binaries. There is no parsed handler AST and no per-instruction CFG, so the commands that depend on it are not implemented: + +- `slice <fn> <var>`: needs Anchor handler AST (see Solana roadmap). +- `trace <fn>`: same reason. +- `sequence`: aliased to `session`; no narrative engine with cross-step dependencies yet. + +## `who` is heuristic + +`who <field_name>` infers the owning account type via a snake_case → PascalCase fallback against the IDL. Programs with non-conventional naming will miss matches. `who <AccountType>` and `who <ix_name>` use exact lookups and are reliable. + +## `time-warp` is one-way for `slot` + +`time-warp <delta>` advances `unix_timestamp` linearly for both positive and negative deltas. The `slot` counter only moves forward; negative deltas do not rewind it. Programs that key off `slot` rather than `unix_timestamp` may see inconsistent values after a backward warp. + +## `time-warp` is not rewound by `back` + +`back` drops the last step and rewinds the VM to the pre-call snapshot of that step, but `time-warp` is a separate side effect on the `Clock` sysvar and is **not** undone. The auditor must reset the clock manually with an inverse `tw` if a later step expects a specific timestamp. + +## `save` / `load` regenerates keypairs by default + +Without `--with-keypairs`, `save` does not embed the test keypairs. On `load`, a fresh keypair is generated for each user; any PDA derived from a signer pubkey resolves to a different address than the original session. + +Pass `save <name> --with-keypairs` to opt into deterministic reload. The resulting JSON contains plaintext keypairs and must not be committed to public repositories. The CLI prints a reminder at both save and load time. + +## Legacy `load` without `call_payload` + +LoadSession is best-effort for legacy saves missing the `call_payload` field. The timeline restores, but the VM stays at genesis (no replay). + +## CPI visibility in the UI + +Cross-program CPI calls are exercised correctly by the VM and surface in logs, but the web canvas does not yet have a dedicated visualisation for CPI edges. See [Solana roadmap](../roadmap/solana.md) for the dedicated CPI view. + +## Flat bipartite CFG visual + +The web canvas renders Solana state as a flat bipartite graph (instructions ↔ accounts). The Solidity-style CFG visual is not implemented; See [Solana roadmap](../roadmap/solana.md) for CFG visual parity. + +## Related pages + +- [Roadmap: Solana Phase 2](../roadmap/solana.md) +- [Solidity limitations](../solidity/limitations.md) +- [Reference: HTTP API](../reference/api-endpoints.md) diff --git a/docs/guide/src/solana/overview.md b/docs/guide/src/solana/overview.md new file mode 100644 index 0000000..00ff96a --- /dev/null +++ b/docs/guide/src/solana/overview.md @@ -0,0 +1,53 @@ +# Solana Backend Overview + +The Solana backend runs Anchor programs against a LiteSVM-backed engine. Every `call` runs in the VM, so the auditor sees real compute units, real logs, and real account state. Static control-flow analysis is not available yet; anything that requires it (`slice`, `trace`, `sequence` narrative) is deferred to Phase 2 (see [Roadmap](../roadmap/solana.md)). + +Two CLI entry points cover Solana: `ilold explore <project>` (REPL + API) and `ilold serve <project>` (API only). Both auto-detect the Solana backend when the path resolves to an Anchor workspace. + +## Project layout the loader expects + +``` +<root>/ + Anchor.toml + idls/ + <program>.json # Anchor IDL (required) + target/deploy/<program>.so # compiled program (or bin/<program>.so) +``` + +`crates/ilold-solana-core/src/ingest` resolves these paths. Without the `.so`, IDL navigation (`f`, `i`, `pda`, `vars`, `who`) still works; everything that drives the VM (`call`, `state`, `inspect`, `timeline`) fails until the program is compiled. + +The committed fixtures live under `tests/fixtures/solana/staking` (single program) and `tests/fixtures/solana/cpi` (two programs that talk to each other through CPI). Both ship pre-built `bin/<program>.so` binaries so the suite runs without the Anchor toolchain. + +## Solidity vs Solana mental model + +| Concept | Solidity | Solana | +| --- | --- | --- | +| Entry point | function on a contract | instruction on a program | +| Persistent state | contract state variables | accounts owned by the program | +| Caller identity | `msg.sender` (implicit) | signers passed by the client | +| `who <X>` | reads/writes of a state variable (CFG-based) | instructions that touch an account type (IDL heuristic) | +| `timeline <X>` | mutation history of a state variable | mutation history of an account pubkey, decoded | +| `step <i>` | re-renders the persisted flow tree | re-prints CU, logs, account diffs | +| `slice` / `trace` | full CFG-based analysis | not implemented (Phase 2) | +| `sequence` | narrative with cross-step dependencies | aliased to `session` (no narrative engine yet) | +| Execution | symbolic (CFG + paths) | concrete (in-process LiteSVM execution) | +| `back` | drops the step from the timeline | drops the step AND rewinds the VM to the pre-call snapshot | +| `save` / `load` | step list + persisted paths | step list + replay-driven VM reconstruction | + +## REPL command groups + +The REPL command surface mirrors the Solidity one with backend-specific extensions. Each group has its own page: + +- [Session](./repl/session.md): `c/call`, `b/back`, `cl/clear`, `s/session`, `state`, `st/step`. +- [Programs and IDL](./repl/programs.md): `ct/programs`, `use`, `f/funcs`, `fa/funcs-all`, `i/info`, `v/vars`, `va/vars-all`. +- [Solana runtime](./repl/runtime.md): `users`, `airdrop`, `tw/time-warp`, `pda`, `inspect`. +- [Analysis](./repl/analysis.md): `who`, `tl/timeline`, `cp/coupling`, `cov/coverage`. +- [Findings](./repl/findings.md): `fi/finding`, `n/note`, `status`, `fl/findings`, `ex/export`. +- [Scenarios](./repl/scenarios.md): `sc/scenario` (`new`, `list`, `switch`, `fork`, `delete`). +- [Workspace](./repl/workspace.md): `save`, `load`, `browser`. +- [Help and control](./repl/help.md): `?/help`, `<cmd>?`, `q/quit/exit`, `seq` (aliased to `session`). + +## Workflows + +- [Audit walkthrough](./workflows/audit-walkthrough.md): staking program end-to-end, paralleling the Solidity walkthrough. +- [Scenarios and forks](./workflows/scenarios.md): branching VMs, rewinding the clock, persisting bundles. diff --git a/docs/guide/src/solana/repl/analysis.md b/docs/guide/src/solana/repl/analysis.md new file mode 100644 index 0000000..6b1b72d --- /dev/null +++ b/docs/guide/src/solana/repl/analysis.md @@ -0,0 +1,131 @@ +# Analysis Commands + +The Solana analysis surface is partial: there is no static CFG yet, so `slice`, `trace`, and the dedicated `sequence` narrative are not implemented (`sequence` is aliased to `session`). The following commands are available today: + +| Command | Status | What it reads | +| --- | --- | --- | +| `who <query>` | works | IDL: account types, instructions, struct fields | +| `timeline <pubkey>` | works | account diffs accumulated by the active scenario | +| `coupling` | works | IDL + accounts metadata | +| `coverage` | works | runtime metrics over the active scenario | +| `slice` / `trace` | not implemented | requires the Anchor handler AST (Phase 2) | + +## who + +`who <AccountType | ix_name | field_name>` + +Resolves a query against the IDL. The same command answers three different questions depending on the input. + +**Account type**: list instructions that reference accounts of that type. The lookup is case-insensitive with a snake_case → PascalCase fallback, so `who pool` and `who Pool` both work. + +``` +ilold[staking]> who Pool + · 'Pool' (account type) + fields: admin: Pubkey, reward_rate: u64, total_staked: u64, last_update_ts: i64 + + Referenced by 5 instructions: + + · initialize_pool (as pool) writable + args: reward_rate: u64 + · stake (as pool) writable + args: amount: u64 + · unstake (as pool) writable + args: amount: u64 + · add_rewards (as pool) writable + args: amount: u64 + · claim_rewards (as pool) writable + args: (none) +``` + +**Instruction**: list accounts the instruction touches, plus its args and discriminator: + +``` +ilold[staking]> who claim_rewards + · 'claim_rewards' (instruction) + args: (none) + discriminator 0xa1b2c3... + + Touches 4 accounts: + + · pool (Pool) writable + · user_stake (UserStake) writable + · user signer + · reward_vault writable +``` + +**Field**: identify the owning type and the instructions that write the owner account (heuristic without source-level analysis we cannot tell which writer actually mutates this field). + +``` +ilold[staking]> who total_staked + · 'total_staked' (field of Pool, type u64) + Pool struct: admin: Pubkey, reward_rate: u64, total_staked: u64, last_update_ts: i64 + + Heuristic: the following instructions write the owner account. + Without source-level analysis we cannot tell which one(s) + actually mutate this field; cross-check with `step <idx>`. + + · stake (as pool) writable + · unstake (as pool) writable +``` + +**Returns:** `WhoList { account_type, instructions, query_kind, field_owner, field_type, owner_fields, ix_args, ix_discriminator_hex, ix_accounts }`. `query_kind` is one of `AccountType`, `Field`, `Instruction`, `NotFound`. + +See also: [`info`](./programs.md#info), [`funcs`](./programs.md#functions), [`vars`](./programs.md#vars), [`coupling`](#coupling). + +## timeline + +`tl <pubkey>` or `timeline <pubkey>` + +Shows the cross-step mutation history of an account, decoded. The pubkey can be a named keypair, a named PDA, or a raw base58 string. + +``` +ilold[staking → initialize_pool → stake]> tl pool + · timeline for pool (7XzG…ABCd) + · #0 initialize_pool (main) data + {"admin":"AdminPubkey…","reward_rate":10,"total_staked":0,"last_update_ts":0} + · #1 stake (main) data + {"admin":"AdminPubkey…","reward_rate":10,"total_staked":1000,"last_update_ts":1714060800} +``` + +**Returns:** `TimelineView { pubkey, label, entries: [{ step_index, instruction, scenario, lamports_delta, data_changed, before_decoded, after_decoded }] }`. + +## coupling + +`cp` or `coupling` + +Lists instruction pairs that share a writable account. Surfaces instructions that may interfere through shared writable state (ProgramView heuristic). + +``` +ilold[staking]> coupling + · stake ↔ unstake [pool, user_stake] + · stake ↔ claim_rewards [pool, user_stake] + · add_rewards ↔ claim_rewards [pool] +``` + +**Returns:** `CouplingList { pairs: [{ a, b, shared_writable: [..] }] }`. + +## coverage + +`cov` or `coverage` + +Aggregated runtime metrics over the active scenario: calls, failures, CU stats, CPI edges (RuntimeOverlay). + +``` +ilold[staking → initialize_pool → stake]> cov + Coverage for program staking (scenario main) + + Instruction Calls Failed CU avg CU max CPIs + initialize_pool 1 0 12400 12400 0 + stake 1 0 18700 18700 0 + + Total: 2 calls, 0 failed +``` + +**Returns:** `Coverage { overlay: { program, scenario, calls_per_ix, failed_per_ix, cu_stats_per_ix, cpi_edges } }`. + +Coverage is the closest current surrogate for "have I exercised every instruction?": it makes it easy to spot instructions never called, instructions that always fail, and programs reached only through CPI. + +## Notes + +- See [Solana: Limitations](../limitations.md) for the static-analysis gap (no CFG → no `slice` / `trace` yet). +- The Solidity counterparts of `who` and `timeline` work on state variables; the Solana versions work on account types and pubkeys respectively. The mental shift is the same ("what touches this piece of state?") but the units of state are different. diff --git a/docs/guide/src/solana/repl/findings.md b/docs/guide/src/solana/repl/findings.md new file mode 100644 index 0000000..2187d29 --- /dev/null +++ b/docs/guide/src/solana/repl/findings.md @@ -0,0 +1,106 @@ +# Findings Commands + +Findings, notes, and per-instruction status flags are recorded against the active scenario and aggregated by `export`. The Solana export adds runtime metadata (CU, logs, account diffs) to each step in the report. + +## finding + +`fi <severity> <title>` or `finding <severity> <title>` (alias: `fi`) + +Records a security finding tied to the latest step of the active scenario. + +Flags: + +| Flag | Description | +| --- | --- | +| `--rec="..."` | Optional remediation recommendation. Quote it if it contains spaces. | + +Valid severities: `critical`, `high`, `medium`, `low`, `info`. + +``` +ilold[staking → … → stake]> fi high reentrancy via stake + ✓ finding F-001 +``` + +``` +ilold[staking → … → claim_rewards]> finding critical missing signer --rec="require admin signature" + ✓ finding F-002 +``` + +**Returns:** `FindingAdded { id }`. + +## findings + +`fl` or `findings` + +Lists every finding recorded in the active scenario, with severity, title, the step it is attached to, and the optional remediation. + +``` +ilold[staking]> fl + F-001 high [2026-05-09T10:12:00Z] reentrancy via stake + F-002 critical [2026-05-09T10:14:00Z] missing signer + require admin signature +``` + +**Returns:** `FindingsList { items: [{ id, severity, title, description, created_at }] }`. + +## note + +`n <text>` or `note <text>` + +Attaches a free-form annotation to the active scenario. Notes are stored alongside findings and surface in the exported report. + +``` +ilold[staking → … → stake]> n suspicious admin path here + ✓ note recorded +``` + +**Returns:** `NoteAdded`. + +## status + +`status <ix> <open | reviewed | finding>` + +Sets the review status of an instruction. Useful for tracking audit progress. + +``` +ilold[staking]> status stake reviewed + ✓ status updated +ilold[staking]> status claim_rewards finding + ✓ status updated +``` + +Note: Solana statuses are intentionally narrower than the Solidity equivalent: only `open`, `reviewed`, `finding` (alias `found`). Solidity supports `reviewed`, `suspicious`, `vulnerable`, `clean`, `inprogress`, `notreviewed`. + +**Returns:** `StatusUpdated`. + +## export + +`ex` or `export` + +Generates a Markdown deliverable aggregating audit metadata, severity matrix, methodology, findings (with step index, recommendation, and runtime metadata) and per-scenario step lists across **all** scenarios. + +Flags: + +| Flag | Description | +| --- | --- | +| `--auditor=<name>` | Auditor identity in the report metadata | +| `--version=<v>` | Project version pinned in the report | +| `--date=<YYYY-MM-DD>` | Audit date override (defaults to today) | + +``` +ilold[staking]> export + ✓ markdown report (4321 bytes) + + # ilold audit report + ... + +ilold[staking]> export --auditor="Alba S." --version=v1.2 --date=2026-05-09 + ✓ markdown report (4567 bytes) +``` + +**Returns:** `Exported { markdown, bytes }`. The CLI prints the full Markdown body after the header line. + +## Notes + +- Findings are scoped to the scenario they were recorded in but the export merges all of them. +- The Solidity equivalent (see [Solidity: Findings](../../solidity/repl/findings.md)) does not support the `--rec=`, `--auditor=`, `--version=`, `--date=` flags; the report there is simpler. diff --git a/docs/guide/src/solana/repl/help.md b/docs/guide/src/solana/repl/help.md new file mode 100644 index 0000000..cf2adaa --- /dev/null +++ b/docs/guide/src/solana/repl/help.md @@ -0,0 +1,78 @@ +# Help and Control + +These commands print the command menu, the structured help block for an individual command, or exit the REPL. + +## help + +`?`, `h`, or `help` + +Prints the top-level command menu grouped by category (Session, Programs, Solana runtime, Analysis, Findings, Workspace). Hint at the bottom reminds the auditor that appending `?` to any command prints the full reference block for that command. + +``` +ilold[staking]> ? + + ilold explore — append ? to any command for inline help (e.g. sl?) + + Session + c | call <ix> arg=val acc=user Concise: keys auto-distributed; signers auto from IDL + b | back Remove last step from active scenario + cl | clear Reset active scenario steps + | state Decoded view of accounts mutated this session + s | session Active scenario summary (steps + findings) + ... +``` + +## inline help + +`<command>?` + +Renders the structured help block for that command: purpose, syntax, flags, examples, return shape, and related commands. The block lives in `crates/ilold-cli/src/help.rs::SOLANA_HELP_BLOCKS` and is the canonical reference for every Solana command. + +``` +ilold[staking]> call? + + c | call + + Purpose + Run an Anchor instruction against the LiteSVM and append the result as + a step on the active scenario. + + Syntax + c <ix> arg=val acc=user Concise key=value form (signers auto-resolved from IDL) + c <ix> {json} Full JSON form: {"args":{...},"accounts":{...},"signers":[...]} + + Flags + --signer=a,b Add signers (override IDL defaults) + --no-signer=name Remove a default signer (test negative cases) + + Examples + c stake amount=1000 pool=pool user_stake=alice_stake user=alice + c initialize_pool reward_rate=10 pool=pool admin=admin + c stake {"args":{"amount":1000},"accounts":{"pool":"pool", ...}} + + Returns + StepAdded { step_index, instruction, logs_excerpt, account_diffs_count, compute_units } + on success, or CallFailed { ... } when the VM rejects. + + See also + info, pda, state, step, back +``` + +Lookup is case-insensitive: `CALL?`, `call?`, and `c?` all return the same block. + +## sequence + +`seq` or `sequence` + +Solana has no dedicated cross-step narrative engine yet (Phase 2, see [Roadmap](../../roadmap/solana.md)). `seq` is aliased to `session`: it prints the active scenario summary so an auditor who reaches for `seq` out of habit still gets a useful view. + +## quit + +`q`, `quit`, or `exit` + +Exits the REPL. `Ctrl+D` and `Ctrl+C` also work. + +## Notes + +- The full list of registered command aliases is enforced by the test `every_solana_command_has_a_help_block` in `crates/ilold-cli/src/help.rs`. New commands without a corresponding HelpBlock break the build. +- The Solidity REPL uses a flat one-line inline-help table (see `print_inline_help`); Solana uses the structured HelpBlock format above. Both respond to the `<cmd>?` trailing syntax. diff --git a/docs/guide/src/solana/repl/programs.md b/docs/guide/src/solana/repl/programs.md new file mode 100644 index 0000000..57df9a2 --- /dev/null +++ b/docs/guide/src/solana/repl/programs.md @@ -0,0 +1,120 @@ +# Programs and IDL Commands + +These commands inspect the static surface of the active program: instruction list, instruction detail, account types, and project-level navigation. + +## programs / contracts + +`ct` or `programs` (aliases: `contracts`, `progs`) + +Lists every program detected in the workspace. Multi-program Anchor workspaces (e.g. `tests/fixtures/solana/cpi`) show one entry per program. + +``` +ilold[staking]> ct + staking ← current + reward_oracle +``` + +## use + +`use <program>` + +Switches the active program. Subsequent commands target the new program; the prompt label updates. + +``` +ilold[staking]> use reward_oracle + ✓ now using reward_oracle +``` + +## functions + +`f` or `funcs` (alias: `functions`) + +Lists the instructions exposed by the active program, with arg / account counts and signer names. PDA-bearing instructions are tagged `[PDA]`. + +``` +ilold[staking]> f + [PDA] initialize_pool (args:1 accounts:3) signers: admin + [PDA] stake (args:1 accounts:4) signers: user + [PDA] unstake (args:1 accounts:4) signers: user + [ix] add_rewards (args:1 accounts:2) signers: admin + [PDA] claim_rewards (args:0 accounts:4) signers: user +``` + +**Returns:** `InstructionList { items: [InstructionEntry { name, args_count, accounts_count, has_pdas, signers }] }`. + +## funcs-all + +`fa` or `funcs-all` + +Currently identical to `funcs`: both dispatch to the same `Funcs` command and return `InstructionList`. The admin-gating and coupling hints surface separately via [`info`](#info) (`admin_gated` flag) and [`coupling`](./analysis.md#coupling). + +``` +ilold[staking]> fa + [PDA] initialize_pool (args:1 accounts:3) signers: admin + [PDA] stake (args:1 accounts:4) signers: user + ... +``` + +**Returns:** `InstructionList { items: [...] }`: same shape as `funcs`. + +## info + +`i <ix>` or `info <ix>` + +Full detail of an instruction: discriminator, typed args, accounts with their `signer` / `writable` / `optional` flags and kind (`system`, `sysvar`, `program`, `pda`, `other`), declared PDAs with their seeds, and the admin-gating heuristic. + +``` +ilold[staking]> i stake + instruction stake + discriminator 0xa1b2c3... + + args (1) + · amount u64 + + accounts (4) + · pool other writable + · user_stake pda writable + · user other signer writable + · system_program program const 11111111111111111111111111111111 + + pdas (1) + · user_stake seeds=["user-stake", user] program=self + + admin_gated false +``` + +**Returns:** `IxInfo { ix: IxView, admin_gated: bool }`. `IxView` carries `name`, `discriminator_hex`, `args`, `accounts`, and per-account `pda` metadata. + +See also: [`funcs-all`](#funcs-all), [`pda`](./runtime.md#pda), [`who`](./analysis.md#who), [`call`](./session.md#call). + +## vars + +`v` or `vars` + +Lists the account types declared in the IDL, with their Anchor discriminator and field layout. Solana does not split `vars` / `vars-all`: both alias to the same `Vars` command. + +``` +ilold[staking]> v + [T] Pool 0x4e2a... + · admin Pubkey + · reward_rate u64 + · total_staked u64 + · last_update_ts i64 + [T] UserStake 0x8c91... + · user Pubkey + · amount u64 + · reward_debt u64 +``` + +**Returns:** `AccountTypes { accounts: [AccountView { name, discriminator_hex, fields: [FieldView { name, ty }] }] }`. + +## vars-all + +`va` or `vars-all` + +Aliased to `vars` at the dispatcher level: same command, same output. + +## Notes + +- The Solidity counterpart of these commands is documented under [Contract](../../solidity/repl/contract.md). The shapes line up so an auditor moving between backends sees the same structure. +- `use` clears the displayed step list for the previous program. The underlying scenario state for that program is preserved and reappears when switching back. diff --git a/docs/guide/src/solana/repl/runtime.md b/docs/guide/src/solana/repl/runtime.md new file mode 100644 index 0000000..42f781e --- /dev/null +++ b/docs/guide/src/solana/repl/runtime.md @@ -0,0 +1,94 @@ +# Solana Runtime Commands + +These commands operate directly on the LiteSVM owned by the active scenario. They have no Solidity counterpart. + +## users + +`users`: list keypairs in the active scenario. + +`users new <name> [lamports]`: create a keypair and airdrop it. Default airdrop is `10_000_000_000` lamports (10 SOL). + +``` +ilold[staking]> users new alice + ✓ user alice created at 8H7…Pq with 10000000000 lamports + +ilold[staking]> users new bob 5000000000 + ✓ user bob created at 6Yg…Tx with 5000000000 lamports + +ilold[staking]> users + [U] alice 8H7…Pq 10000000000 lamports + [U] bob 6Yg…Tx 5000000000 lamports +``` + +**Returns:** `UserList { users: [{ name, pubkey, lamports }] }` on listing, `UserCreated { name, pubkey, lamports }` on `users new`. + +## airdrop + +`airdrop <user> <lamports>` (alias: `air`) + +Tops up an existing keypair with extra lamports. + +``` +ilold[staking]> airdrop alice 1000000000 + ✓ alice now 11000000000 lamports 8H7…Pq +``` + +**Returns:** `Airdropped { name, pubkey, total_lamports }`. + +## time-warp + +`tw <delta_seconds>` or `time-warp <delta_seconds>` + +Advances (or rewinds) the `Clock` sysvar so vesting / reward / lockup logic can be exercised. Positive deltas move forward, negative deltas move backward. + +``` +ilold[staking]> tw 86400 + ✓ clock now ts=1714147200 slot=12345 + +ilold[staking]> tw -3600 + ✓ clock now ts=1714143600 slot=12345 +``` + +**Returns:** `TimeWarped { unix_timestamp, slot }`. + +`time-warp` is a global side effect on the `Clock` sysvar and is **not** undone by `back`. It is the auditor's responsibility to reset the clock manually if a later test expects a specific timestamp. Negative deltas adjust `unix_timestamp` linearly but do not move the `slot` counter backwards. + +## pda + +`pda <ix>` + +Lists the PDAs declared by an instruction (Anchor seeds plus bump). Read directly from the IDL, no VM execution required. + +``` +ilold[staking]> pda stake + [PDA] user_stake seeds=["user-stake", user] program=self +``` + +**Returns:** `PdaList { instruction, pdas: [{ account_name, seeds, program }] }`. + +## inspect + +`inspect <pubkey>` (alias: `acc`) + +Reads an account from the VM and decodes it via the Anchor discriminator. The pubkey can be a named keypair, a named PDA from a previous step, or a raw base58 string. + +``` +ilold[staking]> inspect alice + 8H7…Pq owner=11111111111111111111111111111111 lamports=10000000000 data_len=0 + +ilold[staking]> inspect 6Yg7... + 6Yg7…ABCd owner=StakingProgram… lamports=2039280 data_len=72 + { + "admin": "AdminPubkey…", + "reward_rate": 10, + "total_staked": 1000, + "last_update_ts": 1714060800 + } +``` + +**Returns:** `AccountInspected { pubkey, owner, lamports, data_len, decoded }`. `decoded` is `Some(Value)` when the Anchor discriminator matches a known account type, otherwise `None`. + +## Notes + +- All runtime commands are scoped to the **active scenario**. Forks own their own VM and their own keypair set; switching scenarios swaps the runtime. +- `inspect` is the easiest way to confirm what `state` and `timeline` will surface for a given account. diff --git a/docs/guide/src/solana/repl/scenarios.md b/docs/guide/src/solana/repl/scenarios.md new file mode 100644 index 0000000..a495cbd --- /dev/null +++ b/docs/guide/src/solana/repl/scenarios.md @@ -0,0 +1,80 @@ +# Scenario Commands + +Scenarios are independent branches of the session. On Solana, **each scenario owns its own VM and its own keypair set**: a fork carries the parent's VM state up to the fork step, then diverges. This is the core mechanism for testing "what happens if step 2 fails differently?" without losing the original timeline. + +## scenario new + +`sc new <name>` or `scenario new <name>` + +Creates an empty scenario with a fresh VM (the program is reloaded, no users yet). The new scenario is not activated automatically. + +``` +ilold[staking]> sc new attack + ✓ scenario attack created +``` + +**Returns:** `ScenarioCreated { name }`. + +## scenario list + +`sc list` (aliases: `sc ls`, `scenario list`, bare `sc`) + +Lists every scenario in the active session with the active marker and step count. + +``` +ilold[staking]> sc list + [S] main (3 steps) ← active + [S] attack (0 steps) +``` + +**Returns:** `ScenarioList { items: [ScenarioInfo { name, step_count, active }] }`. + +## scenario switch + +`sc switch <name>` + +Activates an existing scenario. The VM, keypairs, and step list are swapped to that scenario's state. + +``` +ilold[staking]> sc switch attack + → main → attack +ilold[staking/attack]> +``` + +**Returns:** `ScenarioSwitched { from, to }`. + +## scenario fork + +`sc fork <name> [step]` + +Creates a new scenario branching from the active one. With `[step]`, the new scenario inherits steps `0..step` and the VM is rewound to the **pre-call snapshot** of that step. Without `[step]`, the fork inherits the full step list and the VM state at HEAD. The new scenario is activated after forking. + +``` +ilold[staking → … → claim_rewards]> sc fork attack-v2 1 + ✓ forked main → attack-v2 at step 1 +ilold[staking/attack-v2 → initialize_pool]> +``` + +**Returns:** `ScenarioForked { from, to, at_step }`. + +The fork keeps the parent's keypair definitions so PDAs derived from them resolve to the same addresses (provided you opt into deterministic keypairs via `save --with-keypairs`; see [Workspace](./workspace.md)). + +## scenario delete + +`sc delete <name>` (aliases: `sc rm <name>`) + +Removes a scenario. The active scenario cannot be deleted; switch first. + +``` +ilold[staking]> sc delete attack + ✓ scenario attack deleted +``` + +**Returns:** `ScenarioDeleted { name }`. + +## Notes + +- `back` and `clear` rewind the VM of the **active scenario only**, never the fork's parent. Diverging via `fork` is the only way to keep both timelines side by side. +- `time-warp` is a per-scenario side effect on the `Clock` sysvar, but is not reverted by `back`. +- See [Solana: Scenarios and forks](../workflows/scenarios.md) for an end-to-end workflow. +- The Solidity counterpart is documented at [Solidity: Scenarios](../../solidity/repl/scenarios.md); the command surface is identical, but Solidity scenarios share the parsed model (no VM clone needed). diff --git a/docs/guide/src/solana/repl/session.md b/docs/guide/src/solana/repl/session.md new file mode 100644 index 0000000..ff2c3a6 --- /dev/null +++ b/docs/guide/src/solana/repl/session.md @@ -0,0 +1,134 @@ +# Session Commands + +Session commands drive the active scenario: append a call, rewind, inspect what changed. Every state-changing command updates the LiteSVM as a side effect; `back` and `clear` rewind the VM to the corresponding pre-step snapshot. + +## call + +`c <ix> [arg=val ...] [account=user_or_pubkey ...]` or `call <ix> {json}` (alias: `c`) + +Runs an Anchor instruction against the VM and appends the result to the active scenario. Two payload forms are supported: + +- **Concise key=value form**: positional `arg=value` and `account_field=user_name` tokens. Unmapped names become local keypairs. Signers are auto-resolved from the IDL. +- **JSON form**: `{"args": {...}, "accounts": {...}, "signers": [...]}` for full control. + +Flags: + +| Flag | Description | +| --- | --- | +| `--signer=a,b` | Add signers on top of the IDL defaults | +| `--no-signer=name` | Remove a default signer (for negative cases) | + +``` +ilold[staking]> c initialize_pool reward_rate=10 pool=pool admin=admin + ✓ step 0 [ok]: initialize_pool (12400 CU, 1 diffs) +``` + +``` +ilold[staking → initialize_pool]> c stake amount=1000 pool=pool user_stake=alice_stake user=alice + ✓ step 1 [ok]: stake (18700 CU, 2 diffs) +``` + +``` +ilold[staking]> c stake {"args":{"amount":1000},"accounts":{"pool":"pool","user_stake":"alice_stake","user":"alice"}} +``` + +When the VM rejects the call (Anchor constraint, custom `require!`, etc.) no step is appended and the CLI prints the error inline: + +``` +ilold[staking]> c stake amount=0 pool=pool user_stake=alice_stake user=alice + ✗ FAILED: stake (4200 CU, not recorded) + error: AnchorError: Amount must be > 0 +``` + +**Returns:** `StepAdded { step_index, instruction, logs_excerpt, account_diffs_count, compute_units, error }` on success, `CallFailed { instruction, logs_excerpt, compute_units, error }` when the VM rejects. + +See also: [`info`](./programs.md#info), [`pda`](./runtime.md#pda), [`state`](#state), [`step`](#step), [`back`](#back). + +## back + +`b` or `back` + +Removes the last step from the active scenario and rewinds the VM to the pre-call snapshot of that step. Re-issuing the same `call` after `back` produces fresh CU and diffs. + +``` +ilold[staking → initialize_pool → stake]> b + ✓ step undone (1 remaining) +``` + +**Returns:** `StepRemoved { remaining }`. + +`time-warp` is a global side effect on the `Clock` sysvar and is **not** undone by `back`. Reset the clock manually if a test relies on a specific timestamp. + +## clear + +`cl` or `clear` + +Drops every step in the active scenario and rewinds the VM to the genesis snapshot of that scenario. + +``` +ilold[staking → initialize_pool → stake]> cl + ✓ session cleared +``` + +**Returns:** `Cleared`. + +## state + +`state` + +Decoded view of every account mutated during the active scenario. Each entry shows the pubkey, the owning program, and the decoded fields from the latest step. + +``` +ilold[staking → initialize_pool → stake]> state + [A] pool (2039280 lamports) 7XzG…ABCd + admin AdminPubkey… + reward_rate 10 + total_staked 1000 + last_update_ts 1714060800 + [A] alice_stake (1559040 lamports) 6Hj…Pq + user AlicePubkey… + amount 1000 + reward_debt 0 +``` + +**Returns:** `StateView { accounts: [AccountSummary { pubkey, label, lamports, decoded }] }`. `decoded` is the Anchor-decoded JSON snapshot, `None` for accounts whose discriminator does not match a known type. + +## session + +`s` or `session` + +Prints the active scenario summary: ordered steps, findings, notes, and the current scenario name. + +``` +ilold[staking → initialize_pool → stake]> s + program=staking scenario=main steps=2 findings=0 + 0. initialize_pool + 1. stake +``` + +**Returns:** `SessionView { program, scenario, steps, findings_count }`. + +## step + +`st <index>` or `step <index>` (no-space shortcut: `st0`, `step1`) + +Re-inspects a specific step of the active scenario, printing the persisted CU, logs, and decoded account diffs. + +``` +ilold[staking → initialize_pool → stake]> step 1 + · step 1 · stake + compute units: 18700 + logs: (4 lines) + diffs (2): + pool (Pool) + total_staked 0 → 1000 + alice_stake (UserStake) + amount — → 1000 +``` + +**Returns:** `StepDetail { step_index, instruction, runtime_trace, diff_summary }`. `runtime_trace` is a JSON blob carrying `compute_units`, `logs`, and (when present) `error`; `diff_summary` is a list of `{ address, name, lamports_delta, data_changed, decoded_before, decoded_after }`. + +## Notes + +- The Solidity equivalent (`seq` / `sequence`) is currently aliased to `session` on Solana; there is no cross-step narrative engine yet. See [Roadmap](../../roadmap/solana.md). +- `call` is the only command that drives the VM forward; everything else inspects or rewinds. diff --git a/docs/guide/src/solana/repl/workspace.md b/docs/guide/src/solana/repl/workspace.md new file mode 100644 index 0000000..d0fe144 --- /dev/null +++ b/docs/guide/src/solana/repl/workspace.md @@ -0,0 +1,64 @@ +# Workspace Commands + +Workspace commands handle persistence and external tooling. On Solana, `save` / `load` cover not just the step list but the entire scenario tree, including runtime traces, findings, fork origins, and the original call payloads needed to replay each call against a fresh VM. + +## save + +`save <name>` + +Serialises the active session to `~/.ilold/sessions/<name>.json`. + +Flags: + +| Flag | Description | +| --- | --- | +| `--with-keypairs` | Bundle plaintext test keypairs for deterministic reload. Do NOT commit the resulting file. | + +``` +ilold[staking]> save reentrancy-attack + ✓ session JSON (8421 bytes) + +ilold[staking]> save reentrancy-attack --with-keypairs + ✓ session JSON (9532 bytes) +``` + +**Returns:** `SessionSaved { json }`. The CLI prints the JSON byte count. The file is written by the dispatch layer; warnings about bundled keypairs come from the surrounding CLI flow, not the result variant itself. + +Without `--with-keypairs`, `load` regenerates user keypairs and any PDA derived from a signer pubkey will resolve to different addresses on reload. With the flag, the JSON embeds the keypairs in plaintext so the next `load` reproduces the exact same pubkeys. + +## load + +`load <name>` + +Reads `~/.ilold/sessions/<name>.json`, boots a fresh VM per scenario, re-airdrops the in-memory users, and replays each call from the persisted payload. + +``` +ilold[staking]> load reentrancy-attack + ✓ loaded program=staking steps=3 +``` + +**Returns:** `SessionLoaded { program, steps }` where `steps` is the list of instruction names that were replayed. + +The reconstructed VM state matches the saved snapshot for typical Anchor flows. Programs with non-deterministic behaviour or balances above the default replay cap may diverge; see [Solana: Limitations](../limitations.md). + +## browser + +`browser` + +Prints the base URL of the local HTTP API. The web canvas (`crates/ilold-web/frontend`) subscribes to the same `/api` and `/ws` endpoints, so anything the REPL runs surfaces there live. + +``` +ilold[staking]> browser + · Web UI not yet available in explore mode. + · API running at http://127.0.0.1:8080/api/ +``` + +See [HTTP API Reference](../../reference/api-endpoints.md) for the full surface and [WebSocket events](../../reference/websocket.md) for the live update stream. + +## quit + +`q`, `quit`, or `exit` + +Exits the REPL. `Ctrl+D` and `Ctrl+C` also work. + +Unsaved scenarios are lost on exit. Use `save` before quitting if the session needs to survive. diff --git a/docs/guide/src/solana/workflows/audit-walkthrough.md b/docs/guide/src/solana/workflows/audit-walkthrough.md new file mode 100644 index 0000000..2dd6411 --- /dev/null +++ b/docs/guide/src/solana/workflows/audit-walkthrough.md @@ -0,0 +1,185 @@ +# Solana Audit Walkthrough + +This walkthrough mirrors the [Solidity audit walkthrough](../../solidity/workflows/audit-walkthrough.md) against the canonical Solana staking fixture under `tests/fixtures/solana/staking`. The program exposes five instructions — `initialize_pool`, `stake`, `unstake`, `add_rewards`, `claim_rewards` — and ships with a pre-built `bin/staking.so` so the suite runs without the Anchor toolchain. + +## Starting the session + +Launch ilold against the Anchor workspace: + +``` +ilold explore tests/fixtures/solana/staking +``` + +The REPL boots a LiteSVM with `bin/staking.so` and auto-selects the program: + +``` +ilold[staking]> +``` + +List the instructions and account types to orient yourself: + +``` +ilold[staking]> f + initialize_pool args=1 accounts=3 signers=1 pdas=1 + stake args=1 accounts=4 signers=1 pdas=1 + unstake args=1 accounts=4 signers=1 pdas=1 + add_rewards args=1 accounts=2 signers=1 pdas=0 + claim_rewards args=0 accounts=4 signers=1 pdas=1 + +ilold[staking]> v + Pool disc=0x4e2a... + UserStake disc=0x8c91... +``` + +This is the starting map: five entry points, two account types. + +## Creating users and bootstrapping the pool + +Solana sessions need accounts to drive. Mint the keypairs you need: + +``` +ilold[staking]> users new admin 100000000 + ✓ admin pubkey=AdminPk… balance=0.1 SOL + +ilold[staking]> users new pool 2000000 +ilold[staking]> users new alice 50000000 +ilold[staking]> users new alice_stake 2000000 +``` + +`users new` creates a keypair and airdrops it. Unmapped account names in a later `call` will be coerced to local keypairs as well, but pre-creating them makes the session deterministic. + +Now initialize the pool: + +``` +ilold[staking]> call initialize_pool reward_rate=10 pool=pool admin=admin + + Step 0: initialize_pool CU 12.4k 1 account written +``` + +`call` runs the instruction in the VM and appends the result. The output lists CU consumed and the number of mutated accounts. + +## Staking and observing state + +``` +ilold[staking → initialize_pool]> call stake amount=1000 pool=pool user_stake=alice_stake user=alice + + Step 1: stake CU 18.7k 2 accounts written +``` + +Check the accumulated state: + +``` +ilold[staking → … → stake]> state + Pool 7XzG…ABCd + admin = AdminPk… + reward_rate = 10 + total_staked = 1000 + UserStake 6Hj…Pq + user = AlicePk… + amount = 1000 + reward_debt = 0 +``` + +## Inspecting a step in detail + +`step <i>` re-prints the persisted CU, logs, and decoded diffs for a specific step: + +``` +ilold[staking → … → stake]> step 1 + step 1 stake + CU 18.7k + accounts written: + Pool 7XzG…ABCd total_staked: 0 → 1000 + UserStake 6Hj…Pq amount: — → 1000 + logs: + Program log: Instruction: Stake + ... +``` + +## Cross-step questions + +`who` resolves a query against the IDL. Use it to find which instructions touch a given account type: + +``` +ilold[staking → … → stake]> who Pool + AccountType: Pool + Instructions: + initialize_pool accounts: pool [init, mut] + stake accounts: pool [mut] + unstake accounts: pool [mut] + add_rewards accounts: pool [mut] + claim_rewards accounts: pool [mut] +``` + +`timeline <pubkey>` shows how a specific account has evolved across the session, with decoded field diffs: + +``` +ilold[staking → … → stake]> timeline pool + Pool 7XzG…ABCd + step 0 initialize_pool + admin = AdminPk… (— → AdminPk…) + reward_rate = 10 (— → 10) + total_staked = 0 (— → 0) + step 1 stake + total_staked = 1000 (0 → 1000) +``` + +## Recording findings and exporting + +Capture an observation as a note and record a finding tied to the latest step: + +``` +ilold[staking → … → stake]> n staking does not enforce min stake amount + ✓ Note added + +ilold[staking → … → stake]> finding High "missing reentrancy guard" --rec="Apply checks-effects-interactions" + ✓ Finding F-001 added (High) +``` + +Mark instructions as you go: + +``` +ilold[staking]> status stake reviewed +ilold[staking]> status claim_rewards finding +``` + +Export the deliverable: + +``` +ilold[staking]> export --auditor="Demo Auditor" --version="v0.1.0" --date=2026-05-09 + ✓ Exported +``` + +## Persisting the session + +``` +ilold[staking]> save my-audit --with-keypairs + ✓ Saved to ~/.ilold/sessions/my-audit.json + ⚠ bundle includes plaintext test keypairs — do NOT commit it + +ilold[staking]> clear + Cleared 2 step(s). + +ilold[staking]> load my-audit + ⚠ bundle contains plaintext test keypairs — do NOT commit *.json files like this + ✓ Session loaded (2 steps) +``` + +`--with-keypairs` is mandatory when the audit relies on deterministic PDAs (which depend on signer pubkeys): without it, `load` regenerates fresh keypairs and PDAs come back at different addresses. + +## Parallel to the Solidity walkthrough + +The flow is structurally identical to the Solidity one: + +1. `f` / `v` to map the surface (instructions and account types instead of functions and state variables). +2. `users new` + `call` to push the VM forward (vs. `c <func>` on a parsed CFG). +3. `state`, `step`, `timeline` to inspect what changed. +4. `who` to navigate cross-instruction relationships (vs. cross-function in Solidity). +5. `finding`, `note`, `status` to record observations. +6. `export`, `save`, `load` to ship the deliverable and resume later. + +What is **not** available yet on Solana: `slice` and `trace`. Both require the Anchor handler AST and are tracked in [Roadmap: Solana Phase 2](../../roadmap/solana.md). + +## Related pages + +- [Session](../repl/session.md), [Programs and IDL](../repl/programs.md), [Solana runtime](../repl/runtime.md), [Analysis](../repl/analysis.md), [Findings](../repl/findings.md) +- [Scenarios and forks](./scenarios.md) +- [Solana: Limitations](../limitations.md) diff --git a/docs/guide/src/solana/workflows/scenarios.md b/docs/guide/src/solana/workflows/scenarios.md new file mode 100644 index 0000000..fe36370 --- /dev/null +++ b/docs/guide/src/solana/workflows/scenarios.md @@ -0,0 +1,114 @@ +# Scenarios and Forks + +Scenarios are the auditor's tool for asking "what if?" against a real VM without losing the current line of reasoning. This page walks through a forking session against `tests/fixtures/solana/staking` and shows how `back`, `clear`, `fork`, and `time-warp` interact. + +## Setup + +Boot the REPL with the staking fixture and set up the happy path: + +``` +ilold[staking]> users new admin 100000000 +ilold[staking]> users new pool 2000000 +ilold[staking]> users new alice 50000000 +ilold[staking]> users new alice_stake 2000000 + +ilold[staking]> call initialize_pool reward_rate=10 pool=pool admin=admin + + Step 0: initialize_pool CU 12.4k + +ilold[staking]> call stake amount=1000 pool=pool user_stake=alice_stake user=alice + + Step 1: stake CU 18.7k + +ilold[staking → initialize_pool → stake]> +``` + +## Branching with `scenario fork` + +Now diverge: what happens if a malicious caller tries `unstake` for more than the staked amount? + +``` +ilold[staking → initialize_pool → stake]> sc fork over-unstake 2 + ✓ Forked 'main' → 'over-unstake' at step 2 +ilold[staking/over-unstake → initialize_pool → stake]> +``` + +The fork inherits steps `0..2` from `main` and rewinds the VM to the snapshot taken just before step 2 would have executed. The new scenario is active. The `main` scenario keeps its original state untouched. + +Try the attack on the fork: + +``` +ilold[staking/over-unstake → initialize_pool → stake]> call unstake amount=999999 pool=pool user_stake=alice_stake user=alice + ✗ Step rejected CU 7.2k + error: Custom { code: 6001 } "InsufficientStake" +``` + +`CallFailed` is recorded as a step; the VM stays at the pre-call snapshot. + +## Rewinding with `back` + +If you want to retry, `back` drops the failed step **and** rewinds the VM: + +``` +ilold[staking/over-unstake → … → stake]> b + - Step removed. 2 remaining. + +ilold[staking/over-unstake → … → stake]> call unstake amount=500 pool=pool user_stake=alice_stake user=alice + + Step 2: unstake CU 16.1k +``` + +The replayed `call` produces fresh CU and diffs because the VM was rewound, not a cached replay. + +## Switching back to `main` + +``` +ilold[staking/over-unstake → … → unstake]> sc switch main +ilold[staking → initialize_pool → stake]> +``` + +`main` still has its original two steps; the fork's state never leaked. Each scenario carries its own VM, signers, and PDAs. + +## Time-warping vesting / reward logic + +`time-warp` advances the `Clock` sysvar. It is **scenario-local but step-independent**: `back` does not rewind the clock. Use it to exercise reward accrual: + +``` +ilold[staking → … → stake]> tw 86400 + ✓ Clock unix_timestamp += 86400 + +ilold[staking → … → stake]> call claim_rewards pool=pool user_stake=alice_stake user=alice + + Step 2: claim_rewards CU 22.3k +``` + +If a later step needs a different clock, undo the offset manually with `tw -86400`. + +## Persisting the scenario tree + +`save` and `load` cover the whole scenario tree, not just the active one. Use `--with-keypairs` whenever a PDA depends on a signer pubkey (most Anchor programs): + +``` +ilold[staking]> save staking-attack --with-keypairs + ✓ Saved to ~/.ilold/sessions/staking-attack.json + ⚠ bundle includes plaintext test keypairs — do NOT commit it + +ilold[staking]> clear +ilold[staking]> load staking-attack + ⚠ bundle contains plaintext test keypairs — do NOT commit *.json files like this + ✓ Session loaded (2 steps) +``` + +`load` reboots a fresh VM per scenario, re-airdrops the users, and replays each call. The final state matches the saved snapshot for typical Anchor flows. Non-deterministic programs may diverge, see [Solana: Limitations](../limitations.md). + +## Practical patterns + +| Pattern | Commands | +| --- | --- | +| "Keep the original timeline, try a divergent path" | `sc fork <name> <step>`, then continue with `call` | +| "Retry the last failed call" | `b`, edit args, `call` again | +| "Start over without losing other scenarios" | `cl` on the active scenario, then re-issue calls | +| "Reproduce later, same addresses" | `save <name> --with-keypairs`, `load <name>` | +| "Reproduce later, fresh randomness" | `save <name>`, `load <name>` | + +## Related pages + +- [Scenarios](../repl/scenarios.md): command reference. +- [Workspace](../repl/workspace.md): save/load details. +- [Solana: Limitations](../limitations.md): what survives `save`/`load` and what doesn't. diff --git a/docs/guide/src/solidity/cli-analyze.md b/docs/guide/src/solidity/cli-analyze.md new file mode 100644 index 0000000..7d57742 --- /dev/null +++ b/docs/guide/src/solidity/cli-analyze.md @@ -0,0 +1,74 @@ +# CLI: `analyze` + +`ilold analyze` parses a Solidity project, runs the full static-analysis pipeline (parser → CFG → path tree → sequence analysis with cross-contract transitive effects), and prints a structured summary to stdout. It is the one-shot view of what `explore` keeps in memory. + +## Synopsis + +``` +ilold analyze <path> [--contract <name>] [--max-seq-depth <N>] [--verbose] +``` + +| Flag | Default | Description | +| --- | --- | --- | +| `--contract <name>` | (all contracts) | Restrict output to a single contract | +| `--max-seq-depth <N>` | `3` | Depth bound for the sequence tree (call combinations up to N steps) | +| `--verbose` | off | Per-block CFG layout, call-graph edges, full function-behavior breakdown | + +`<path>` may be a single `.sol` file or a directory; the walker skips `out`, `cache`, `node_modules`, `lib`, `target`, and dot-prefixed directories. + +## Example: default output + +``` +$ ilold analyze tests/fixtures/staking.sol +Parsed 1 file(s), 2 contract(s) + +interface IERC20 (3 functions, 0 state vars) + [P] external transfer — 1 blocks, 0 edges, 0 paths (0 happy, 0 revert) + [P] external transferFrom — 1 blocks, 0 edges, 0 paths (0 happy, 0 revert) + [P] external balanceOf — 1 blocks, 0 edges, 0 paths (0 happy, 0 revert) + +contract Staking (9 functions, 11 state vars) + [S] internal constructor — 2 blocks, 1 edges, 1 paths (1 happy, 0 revert) + [P] external deposit — 8 blocks, 8 edges, 5 paths (2 happy, 3 revert) + [P] external withdraw — 8 blocks, 8 edges, 6 paths (2 happy, 4 revert) + [P] external claimRewards — 6 blocks, 7 edges, 4 paths (4 happy, 0 revert) + [R] external setRewardRate — 4 blocks, 3 edges, 2 paths (1 happy, 1 revert) + [R] external pause — 4 blocks, 3 edges, 2 paths (1 happy, 1 revert) + [R] external unpause — 4 blocks, 3 edges, 2 paths (1 happy, 1 revert) + [P] public rewardPerToken — 5 blocks, 4 edges, 2 paths (2 happy, 0 revert) + [P] public earned — 2 blocks, 1 edges, 1 paths (1 happy, 0 revert) + Sequences (depth 3): 584 total (8 functions: 6 state-changing, 2 read-only) +``` + +Each function line prints an access badge (`[P]` public/external, `[R]` restricted/admin-gated, `[S]` system/internal), visibility, block/edge counts from the CFG, and the path-tree breakdown (`total`, `happy`, `revert`). + +## Example: `--max-seq-depth` + +The sequence tree enumerates ordered combinations of entry-point calls up to depth N. Raising the bound surfaces longer interaction patterns that the static analyzer reasons about: + +``` +$ ilold analyze tests/fixtures/staking.sol --max-seq-depth 5 +... +Sequences (depth 5): 37448 total (8 functions: 6 state-changing, 2 read-only) +``` + +The sequence tree is consumed by the cross-step transitive-effect pass and feeds `seq` in the REPL. + +## Example: `--verbose` + +`--verbose` adds: + +- One line per CFG block (`[id] BlockKind (N stmts)`). +- One line per CFG edge (`src → dst EdgeKind`). +- The intra-contract call graph (`fn → contract.fn` with `internal | external | inherited`). +- A per-function behavior tree with `requires`, `writes`, `calls`, `emits`, and transitions to other functions, including the shared state variables that link them. + +The output is meant to be read top-down by a human; `context` (next page) is the machine-readable counterpart. + +## Notes + +- `analyze` does not require a configured project; it works on raw `.sol` files. +- Interfaces are listed in the contract header but have no sequence tree. +- Errors building the CFG for a single function are reported inline and do not abort the run. + +See [Solidity: Limitations](./limitations.md) for the analysis boundaries (intraprocedural slicing, modifier placeholder split, etc.). diff --git a/docs/guide/src/solidity/cli-context.md b/docs/guide/src/solidity/cli-context.md new file mode 100644 index 0000000..ef73f14 --- /dev/null +++ b/docs/guide/src/solidity/cli-context.md @@ -0,0 +1,67 @@ +# CLI: `context` + +`ilold context` produces a structured narrative for a single function or for a comma-separated sequence of functions. It runs the same pipeline as `analyze` (parser → CFG → path tree → sequence analysis with cross-contract transitive effects) but emits a focused narrative instead of the project-wide pretty-print. + +## Synopsis + +``` +ilold context <path> [--contract <name>] [--function <name>] + [--sequence <f1,f2,...>] [--list] +``` + +| Flag | Description | +| --- | --- | +| `--contract <name>` | Pick the active contract. Required when the project has more than one. | +| `--function <name>` | Build a function narrative: paths, state reads/writes, internal/external calls, transitive effects, observations. | +| `--sequence <f1,f2,...>` | Build a sequence narrative across the listed functions. | +| `--list` | List functions of the resolved contract with access level and tags, then exit. | + +The function/sequence narratives are the same data structures the REPL renders for `i <func>` and `seq` respectively (see `crates/ilold-core/src/narrative/function.rs` and `narrative/sequence.rs`). + +## Example: list mode + +``` +$ ilold context tests/fixtures/staking.sol --list + Staking — 9 functions + + [P] deposit external + [P] withdraw external + [P] claimRewards external + [R] setRewardRate external + [R] pause external + [R] unpause external + [P] rewardPerToken public + [P] earned public + + Usage: + ilold context <path> --function <name> + ilold context <path> --sequence "fn1,fn2" + + Example: + ilold context <path> --function deposit + ilold context <path> --sequence "deposit,withdraw" +``` + +The badges line up with `analyze`: `[P]` public/external entry point, `[R]` restricted/admin-gated, `[S]` system/internal. + +## Example: single function + +``` +$ ilold context tests/fixtures/staking.sol --function withdraw +``` + +Output is the same `FunctionNarrative` printed by the REPL's `i withdraw`, including transitive effects through the call chain. + +## Example: a sequence + +``` +$ ilold context tests/fixtures/staking.sol --sequence deposit,withdraw,claimRewards +``` + +The output narrates the per-step writes and the cross-step dependencies (variables shared between consecutive steps). + +## Notes + +- `context` is read-only; it does not start the API server. +- `--list` short-circuits before computing path trees, so it is cheap on large projects. +- Use [`explore`](./repl/session.md) when you need to iterate; `context` is meant for scripts and one-off questions. diff --git a/docs/guide/src/solidity/limitations.md b/docs/guide/src/solidity/limitations.md new file mode 100644 index 0000000..fb83df8 --- /dev/null +++ b/docs/guide/src/solidity/limitations.md @@ -0,0 +1,40 @@ +# Known Limitations + +This page documents the current analysis boundaries of ilold. Understanding these limitations is necessary for interpreting slice, timeline, and trace results correctly. + +## Intraprocedural slicing only + +The dataflow slicer operates within a single function body (plus its inlined modifiers). It does not follow values across function call boundaries. If `deposit` calls an internal helper `_updateBalance(amount)`, the slice for `amount` in `deposit` will show the call site but not the writes inside `_updateBalance`. Use `tr <func>` to inspect internal call bodies separately. + +## Assignment-only DEF extraction + +Only `Assignment` expressions (`x = ...`, `x += ...`, `x -= ...`) produce DEF entries in the slicer's use-def analysis. Solidity mutations that are not modeled as assignments -- specifically `x++`, `x--`, `++x`, `--x`, `delete x`, `arr.push(v)`, and `arr.pop()` -- are captured as USEs of the target variable but not as DEFs. A backward slice on a variable mutated exclusively through `.push()` will miss the mutating statement as a definition point. + +## Modifier placeholder split + +Modifier bodies are split at the first top-level `_;` (placeholder) statement to separate "before" code from "after" code. If the placeholder appears inside a nested block (e.g., inside an `if` branch), the entire modifier body is treated as "before" code. This is over-inclusive: statements that should execute after the function body will appear before it in the flattened view. In practice, most modifiers place `_;` at the top level, so this rarely triggers. + +## Forward slice over-tainting via ancestor merge + +When a statement is included in a forward slice, its lexical ancestors (enclosing `if`, `for`, `while` blocks) are also included so that the rendered slice shows control-flow context. The ancestor's condition variables are merged into the tainted set. This means an `if (unrelatedCondition)` enclosing a tainted write will add `unrelatedCondition` to the taint set, potentially pulling in unrelated statements in subsequent iterations. The result is a conservative (larger) slice rather than a precise one. + +## Tuple destructuring + +Tuple destructuring assignments such as `(a, b) = foo()` may not be recognized as DEFs depending on how the Solidity frontend lowers them. If the frontend does not emit a top-level `Assignment` node, the individual targets (`a`, `b`) are treated as USEs only. This can cause a backward slice to miss the destructuring as a definition point for `a` or `b`. + +## Timeline tracks state mutations only + +The `timeline` command tracks writes to state variables across session steps. Local variable assignments within a function body are recorded separately (`local_entries`) but are not visible in the default timeline output. If you need to trace a local variable, use `slice` within the specific function instead. + +## Session requires at least one call + +The `timeline`, `state`, and `sequence` endpoints require an active session with at least one `Call` step. The `timeline` and `state` commands return empty results if no steps have been added. The `sequence` command requires at least two steps. Use `tr <func>` for read-only inspection of a function's flow without adding it to the session. + +## Internal and private functions cannot be session entry points + +Session steps model real external transactions. Functions with `internal` or `private` visibility cannot be called from outside the contract, so they cannot be added as session steps via `c <func>`. Use `tr <func>` to inspect their execution flow, or call a public/external function that invokes them to see their effects through the modifier and internal-call inlining in the trace. + +## Related pages + +- [Taint Analysis](./workflows/taint-analysis.md) -- forward slice caveats in practice +- [HTTP API Reference](../reference/api-endpoints.md) diff --git a/docs/guide/src/solidity/overview.md b/docs/guide/src/solidity/overview.md new file mode 100644 index 0000000..cfd4df9 --- /dev/null +++ b/docs/guide/src/solidity/overview.md @@ -0,0 +1,39 @@ +# Solidity Backend Overview + +The Solidity backend is built on top of `solar-compiler` (parser) and a set of analysis passes in `ilold-core`. It supports both a one-shot CLI (`analyze`, `context`) and the interactive REPL (`explore`, `serve`). + +## What it parses + +`crates/ilold-cli/src/main.rs::collect_sol_files` walks the input path: + +- A single `.sol` file is loaded as-is. +- A directory is walked recursively; the directories `out`, `cache`, `node_modules`, `lib`, `target`, `.git`, `.svelte-kit` and any dot-prefixed directory are skipped. + +Once the project is parsed, ilold builds a per-function CFG via `CfgBuilder::build_with_project` and a path tree via `build_path_tree` (see `crates/ilold-core/src/cfg/builder.rs` and `pathtree/walker.rs`). Inheritance is resolved transitively, so inherited functions and state variables show up in `funcs-all` / `vars-all` and in `info` output. + +## What you can do with it + +| Surface | Purpose | +| --- | --- | +| [`analyze`](./cli-analyze.md) | One-shot pretty-print of every contract: functions, CFG and path-tree stats, sequences up to `--max-seq-depth`, optional verbose function behavior breakdown | +| [`context`](./cli-context.md) | Generate machine-readable narratives for a function or a comma-separated sequence | +| [`serve`](./repl/workspace.md) | Start the HTTP/WS server only, no REPL: feed the web canvas | +| [`explore`](./repl/session.md) | Interactive REPL, with the HTTP/WS API running alongside | + +## REPL command groups + +The REPL has six command groups, all documented in their own pages: + +- [Session](./repl/session.md): `c/call`, `b/back`, `cl/clear`, `s/state`, `seq/sequence`, `st/step`, `ss/session`. +- [Analysis](./repl/analysis.md): `w/who`, `i/info`, `tr/trace`, `tl/timeline`, `sl/slice`. +- [Contract](./repl/contract.md): `f/functions`, `fa/funcs-all`, `v/vars`, `va/vars-all`, `ct/contracts`, `use`. +- [Findings](./repl/findings.md): `fi/finding`, `n/note`, `status`, `fl/findings`, `ex/export`. +- [Scenarios](./repl/scenarios.md): `sc/scenario` (`new`, `list`, `switch`, `fork`, `delete`). +- [Workspace](./repl/workspace.md): `save`, `load`, `browser`, `q/quit/exit`, `?/help`. + +## Workflows + +Two end-to-end walkthroughs are included: + +- [Audit walkthrough](./workflows/audit-walkthrough.md): full session against a Staking contract. +- [Taint analysis](./workflows/taint-analysis.md): forward slicing of user-controlled parameters. diff --git a/docs/guide/src/commands/analysis.md b/docs/guide/src/solidity/repl/analysis.md similarity index 94% rename from docs/guide/src/commands/analysis.md rename to docs/guide/src/solidity/repl/analysis.md index ba35118..2b03e40 100644 --- a/docs/guide/src/commands/analysis.md +++ b/docs/guide/src/solidity/repl/analysis.md @@ -13,15 +13,17 @@ ilold[Staking]> who totalStaked who: totalStaked Writers: - [public] deposit - [public] withdraw + [P] deposit + [P] withdraw Readers: - [public] getStakeInfo + [P] rewardPerToken → sl deposit totalStaked, sl withdraw totalStaked → tl totalStaked ``` -The cross-reference hints suggest running [slice](#slice) for each writer and [timeline](#timeline) for the variable. +The cross-reference hints suggest running [slice](#slice) for each writer and [timeline](#timeline) for the variable. Access badges: `[P]` public/external, `[R]` restricted (admin-gated), `[I]` internal, `[S]` special. + +**Returns:** `VariableInfo { variable, writers, readers }` where each writer/reader is a `(String, AccessLevel)` pair. ## info diff --git a/docs/guide/src/solidity/repl/contract.md b/docs/guide/src/solidity/repl/contract.md new file mode 100644 index 0000000..fb1b3d8 --- /dev/null +++ b/docs/guide/src/solidity/repl/contract.md @@ -0,0 +1,122 @@ +# Contract Commands + +Contract commands inspect the structure of the loaded contracts without modifying the session. + +## functions + +`f` or `functions` + +Lists the callable functions in the active contract with their access level and tags. + +``` +ilold[Staking]> f + + [P] deposit writes state, external calls + [P] withdraw writes state, external calls + [P] claimRewards writes state, external calls + [R] setRewardRate writes state + [R] pause writes state + [R] unpause writes state + [P] rewardPerToken view + [P] earned view +``` + +Badges: `[P]` public/external, `[R]` restricted (admin-gated), `[I]` internal, `[S]` special. Tags indicate `writes state`, `external calls`, or `view` (read-only, no external calls). + +**Returns:** `FunctionList { functions: [FunctionEntry { name, access, writes_state, has_external_calls, is_read_only }] }`. + +## funcs-all + +`fa` or `funcs-all` + +Lists all accessible functions including those inherited from parent contracts. + +``` +ilold[Staking]> fa + + [P] deposit writes state, external calls + [P] withdraw writes state, external calls + [P] claimRewards writes state, external calls + [R] setRewardRate writes state + [R] pause writes state + [R] unpause writes state + [P] rewardPerToken view + [P] earned view + + inherited: + [P] owner from Ownable + [P] transferOwnership from Ownable +``` + +Inherited functions are listed separately with their origin contract. + +**Returns:** `FunctionListAll { functions: [AccessibleFunctionEntry { name, access, writes_state, has_external_calls, is_read_only, origin, is_inherited }] }`. + +## vars + +`v` or `vars` + +Lists the state variables of the active contract with their type and mutability tag. + +``` +ilold[Staking]> v + + mutable owner address + mutable paused bool + mutable rewardRate uint256 + mutable lastUpdateTime uint256 + mutable rewardPerTokenStored uint256 + mutable balances mapping(address => uint256) + mutable userRewardPerTokenPaid mapping(address => uint256) + mutable rewards mapping(address => uint256) + mutable totalStaked uint256 +``` + +Tags are `mutable`, `const`, or `immutable`. + +## vars-all + +`va` or `vars-all` + +Lists all accessible state variables including inherited ones. + +``` +ilold[Staking]> va + + mutable owner address + mutable paused bool + mutable rewardRate uint256 + ... +``` + +**Returns:** `StateVarListAll { state_vars: [AccessibleStateVarEntry { name, type_name, is_constant, is_immutable, origin, is_inherited }] }`. Inherited entries print under an `inherited:` section with `from <origin>`. + +## contracts + +`ct` or `contracts` + +Lists all contracts in the loaded project with their type badge, function count, state variable count, and `inherits` clause when present. + +``` +ilold[Staking]> ct + + [I] IERC20 3 functions, 0 state vars + [C] Staking 9 functions, 11 state vars ← current +``` + +Type badges: `[C]` contract, `[I]` interface, `[L]` library, `[A]` abstract. + +## use + +`use <contract>` + +Switches the active contract. Clears the current session steps. + +``` +ilold[Staking]> use Ownable + + ✓ Now using: Ownable + Cleared 2 step(s) from previous contract +``` + +After switching, all session and analysis commands operate on the new contract. diff --git a/docs/guide/src/commands/findings.md b/docs/guide/src/solidity/repl/findings.md similarity index 86% rename from docs/guide/src/commands/findings.md rename to docs/guide/src/solidity/repl/findings.md index 1b0ed29..290c7a4 100644 --- a/docs/guide/src/commands/findings.md +++ b/docs/guide/src/solidity/repl/findings.md @@ -45,23 +45,7 @@ ilold[→ deposit → withdraw]> n Check if msg.value can be zero here ✓ Note added ``` -## scenario - -`sc <name>` or `scenario <name>` - -Names the current call sequence. Run without arguments to see the current name. - -``` -ilold[→ deposit → withdraw]> sc reentrancy-attack - - Scenario: reentrancy-attack -``` - -``` -ilold[→ deposit → withdraw]> sc - - Current scenario: reentrancy-attack -``` +Scenarios are managed by the dedicated `sc | scenario` command family (`scenario new <name>`, `scenario fork <name> [at <N>]`, `scenario switch <name>`, `scenario list`, `scenario delete <name>`). See [Scenarios](./scenarios.md) for the full reference. ## status diff --git a/docs/guide/src/solidity/repl/scenarios.md b/docs/guide/src/solidity/repl/scenarios.md new file mode 100644 index 0000000..0536f90 --- /dev/null +++ b/docs/guide/src/solidity/repl/scenarios.md @@ -0,0 +1,81 @@ +# Scenario Commands + +A scenario is a named branch of the session timeline. Every session starts on the default scenario `main`; the auditor can create more scenarios, switch between them, fork an existing one at a specific step, or delete a scenario that is no longer needed. The prompt shows the active scenario as `ilold[Contract/scenario]` when it is not `main`. + +## scenario new + +`scenario new <name>` (alias: `sc new`) + +Creates an empty scenario with no steps. The new scenario is not activated automatically. Use `scenario switch` to make it the active one. + +``` +ilold[Staking]> sc new reentrancy + ✓ Created scenario 'reentrancy' +``` + +**Returns:** `ScenarioCreated { name }`. + +## scenario list + +`scenario list` (aliases: `scenario ls`, `sc list`) + +Lists every scenario in the active session, marking the active one. + +``` +ilold[Staking]> sc list + scenarios — 2 total, active: main + name steps + → main 2 + reentrancy 0 +``` + +**Returns:** `ScenarioList { items: [ScenarioInfo { name, step_count, active }] }`. The CLI renders it inside a framed header box. + +## scenario switch + +`scenario switch <name>` (alias: `sc switch <name>`) + +Activates an existing scenario. All subsequent session and analysis commands operate against its step list. The prompt updates to reflect the new active scenario. + +``` +ilold[Staking]> sc switch reentrancy + ✓ Switched: 'main' → 'reentrancy' +ilold[Staking/reentrancy]> +``` + +**Returns:** `ScenarioSwitched { from, to }`. Switching to the active scenario is idempotent and prints `· Already on scenario '<name>'`. + +## scenario fork + +`scenario fork <name> [at <N>]` (alias: `sc fork`) + +Creates a new scenario branching from the active one. With `at <N>`, the new scenario inherits steps `0..N` from the source scenario; without it, the new scenario inherits the full step list of the source. After forking, the new scenario is activated. + +``` +ilold[Staking → deposit → withdraw → claimRewards]> sc fork attack-v2 at 1 + ✓ Forked 'main' → 'attack-v2' at step 1 +ilold[Staking/attack-v2 → deposit]> +``` + +**Returns:** `ScenarioForked { from, to, at_step }`. + +Forks are useful when the auditor wants to keep an existing line of reasoning intact while testing a divergent path. + +## scenario delete + +`scenario delete <name>` (aliases: `scenario rm <name>`, `sc delete`, `sc rm`) + +Removes a scenario. The active scenario cannot be deleted; switch first. + +``` +ilold[Staking]> sc delete reentrancy + ✓ Deleted scenario 'reentrancy' +``` + +**Returns:** `ScenarioDeleted { name }`. + +## Notes + +- Solidity scenarios share the same parsed model, so analysis commands stay cheap across forks. +- For the Solana counterpart (each scenario carries its own VM, signers and PDAs), see [Solana: Scenarios](../../solana/repl/scenarios.md). +- The full scenario tree is included in `save` / `load` and in the `export` report. diff --git a/docs/guide/src/commands/session.md b/docs/guide/src/solidity/repl/session.md similarity index 81% rename from docs/guide/src/commands/session.md rename to docs/guide/src/solidity/repl/session.md index 35b961d..9f0bc7b 100644 --- a/docs/guide/src/commands/session.md +++ b/docs/guide/src/solidity/repl/session.md @@ -11,7 +11,7 @@ Adds a function call to the session sequence. Only external and public functions ``` ilold[Staking]> c deposit - + Step 0: deposit [public] external + + Step 0: deposit [P] external State writes: · balances · totalStaked @@ -21,13 +21,15 @@ ilold[Staking]> c deposit ``` ilold[→ deposit]> c withdraw - + Step 1: withdraw [public] external + + Step 1: withdraw [P] external State writes: · balances · totalStaked Sequence: deposit → withdraw ``` +**Returns:** `StepAdded { step_index, function, access, state_changed }`. `access` is one of `Public`, `Restricted { role }`, `Internal`, `Special { kind }`. + Attempting to call an internal function: ``` @@ -75,14 +77,16 @@ ilold[→ deposit → withdraw]> s ═══════════════════[ STATE ]═══════════════════ balances - balances[msg.sender] += msg.value (deposit) - balances[msg.sender] -= amount (withdraw) + += msg.value (step 0, deposit) + -= amount (step 1, withdraw) totalStaked - totalStaked += msg.value (deposit) - totalStaked -= amount (withdraw) + += msg.value (step 0, deposit) + -= amount (step 1, withdraw) ``` -If the session is empty, `state` tells you to add steps first. +Each change line is `<operator> <value_expr> (step <N>, <function>)`, with an optional `via <modifier>` suffix when the mutation comes from a modifier body. + +**Returns:** `StateView { summary: [VariableSummary { variable, changes }] }`. If the session is empty, `state` tells you to add steps first. ## sequence diff --git a/docs/guide/src/commands/workspace.md b/docs/guide/src/solidity/repl/workspace.md similarity index 66% rename from docs/guide/src/commands/workspace.md rename to docs/guide/src/solidity/repl/workspace.md index fcc9dda..a24a2f2 100644 --- a/docs/guide/src/commands/workspace.md +++ b/docs/guide/src/solidity/repl/workspace.md @@ -34,7 +34,7 @@ The prompt updates to reflect the loaded steps. The session replaces whatever is `browser` -Opens the web UI. ilold starts an API server on startup; `browser` provides the URL. +Prints the base URL of the HTTP API the REPL is talking to. `explore` runs the API in-process by default; pass `--attach <url>` to point the REPL at a separate `serve` instance instead. ``` ilold[Staking]> browser @@ -42,10 +42,12 @@ ilold[Staking]> browser API running at http://127.0.0.1:52431/api/ ``` +The web canvas (when running `serve` and opening the URL in a browser) subscribes to the same HTTP/WS endpoints. See [HTTP API Reference](../../reference/api-endpoints.md) for the full surface. + ## quit `q`, `quit`, or `exit` -Exits the REPL. You can also press `Ctrl+D` or `Ctrl+C`. +Exits the REPL. `Ctrl+D` and `Ctrl+C` also work. -Unsaved session data is lost on exit. Use [save](#save) before quitting if you want to resume later. +Unsaved session data is lost on exit. Use [save](#save) before quitting if the session needs to survive. diff --git a/docs/guide/src/workflows/audit-walkthrough.md b/docs/guide/src/solidity/workflows/audit-walkthrough.md similarity index 98% rename from docs/guide/src/workflows/audit-walkthrough.md rename to docs/guide/src/solidity/workflows/audit-walkthrough.md index 871b985..27033ee 100644 --- a/docs/guide/src/workflows/audit-walkthrough.md +++ b/docs/guide/src/solidity/workflows/audit-walkthrough.md @@ -228,7 +228,7 @@ Each command's output contains the variable names, function names, and modifier ## Related pages -- [Session commands](../commands/session.md) +- [Session commands](../repl/session.md) - [Taint Analysis](./taint-analysis.md) -- [HTTP API Reference](../reference/api-endpoints.md) -- [Known Limitations](../reference/limitations.md) +- [HTTP API Reference](../../reference/api-endpoints.md) +- [Known Limitations](../limitations.md) diff --git a/docs/guide/src/workflows/taint-analysis.md b/docs/guide/src/solidity/workflows/taint-analysis.md similarity index 98% rename from docs/guide/src/workflows/taint-analysis.md rename to docs/guide/src/solidity/workflows/taint-analysis.md index d6fd89c..67cc4ac 100644 --- a/docs/guide/src/workflows/taint-analysis.md +++ b/docs/guide/src/solidity/workflows/taint-analysis.md @@ -100,4 +100,4 @@ Forward slice results map to common vulnerability classes: ## Related pages - [Full Audit Walkthrough](./audit-walkthrough.md) -- [Known Limitations](../reference/limitations.md) -- forward slice caveats +- [Known Limitations](../limitations.md) -- forward slice caveats From 8e5cd59bf8b5d24481f3b41b255041ea4f18fec6 Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Mon, 11 May 2026 10:04:41 +0200 Subject: [PATCH 102/115] feat(mcp): crate skeleton with tool registry from help blocks Add crates/ilold-mcp with rmcp 1.6 server skeleton over stdio. Tool registry derived from SOLANA_HELP_BLOCKS (single source of truth, extracted to new leaf crate ilold-help to break the cli<->mcp dependency cycle). Excludes ?/help/quit/exit/browser meta-commands and uses the canonical alias per command. call_tool returns a placeholder isError=true response pending T-R55b handlers. New ilold mcp subcommand wires the server with --server-url (env ILOLD_SERVER_URL, default http://127.0.0.1:8080). Three unit tests cover non-empty registry, unique names, and exclusions; three more cover canonical alias selection, name normalization, and ilold_ prefix. --- Cargo.lock | 217 +++++++++++++++++ Cargo.toml | 8 +- crates/ilold-cli/Cargo.toml | 2 + crates/ilold-cli/src/help.rs | 423 +-------------------------------- crates/ilold-cli/src/main.rs | 7 + crates/ilold-help/Cargo.toml | 5 + crates/ilold-help/src/lib.rs | 417 ++++++++++++++++++++++++++++++++ crates/ilold-mcp/Cargo.toml | 17 ++ crates/ilold-mcp/src/lib.rs | 21 ++ crates/ilold-mcp/src/server.rs | 57 +++++ crates/ilold-mcp/src/tools.rs | 142 +++++++++++ 11 files changed, 895 insertions(+), 421 deletions(-) create mode 100644 crates/ilold-help/Cargo.toml create mode 100644 crates/ilold-help/src/lib.rs create mode 100644 crates/ilold-mcp/Cargo.toml create mode 100644 crates/ilold-mcp/src/lib.rs create mode 100644 crates/ilold-mcp/src/server.rs create mode 100644 crates/ilold-mcp/src/tools.rs diff --git a/Cargo.lock b/Cargo.lock index 7d0b5dd..1e2da5e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -644,6 +644,17 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eab1c04a571841102f5345a8fc0f6bb3d31c315dec879b5c6e42e40ce7ffa34e" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -984,8 +995,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link", ] @@ -1555,6 +1568,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "eager" version = "0.1.0" @@ -1870,6 +1889,21 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + [[package]] name = "futures-channel" version = "0.3.32" @@ -1877,6 +1911,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", + "futures-sink", ] [[package]] @@ -1885,6 +1920,23 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + [[package]] name = "futures-macro" version = "0.3.32" @@ -1914,10 +1966,13 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ + "futures-channel", "futures-core", + "futures-io", "futures-macro", "futures-sink", "futures-task", + "memchr", "pin-project-lite", "slab", ] @@ -2378,6 +2433,8 @@ dependencies = [ "crossterm", "dirs", "ilold-core", + "ilold-help", + "ilold-mcp", "ilold-solana-core", "ilold-web", "nu-ansi-term", @@ -2401,6 +2458,26 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ilold-help" +version = "0.1.0" + +[[package]] +name = "ilold-mcp" +version = "0.1.0" +dependencies = [ + "anyhow", + "ilold-help", + "rmcp", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "ilold-session-core" version = "0.1.0" @@ -2885,6 +2962,15 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + [[package]] name = "matchit" version = "0.8.4" @@ -3751,6 +3837,26 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "regex" version = "1.12.3" @@ -3854,6 +3960,41 @@ dependencies = [ "rustc-hex", ] +[[package]] +name = "rmcp" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e12ca9067b5ebfbd5b3fcdc4acfceb81aa7d5ab2a879dff7cb75d22434276aad" +dependencies = [ + "async-trait", + "base64 0.22.1", + "chrono", + "futures", + "pastey", + "pin-project-lite", + "rmcp-macros", + "schemars", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "rmcp-macros" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7caa6743cc0888e433105fe1bc551a7f607940b126a37bc97b478e86064627eb" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.117", +] + [[package]] name = "ruint" version = "1.17.2" @@ -4003,6 +4144,32 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "chrono", + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -4125,6 +4292,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "serde_json" version = "1.0.149" @@ -4286,6 +4464,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shared_library" version = "0.1.9" @@ -6345,6 +6532,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2b21757..3f17ff7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,8 @@ members = [ "crates/ilold-web", "crates/ilold-session-core", "crates/ilold-solana-core", + "crates/ilold-mcp", + "crates/ilold-help", ] [workspace.package] @@ -19,10 +21,14 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" thiserror = "2" anyhow = "1" -clap = { version = "4", features = ["derive"] } +clap = { version = "4", features = ["derive", "env"] } tokio = { version = "1", features = ["full"] } axum = { version = "0.8", features = ["ws"] } tower-http = { version = "0.6", features = ["cors", "fs"] } +rmcp = { version = "1.6", features = ["server", "transport-io"] } +schemars = "1.2" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } [profile.release] lto = "thin" diff --git a/crates/ilold-cli/Cargo.toml b/crates/ilold-cli/Cargo.toml index 6dd4a38..ef001d3 100644 --- a/crates/ilold-cli/Cargo.toml +++ b/crates/ilold-cli/Cargo.toml @@ -12,6 +12,8 @@ path = "src/main.rs" ilold-core = { path = "../ilold-core" } ilold-solana-core = { path = "../ilold-solana-core" } ilold-web = { path = "../ilold-web" } +ilold-mcp = { path = "../ilold-mcp" } +ilold-help = { path = "../ilold-help" } anyhow = { workspace = true } clap = { workspace = true } tokio = { workspace = true } diff --git a/crates/ilold-cli/src/help.rs b/crates/ilold-cli/src/help.rs index eec20dc..084c1f4 100644 --- a/crates/ilold-cli/src/help.rs +++ b/crates/ilold-cli/src/help.rs @@ -1,422 +1,8 @@ use crate::colors::{c_accent, c_bright, c_muted}; -pub struct HelpBlock { - pub title: &'static str, - pub aliases: &'static [&'static str], - pub purpose: &'static str, - pub syntax: &'static [(&'static str, &'static str)], - pub flags: &'static [(&'static str, &'static str)], - pub examples: &'static [(&'static str, &'static str)], - pub returns: &'static str, - pub see_also: &'static [&'static str], -} - -pub const SOLANA_HELP_BLOCKS: &[HelpBlock] = &[ - HelpBlock { - title: "c | call", - aliases: &["c", "call"], - purpose: "Run an Anchor instruction against the LiteSVM and append the result as a step on the active scenario.", - syntax: &[ - ("c <ix> arg=val acc=user", "Concise key=value form (signers auto-resolved from IDL)"), - ("c <ix> {json}", "Full JSON form: {\"args\":{...},\"accounts\":{...},\"signers\":[...]}"), - ], - flags: &[ - ("--signer=a,b", "Force these accounts to sign (override IDL defaults)"), - ("--no-signer=name", "Remove a default signer (test negative cases)"), - ], - examples: &[ - ("c stake amount=1000 pool=pool user_stake=alice_stake user=alice", "Stake 1000 from alice"), - ("c initialize_pool reward_rate=10 pool=pool admin=admin", "Bootstrap a pool"), - ("c stake {\"args\":{\"amount\":1000},\"accounts\":{\"pool\":\"pool\",\"user_stake\":\"alice_stake\",\"user\":\"alice\"}}", "Same call in JSON form"), - ], - returns: "StepAdded { step_index, instruction, logs_excerpt, account_diffs_count, compute_units } on success, or CallFailed { instruction, logs_excerpt, compute_units, error } when the VM rejects.", - see_also: &["info", "pda", "state", "step", "back"], - }, - HelpBlock { - title: "b | back", - aliases: &["b", "back"], - purpose: "Remove the last step from the active scenario and rewind the VM to that point.", - syntax: &[("b", "")], - flags: &[], - examples: &[("b", "Drop the most recent call before resuming exploration")], - returns: "ScenarioUpdated with the truncated step list.", - see_also: &["clear", "step", "session"], - }, - HelpBlock { - title: "cl | clear", - aliases: &["cl", "clear"], - purpose: "Reset the active scenario steps and the underlying VM state.", - syntax: &[("cl", "")], - flags: &[], - examples: &[("cl", "Wipe the timeline before starting a new attack flow")], - returns: "ScenarioUpdated with an empty step list.", - see_also: &["back", "scenario", "session"], - }, - HelpBlock { - title: "state", - aliases: &["state"], - purpose: "Show the decoded view of every account mutated during the active scenario.", - syntax: &[("state", "")], - flags: &[], - examples: &[("state", "Inspect post-step balances and PDA contents at the latest step")], - returns: "StateView { accounts: [{ pubkey, decoded_fields, owner_program, ... }] }.", - see_also: &["timeline", "inspect", "session"], - }, - HelpBlock { - title: "s | session", - aliases: &["s", "session"], - purpose: "Print the active scenario summary: ordered steps, findings, notes.", - syntax: &[("s", "")], - flags: &[], - examples: &[("s", "Recap what has been called so far and which findings are attached")], - returns: "SessionView { scenario_name, steps, findings, notes }.", - see_also: &["scenario", "state", "findings"], - }, - HelpBlock { - title: "ct | programs | contracts", - aliases: &["ct", "programs", "progs", "contracts"], - purpose: "List every program detected in the workspace (multi-program Anchor workspaces included).", - syntax: &[("ct", "")], - flags: &[], - examples: &[("ct", "Discover the available programs before switching with use")], - returns: "Plain list of program names with the active one marked.", - see_also: &["use", "funcs", "vars"], - }, - HelpBlock { - title: "use", - aliases: &["use"], - purpose: "Switch the active program so subsequent commands target it.", - syntax: &[("use <program>", "")], - flags: &[], - examples: &[("use staking", "Make staking the active program")], - returns: "Updates the prompt label and the completer source.", - see_also: &["programs", "funcs", "vars"], - }, - HelpBlock { - title: "f | funcs | functions", - aliases: &["f", "funcs", "functions"], - purpose: "List the instructions exposed by the active program.", - syntax: &[("f", "")], - flags: &[], - examples: &[("f", "Enumerate callable instructions with arg/account counts")], - returns: "FuncsList { instructions: [{ name, arg_count, account_count, signer_count, pda_count }] }.", - see_also: &["funcs-all", "info", "vars"], - }, - HelpBlock { - title: "fa | funcs-all", - aliases: &["fa", "funcs-all"], - purpose: "List instructions with full counts plus admin-gating and coupling hints (T-R50 ProgramView).", - syntax: &[("fa", "")], - flags: &[], - examples: &[("fa", "Spot admin-only entry points and shared-writable couplings at a glance")], - returns: "FuncsAll { instructions: [{ name, args, accounts, signers, pdas, admin_gated, couples_with }] }.", - see_also: &["funcs", "info", "coupling"], - }, - HelpBlock { - title: "i | info", - aliases: &["i", "info"], - purpose: "Detail an instruction: typed args, accounts with flags, signers, PDAs, discriminator.", - syntax: &[("i <ix>", "")], - flags: &[], - examples: &[ - ("i stake", "Inspect the stake instruction in full"), - ("info initialize_pool", ""), - ], - returns: "InstructionDetail { name, args, accounts, signers, pdas, discriminator }.", - see_also: &["funcs-all", "pda", "who", "call"], - }, - HelpBlock { - title: "v | vars", - aliases: &["v", "vars", "vars-all", "va"], - purpose: "List declared account types with their Anchor discriminators.", - syntax: &[ - ("v", "Compact view"), - ("va", "Same as v in current Solana backend (full layout)"), - ], - flags: &[], - examples: &[("v", "List Pool, UserStake, etc. with discriminators")], - returns: "VarsList { account_types: [{ name, discriminator, fields }] }.", - see_also: &["who", "inspect", "funcs"], - }, - HelpBlock { - title: "users", - aliases: &["users"], - purpose: "Manage the keypair set in the active scenario: list existing users or mint a new one.", - syntax: &[ - ("users", "List keypairs"), - ("users new <name> [lamports]", "Create keypair and airdrop (default 10 SOL)"), - ], - flags: &[], - examples: &[ - ("users", "Show all named keypairs"), - ("users new alice", "Create alice with 10 SOL"), - ("users new bob 5000000000", "Create bob with 5 SOL"), - ], - returns: "UsersList { users: [{ name, pubkey, lamports }] } or UsersUpdated.", - see_also: &["airdrop", "inspect", "scenario"], - }, - HelpBlock { - title: "airdrop", - aliases: &["airdrop", "air"], - purpose: "Top up an existing keypair with extra lamports.", - syntax: &[("airdrop <user> <lamports>", "")], - flags: &[], - examples: &[("airdrop alice 1000000000", "Add 1 SOL to alice")], - returns: "UsersUpdated reflecting the new balance.", - see_also: &["users", "inspect"], - }, - HelpBlock { - title: "tw | time-warp", - aliases: &["tw", "time-warp"], - purpose: "Advance (or rewind) the Clock sysvar so vesting / reward / lockup logic can be exercised.", - syntax: &[("tw <delta_seconds>", "Positive moves forward, negative back")], - flags: &[], - examples: &[ - ("tw 86400", "Skip one day"), - ("tw -3600", "Rewind one hour"), - ], - returns: "ClockUpdated { unix_timestamp, slot }.", - see_also: &["state", "call"], - }, - HelpBlock { - title: "pda", - aliases: &["pda"], - purpose: "List the PDAs declared by an instruction (Anchor seeds, derived addresses).", - syntax: &[("pda <ix>", "")], - flags: &[], - examples: &[("pda stake", "See seeds + bump seeds for the stake instruction")], - returns: "PdaList { instruction, pdas: [{ name, seeds, bump }] }.", - see_also: &["info", "inspect", "call"], - }, - HelpBlock { - title: "inspect", - aliases: &["inspect", "acc"], - purpose: "Read a VM account by pubkey and decode it via the Anchor discriminator.", - syntax: &[("inspect <pubkey>", "")], - flags: &[], - examples: &[ - ("inspect alice", "Decode alice's PDA / wallet"), - ("inspect 6Yg7...", "Decode by raw base58 pubkey"), - ], - returns: "AccountView { pubkey, owner, lamports, data_decoded }.", - see_also: &["state", "timeline", "vars"], - }, - HelpBlock { - title: "st | step", - aliases: &["st", "step"], - purpose: "Re-inspect a specific step of the active scenario: CU, logs, decoded diffs.", - syntax: &[("st <index>", "")], - flags: &[], - examples: &[ - ("st 0", "Inspect the first step"), - ("step 3", ""), - ], - returns: "StepDetail { index, instruction, logs, account_diffs, compute_units }.", - see_also: &["session", "state", "timeline"], - }, - HelpBlock { - title: "who", - aliases: &["who"], - purpose: "Resolve a query against the IDL: account type, instruction, or struct field.", - syntax: &[("who <AccountType|ix_name|field_name>", "")], - flags: &[], - examples: &[ - ("who Pool", "AccountType: list ix that touch it with args+fields"), - ("who pool", "Same — case-insensitive snake_to_pascal fallback"), - ("who claim_rewards", "Instruction: list accounts it touches with type+fields"), - ("who total_staked", "Field: identify owner type, list ix that write it (heuristic)"), - ], - returns: "WhoList { query_kind: AccountType|Instruction|Field|NotFound, ... }.", - see_also: &["info", "funcs", "vars", "coupling"], - }, - HelpBlock { - title: "tl | timeline", - aliases: &["tl", "timeline"], - purpose: "Show the cross-step mutation history of an account, decoded.", - syntax: &[("tl <pubkey>", "Pubkey or named keypair")], - flags: &[], - examples: &[ - ("tl alice", "Trace alice across every step"), - ("timeline pool", "Watch the pool PDA evolve"), - ], - returns: "Timeline { pubkey, entries: [{ step, decoded_fields, diff }] }.", - see_also: &["state", "inspect", "step"], - }, - HelpBlock { - title: "coupling | cp", - aliases: &["coupling", "cp"], - purpose: "List instruction pairs that share a writable account (coupling heuristic from T-R50).", - syntax: &[("coupling", "")], - flags: &[], - examples: &[("coupling", "Surface ix that may interfere via shared writable state")], - returns: "CouplingList { pairs: [{ ix_a, ix_b, shared_writable: [..] }] }.", - see_also: &["who", "funcs-all", "info"], - }, - HelpBlock { - title: "coverage | cov", - aliases: &["coverage", "cov"], - purpose: "Aggregated runtime metrics over the active scenario: calls, failures, CU stats, CPI edges (T-R52 RuntimeOverlay).", - syntax: &[("coverage", "")], - flags: &[], - examples: &[("cov", "Spot ix never called, ix that always fail, programs reached via CPI")], - returns: "Coverage { overlay: { program, scenario, calls_per_ix, failed_per_ix, cu_stats_per_ix, cpi_edges } }.", - see_also: &["session", "state", "funcs"], - }, - HelpBlock { - title: "fi | finding", - aliases: &["fi", "finding"], - purpose: "Record a security finding tied to the latest step of the active scenario.", - syntax: &[("fi <severity> <title>", "")], - flags: &[ - ("--rec=\"...\"", "Optional remediation recommendation (quote it if it has spaces)"), - ], - examples: &[ - ("fi high reentrancy via stake", "Severity + free-form title"), - ("finding critical missing signer --rec=\"require admin signature\"", ""), - ], - returns: "FindingRecorded { id, severity, title, step_index }.", - see_also: &["findings", "note", "status", "export"], - }, - HelpBlock { - title: "fl | findings", - aliases: &["fl", "findings"], - purpose: "List every finding recorded in the active scenario.", - syntax: &[("fl", "")], - flags: &[], - examples: &[("fl", "")], - returns: "FindingsList { findings: [{ id, severity, title, step_index, recommendation }] }.", - see_also: &["finding", "export", "session"], - }, - HelpBlock { - title: "n | note", - aliases: &["n", "note"], - purpose: "Attach a free-form annotation to the active scenario.", - syntax: &[("n <text>", "")], - flags: &[], - examples: &[("n suspicious admin path here", "Drop a context note before moving on")], - returns: "NoteAdded.", - see_also: &["finding", "session"], - }, - HelpBlock { - title: "status", - aliases: &["status"], - purpose: "Set the review status of an instruction: open, reviewed, or finding.", - syntax: &[("status <ix> <open|reviewed|finding>", "")], - flags: &[], - examples: &[ - ("status stake reviewed", "Mark stake as reviewed"), - ("status claim_rewards finding", "Flag claim_rewards as having an issue"), - ], - returns: "StatusUpdated { ix, status }.", - see_also: &["finding", "findings", "export"], - }, - HelpBlock { - title: "ex | export", - aliases: &["ex", "export"], - purpose: "Generate the audit deliverable (Markdown) with sequence, findings, and program info.", - syntax: &[("ex", "")], - flags: &[ - ("--auditor=<name>", "Auditor identity in the report metadata"), - ("--version=<v>", "Project version pinned in the report"), - ("--date=<YYYY-MM-DD>", "Audit date override (defaults to today)"), - ], - examples: &[ - ("ex", "Export with no metadata"), - ("export --auditor=\"Alba S.\" --version=v1.2 --date=2026-05-09", ""), - ], - returns: "ExportArtifact { path, markdown }.", - see_also: &["findings", "finding", "session"], - }, - HelpBlock { - title: "sc | scenario", - aliases: &["sc", "scenario"], - purpose: "Manage scenarios: create, list, switch, fork from a step, delete.", - syntax: &[ - ("sc list", "List all scenarios in the workspace (default action)"), - ("sc new <name>", "Create a fresh empty scenario"), - ("sc switch <name>", "Activate an existing scenario"), - ("sc fork <name> [step]", "Branch from the active scenario at an optional step index"), - ("sc delete <name>", "Remove a scenario"), - ], - flags: &[], - examples: &[ - ("sc new reentrancy", "Start a new scenario named reentrancy"), - ("sc fork attack-v2 3", "Fork the active scenario at step 3"), - ("sc switch main", ""), - ], - returns: "ScenarioList or ScenarioUpdated depending on the sub-command.", - see_also: &["session", "save", "load"], - }, - HelpBlock { - title: "save", - aliases: &["save"], - purpose: "Serialise the active scenario to ~/.ilold/sessions/<name>.json.", - syntax: &[("save <name>", "")], - flags: &[ - ("--with-keypairs", "Bundle plaintext test keypairs (do NOT commit the file)"), - ], - examples: &[ - ("save reentrancy-attack", ""), - ("save reentrancy-attack --with-keypairs", "Bundle keypairs for full reproducibility"), - ], - returns: "SessionSaved { json } — the CLI writes the file and warns if keypairs are bundled.", - see_also: &["load", "scenario", "export"], - }, - HelpBlock { - title: "load", - aliases: &["load"], - purpose: "Restore a scenario JSON from disk and replay it into the VM.", - syntax: &[("load <name>", "")], - flags: &[], - examples: &[("load reentrancy-attack", "Replay the saved scenario step-by-step")], - returns: "SessionLoaded { steps } — VM is rebuilt deterministically.", - see_also: &["save", "scenario", "session"], - }, - HelpBlock { - title: "seq | sequence", - aliases: &["seq", "sequence"], - purpose: "Render the narrative of the active scenario (Solana falls back to the session view).", - syntax: &[("seq", "")], - flags: &[], - examples: &[("seq", "Read the step-by-step story so far")], - returns: "SessionView (Solana parity with the Solidity sequence command).", - see_also: &["session", "step", "state"], - }, - HelpBlock { - title: "browser", - aliases: &["browser"], - purpose: "Print the URL of the local web canvas (the explore REPL serves it next to the API).", - syntax: &[("browser", "")], - flags: &[], - examples: &[("browser", "")], - returns: "Plain text with the API base URL.", - see_also: &["session", "state"], - }, - HelpBlock { - title: "? | help", - aliases: &["?", "help", "h"], - purpose: "Print the command menu. Append ? to any command for the full reference (e.g. call?, who?).", - syntax: &[("?", "")], - flags: &[], - examples: &[ - ("?", "Top-level menu"), - ("call?", "Full reference for call"), - ("who?", "Full reference for who"), - ], - returns: "Formatted help text.", - see_also: &[], - }, - HelpBlock { - title: "q | quit | exit", - aliases: &["q", "quit", "exit"], - purpose: "Exit the explore REPL.", - syntax: &[("q", "")], - flags: &[], - examples: &[("q", "")], - returns: "Terminates the session.", - see_also: &[], - }, -]; +pub use ilold_help::SOLANA_HELP_BLOCKS; +#[allow(unused_imports)] +pub use ilold_help::HelpBlock; pub fn render_solana_help_block(cmd: &str) -> Option<String> { let key = cmd.trim().to_lowercase(); @@ -588,9 +174,6 @@ mod tests { #[test] fn every_solana_command_has_a_help_block() { - // Every command identifier accepted by handle_solana_input must have - // either its own HelpBlock or be aliased into one. If you add a new - // command branch in explore.rs, register it here too. let registered: &[&str] = &[ "?", "help", "h", "quit", "q", "exit", diff --git a/crates/ilold-cli/src/main.rs b/crates/ilold-cli/src/main.rs index d1b9ad3..3388e52 100644 --- a/crates/ilold-cli/src/main.rs +++ b/crates/ilold-cli/src/main.rs @@ -62,6 +62,12 @@ enum Commands { #[arg(long)] attach: Option<String>, }, + /// Run the MCP server (stdio transport) exposing Solana REPL commands as tools + Mcp { + /// Base URL of a running `ilold serve` instance (defaults to http://127.0.0.1:8080) + #[arg(long, env = "ILOLD_SERVER_URL", default_value = "http://127.0.0.1:8080")] + server_url: String, + }, } #[tokio::main] @@ -92,6 +98,7 @@ async fn main() -> Result<()> { ProjectKind::Solana => explore::run_solana(detected, port).await, } } + Commands::Mcp { server_url } => ilold_mcp::run(server_url).await, } } diff --git a/crates/ilold-help/Cargo.toml b/crates/ilold-help/Cargo.toml new file mode 100644 index 0000000..4913d6f --- /dev/null +++ b/crates/ilold-help/Cargo.toml @@ -0,0 +1,5 @@ +[package] +name = "ilold-help" +version.workspace = true +edition.workspace = true +description = "Shared HelpBlock registry for Ilold REPL commands" diff --git a/crates/ilold-help/src/lib.rs b/crates/ilold-help/src/lib.rs new file mode 100644 index 0000000..3d1f282 --- /dev/null +++ b/crates/ilold-help/src/lib.rs @@ -0,0 +1,417 @@ +pub struct HelpBlock { + pub title: &'static str, + pub aliases: &'static [&'static str], + pub purpose: &'static str, + pub syntax: &'static [(&'static str, &'static str)], + pub flags: &'static [(&'static str, &'static str)], + pub examples: &'static [(&'static str, &'static str)], + pub returns: &'static str, + pub see_also: &'static [&'static str], +} + +pub const SOLANA_HELP_BLOCKS: &[HelpBlock] = &[ + HelpBlock { + title: "c | call", + aliases: &["c", "call"], + purpose: "Run an Anchor instruction against the LiteSVM and append the result as a step on the active scenario.", + syntax: &[ + ("c <ix> arg=val acc=user", "Concise key=value form (signers auto-resolved from IDL)"), + ("c <ix> {json}", "Full JSON form: {\"args\":{...},\"accounts\":{...},\"signers\":[...]}"), + ], + flags: &[ + ("--signer=a,b", "Force these accounts to sign (override IDL defaults)"), + ("--no-signer=name", "Remove a default signer (test negative cases)"), + ], + examples: &[ + ("c stake amount=1000 pool=pool user_stake=alice_stake user=alice", "Stake 1000 from alice"), + ("c initialize_pool reward_rate=10 pool=pool admin=admin", "Bootstrap a pool"), + ("c stake {\"args\":{\"amount\":1000},\"accounts\":{\"pool\":\"pool\",\"user_stake\":\"alice_stake\",\"user\":\"alice\"}}", "Same call in JSON form"), + ], + returns: "StepAdded { step_index, instruction, logs_excerpt, account_diffs_count, compute_units } on success, or CallFailed { instruction, logs_excerpt, compute_units, error } when the VM rejects.", + see_also: &["info", "pda", "state", "step", "back"], + }, + HelpBlock { + title: "b | back", + aliases: &["b", "back"], + purpose: "Remove the last step from the active scenario and rewind the VM to that point.", + syntax: &[("b", "")], + flags: &[], + examples: &[("b", "Drop the most recent call before resuming exploration")], + returns: "ScenarioUpdated with the truncated step list.", + see_also: &["clear", "step", "session"], + }, + HelpBlock { + title: "cl | clear", + aliases: &["cl", "clear"], + purpose: "Reset the active scenario steps and the underlying VM state.", + syntax: &[("cl", "")], + flags: &[], + examples: &[("cl", "Wipe the timeline before starting a new attack flow")], + returns: "ScenarioUpdated with an empty step list.", + see_also: &["back", "scenario", "session"], + }, + HelpBlock { + title: "state", + aliases: &["state"], + purpose: "Show the decoded view of every account mutated during the active scenario.", + syntax: &[("state", "")], + flags: &[], + examples: &[("state", "Inspect post-step balances and PDA contents at the latest step")], + returns: "StateView { accounts: [{ pubkey, decoded_fields, owner_program, ... }] }.", + see_also: &["timeline", "inspect", "session"], + }, + HelpBlock { + title: "s | session", + aliases: &["s", "session"], + purpose: "Print the active scenario summary: ordered steps, findings, notes.", + syntax: &[("s", "")], + flags: &[], + examples: &[("s", "Recap what has been called so far and which findings are attached")], + returns: "SessionView { scenario_name, steps, findings, notes }.", + see_also: &["scenario", "state", "findings"], + }, + HelpBlock { + title: "ct | programs | contracts", + aliases: &["ct", "programs", "progs", "contracts"], + purpose: "List every program detected in the workspace (multi-program Anchor workspaces included).", + syntax: &[("ct", "")], + flags: &[], + examples: &[("ct", "Discover the available programs before switching with use")], + returns: "Plain list of program names with the active one marked.", + see_also: &["use", "funcs", "vars"], + }, + HelpBlock { + title: "use", + aliases: &["use"], + purpose: "Switch the active program so subsequent commands target it.", + syntax: &[("use <program>", "")], + flags: &[], + examples: &[("use staking", "Make staking the active program")], + returns: "Updates the prompt label and the completer source.", + see_also: &["programs", "funcs", "vars"], + }, + HelpBlock { + title: "f | funcs | functions", + aliases: &["f", "funcs", "functions"], + purpose: "List the instructions exposed by the active program.", + syntax: &[("f", "")], + flags: &[], + examples: &[("f", "Enumerate callable instructions with arg/account counts")], + returns: "FuncsList { instructions: [{ name, arg_count, account_count, signer_count, pda_count }] }.", + see_also: &["funcs-all", "info", "vars"], + }, + HelpBlock { + title: "fa | funcs-all", + aliases: &["fa", "funcs-all"], + purpose: "List instructions with full counts plus admin-gating and coupling hints (T-R50 ProgramView).", + syntax: &[("fa", "")], + flags: &[], + examples: &[("fa", "Spot admin-only entry points and shared-writable couplings at a glance")], + returns: "FuncsAll { instructions: [{ name, args, accounts, signers, pdas, admin_gated, couples_with }] }.", + see_also: &["funcs", "info", "coupling"], + }, + HelpBlock { + title: "i | info", + aliases: &["i", "info"], + purpose: "Detail an instruction: typed args, accounts with flags, signers, PDAs, discriminator.", + syntax: &[("i <ix>", "")], + flags: &[], + examples: &[ + ("i stake", "Inspect the stake instruction in full"), + ("info initialize_pool", ""), + ], + returns: "InstructionDetail { name, args, accounts, signers, pdas, discriminator }.", + see_also: &["funcs-all", "pda", "who", "call"], + }, + HelpBlock { + title: "v | vars", + aliases: &["v", "vars", "vars-all", "va"], + purpose: "List declared account types with their Anchor discriminators.", + syntax: &[ + ("v", "Compact view"), + ("va", "Same as v in current Solana backend (full layout)"), + ], + flags: &[], + examples: &[("v", "List Pool, UserStake, etc. with discriminators")], + returns: "VarsList { account_types: [{ name, discriminator, fields }] }.", + see_also: &["who", "inspect", "funcs"], + }, + HelpBlock { + title: "users", + aliases: &["users"], + purpose: "Manage the keypair set in the active scenario: list existing users or mint a new one.", + syntax: &[ + ("users", "List keypairs"), + ("users new <name> [lamports]", "Create keypair and airdrop (default 10 SOL)"), + ], + flags: &[], + examples: &[ + ("users", "Show all named keypairs"), + ("users new alice", "Create alice with 10 SOL"), + ("users new bob 5000000000", "Create bob with 5 SOL"), + ], + returns: "UsersList { users: [{ name, pubkey, lamports }] } or UsersUpdated.", + see_also: &["airdrop", "inspect", "scenario"], + }, + HelpBlock { + title: "airdrop", + aliases: &["airdrop", "air"], + purpose: "Top up an existing keypair with extra lamports.", + syntax: &[("airdrop <user> <lamports>", "")], + flags: &[], + examples: &[("airdrop alice 1000000000", "Add 1 SOL to alice")], + returns: "UsersUpdated reflecting the new balance.", + see_also: &["users", "inspect"], + }, + HelpBlock { + title: "tw | time-warp", + aliases: &["tw", "time-warp"], + purpose: "Advance (or rewind) the Clock sysvar so vesting / reward / lockup logic can be exercised.", + syntax: &[("tw <delta_seconds>", "Positive moves forward, negative back")], + flags: &[], + examples: &[ + ("tw 86400", "Skip one day"), + ("tw -3600", "Rewind one hour"), + ], + returns: "ClockUpdated { unix_timestamp, slot }.", + see_also: &["state", "call"], + }, + HelpBlock { + title: "pda", + aliases: &["pda"], + purpose: "List the PDAs declared by an instruction (Anchor seeds, derived addresses).", + syntax: &[("pda <ix>", "")], + flags: &[], + examples: &[("pda stake", "See seeds + bump seeds for the stake instruction")], + returns: "PdaList { instruction, pdas: [{ name, seeds, bump }] }.", + see_also: &["info", "inspect", "call"], + }, + HelpBlock { + title: "inspect", + aliases: &["inspect", "acc"], + purpose: "Read a VM account by pubkey and decode it via the Anchor discriminator.", + syntax: &[("inspect <pubkey>", "")], + flags: &[], + examples: &[ + ("inspect alice", "Decode alice's PDA / wallet"), + ("inspect 6Yg7...", "Decode by raw base58 pubkey"), + ], + returns: "AccountView { pubkey, owner, lamports, data_decoded }.", + see_also: &["state", "timeline", "vars"], + }, + HelpBlock { + title: "st | step", + aliases: &["st", "step"], + purpose: "Re-inspect a specific step of the active scenario: CU, logs, decoded diffs.", + syntax: &[("st <index>", "")], + flags: &[], + examples: &[ + ("st 0", "Inspect the first step"), + ("step 3", ""), + ], + returns: "StepDetail { index, instruction, logs, account_diffs, compute_units }.", + see_also: &["session", "state", "timeline"], + }, + HelpBlock { + title: "who", + aliases: &["who"], + purpose: "Resolve a query against the IDL: account type, instruction, or struct field.", + syntax: &[("who <AccountType|ix_name|field_name>", "")], + flags: &[], + examples: &[ + ("who Pool", "AccountType: list ix that touch it with args+fields"), + ("who pool", "Same — case-insensitive snake_to_pascal fallback"), + ("who claim_rewards", "Instruction: list accounts it touches with type+fields"), + ("who total_staked", "Field: identify owner type, list ix that write it (heuristic)"), + ], + returns: "WhoList { query_kind: AccountType|Instruction|Field|NotFound, ... }.", + see_also: &["info", "funcs", "vars", "coupling"], + }, + HelpBlock { + title: "tl | timeline", + aliases: &["tl", "timeline"], + purpose: "Show the cross-step mutation history of an account, decoded.", + syntax: &[("tl <pubkey>", "Pubkey or named keypair")], + flags: &[], + examples: &[ + ("tl alice", "Trace alice across every step"), + ("timeline pool", "Watch the pool PDA evolve"), + ], + returns: "Timeline { pubkey, entries: [{ step, decoded_fields, diff }] }.", + see_also: &["state", "inspect", "step"], + }, + HelpBlock { + title: "coupling | cp", + aliases: &["coupling", "cp"], + purpose: "List instruction pairs that share a writable account (coupling heuristic from T-R50).", + syntax: &[("coupling", "")], + flags: &[], + examples: &[("coupling", "Surface ix that may interfere via shared writable state")], + returns: "CouplingList { pairs: [{ ix_a, ix_b, shared_writable: [..] }] }.", + see_also: &["who", "funcs-all", "info"], + }, + HelpBlock { + title: "coverage | cov", + aliases: &["coverage", "cov"], + purpose: "Aggregated runtime metrics over the active scenario: calls, failures, CU stats, CPI edges (T-R52 RuntimeOverlay).", + syntax: &[("coverage", "")], + flags: &[], + examples: &[("cov", "Spot ix never called, ix that always fail, programs reached via CPI")], + returns: "Coverage { overlay: { program, scenario, calls_per_ix, failed_per_ix, cu_stats_per_ix, cpi_edges } }.", + see_also: &["session", "state", "funcs"], + }, + HelpBlock { + title: "fi | finding", + aliases: &["fi", "finding"], + purpose: "Record a security finding tied to the latest step of the active scenario.", + syntax: &[("fi <severity> <title>", "")], + flags: &[ + ("--rec=\"...\"", "Optional remediation recommendation (quote it if it has spaces)"), + ], + examples: &[ + ("fi high reentrancy via stake", "Severity + free-form title"), + ("finding critical missing signer --rec=\"require admin signature\"", ""), + ], + returns: "FindingRecorded { id, severity, title, step_index }.", + see_also: &["findings", "note", "status", "export"], + }, + HelpBlock { + title: "fl | findings", + aliases: &["fl", "findings"], + purpose: "List every finding recorded in the active scenario.", + syntax: &[("fl", "")], + flags: &[], + examples: &[("fl", "")], + returns: "FindingsList { findings: [{ id, severity, title, step_index, recommendation }] }.", + see_also: &["finding", "export", "session"], + }, + HelpBlock { + title: "n | note", + aliases: &["n", "note"], + purpose: "Attach a free-form annotation to the active scenario.", + syntax: &[("n <text>", "")], + flags: &[], + examples: &[("n suspicious admin path here", "Drop a context note before moving on")], + returns: "NoteAdded.", + see_also: &["finding", "session"], + }, + HelpBlock { + title: "status", + aliases: &["status"], + purpose: "Set the review status of an instruction: open, reviewed, or finding.", + syntax: &[("status <ix> <open|reviewed|finding>", "")], + flags: &[], + examples: &[ + ("status stake reviewed", "Mark stake as reviewed"), + ("status claim_rewards finding", "Flag claim_rewards as having an issue"), + ], + returns: "StatusUpdated { ix, status }.", + see_also: &["finding", "findings", "export"], + }, + HelpBlock { + title: "ex | export", + aliases: &["ex", "export"], + purpose: "Generate the audit deliverable (Markdown) with sequence, findings, and program info.", + syntax: &[("ex", "")], + flags: &[ + ("--auditor=<name>", "Auditor identity in the report metadata"), + ("--version=<v>", "Project version pinned in the report"), + ("--date=<YYYY-MM-DD>", "Audit date override (defaults to today)"), + ], + examples: &[ + ("ex", "Export with no metadata"), + ("export --auditor=\"Alba S.\" --version=v1.2 --date=2026-05-09", ""), + ], + returns: "ExportArtifact { path, markdown }.", + see_also: &["findings", "finding", "session"], + }, + HelpBlock { + title: "sc | scenario", + aliases: &["sc", "scenario"], + purpose: "Manage scenarios: create, list, switch, fork from a step, delete.", + syntax: &[ + ("sc list", "List all scenarios in the workspace (default action)"), + ("sc new <name>", "Create a fresh empty scenario"), + ("sc switch <name>", "Activate an existing scenario"), + ("sc fork <name> [step]", "Branch from the active scenario at an optional step index"), + ("sc delete <name>", "Remove a scenario"), + ], + flags: &[], + examples: &[ + ("sc new reentrancy", "Start a new scenario named reentrancy"), + ("sc fork attack-v2 3", "Fork the active scenario at step 3"), + ("sc switch main", ""), + ], + returns: "ScenarioList or ScenarioUpdated depending on the sub-command.", + see_also: &["session", "save", "load"], + }, + HelpBlock { + title: "save", + aliases: &["save"], + purpose: "Serialise the active scenario to ~/.ilold/sessions/<name>.json.", + syntax: &[("save <name>", "")], + flags: &[ + ("--with-keypairs", "Bundle plaintext test keypairs (do NOT commit the file)"), + ], + examples: &[ + ("save reentrancy-attack", ""), + ("save reentrancy-attack --with-keypairs", "Bundle keypairs for full reproducibility"), + ], + returns: "SessionSaved { json } — the CLI writes the file and warns if keypairs are bundled.", + see_also: &["load", "scenario", "export"], + }, + HelpBlock { + title: "load", + aliases: &["load"], + purpose: "Restore a scenario JSON from disk and replay it into the VM.", + syntax: &[("load <name>", "")], + flags: &[], + examples: &[("load reentrancy-attack", "Replay the saved scenario step-by-step")], + returns: "SessionLoaded { steps } — VM is rebuilt deterministically.", + see_also: &["save", "scenario", "session"], + }, + HelpBlock { + title: "seq | sequence", + aliases: &["seq", "sequence"], + purpose: "Render the narrative of the active scenario (Solana falls back to the session view).", + syntax: &[("seq", "")], + flags: &[], + examples: &[("seq", "Read the step-by-step story so far")], + returns: "SessionView (Solana parity with the Solidity sequence command).", + see_also: &["session", "step", "state"], + }, + HelpBlock { + title: "browser", + aliases: &["browser"], + purpose: "Print the URL of the local web canvas (the explore REPL serves it next to the API).", + syntax: &[("browser", "")], + flags: &[], + examples: &[("browser", "")], + returns: "Plain text with the API base URL.", + see_also: &["session", "state"], + }, + HelpBlock { + title: "? | help", + aliases: &["?", "help", "h"], + purpose: "Print the command menu. Append ? to any command for the full reference (e.g. call?, who?).", + syntax: &[("?", "")], + flags: &[], + examples: &[ + ("?", "Top-level menu"), + ("call?", "Full reference for call"), + ("who?", "Full reference for who"), + ], + returns: "Formatted help text.", + see_also: &[], + }, + HelpBlock { + title: "q | quit | exit", + aliases: &["q", "quit", "exit"], + purpose: "Exit the explore REPL.", + syntax: &[("q", "")], + flags: &[], + examples: &[("q", "")], + returns: "Terminates the session.", + see_also: &[], + }, +]; diff --git a/crates/ilold-mcp/Cargo.toml b/crates/ilold-mcp/Cargo.toml new file mode 100644 index 0000000..ce2494b --- /dev/null +++ b/crates/ilold-mcp/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "ilold-mcp" +version.workspace = true +edition.workspace = true +description = "MCP (Model Context Protocol) server exposing Ilold Solana REPL commands as tools" + +[dependencies] +ilold-help = { path = "../ilold-help" } +rmcp = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } diff --git a/crates/ilold-mcp/src/lib.rs b/crates/ilold-mcp/src/lib.rs new file mode 100644 index 0000000..7707b13 --- /dev/null +++ b/crates/ilold-mcp/src/lib.rs @@ -0,0 +1,21 @@ +use anyhow::Result; +use rmcp::ServiceExt; +use rmcp::transport::io::stdio; + +pub mod server; +pub mod tools; + +pub async fn run(server_url: String) -> Result<()> { + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("off")), + ) + .init(); + + let handler = server::IloldMcpServer::new(server_url); + let service = handler.serve(stdio()).await?; + service.waiting().await?; + Ok(()) +} diff --git a/crates/ilold-mcp/src/server.rs b/crates/ilold-mcp/src/server.rs new file mode 100644 index 0000000..29f9a94 --- /dev/null +++ b/crates/ilold-mcp/src/server.rs @@ -0,0 +1,57 @@ +use rmcp::ServerHandler; +use rmcp::model::{ + CallToolRequestParams, CallToolResult, Content, Implementation, ListToolsResult, + PaginatedRequestParams, ServerCapabilities, ServerInfo, +}; +use rmcp::service::{RequestContext, RoleServer}; + +use crate::tools; + +pub struct IloldMcpServer { + server_url: String, +} + +impl IloldMcpServer { + pub fn new(server_url: String) -> Self { + Self { server_url } + } + + pub fn server_url(&self) -> &str { + &self.server_url + } +} + +impl ServerHandler for IloldMcpServer { + fn get_info(&self) -> ServerInfo { + let mut info = ServerInfo::default(); + info.capabilities = ServerCapabilities::builder().enable_tools().build(); + info.server_info = Implementation::new("ilold-mcp", env!("CARGO_PKG_VERSION")); + info.instructions = Some( + "Ilold MCP server. Exposes Solana REPL commands as MCP tools. \ + Tool handlers are stubs in T-R55a; functional dispatch lands in T-R55b." + .to_string(), + ); + info + } + + async fn list_tools( + &self, + _request: Option<PaginatedRequestParams>, + _context: RequestContext<RoleServer>, + ) -> Result<ListToolsResult, rmcp::ErrorData> { + let tools = tools::build_tool_registry(); + Ok(ListToolsResult::with_all_items(tools)) + } + + async fn call_tool( + &self, + request: CallToolRequestParams, + _context: RequestContext<RoleServer>, + ) -> Result<CallToolResult, rmcp::ErrorData> { + let msg = format!( + "tool `{}` not yet implemented (T-R55b). server_url={}", + request.name, self.server_url + ); + Ok(CallToolResult::error(vec![Content::text(msg)])) + } +} diff --git a/crates/ilold-mcp/src/tools.rs b/crates/ilold-mcp/src/tools.rs new file mode 100644 index 0000000..a4f0f94 --- /dev/null +++ b/crates/ilold-mcp/src/tools.rs @@ -0,0 +1,142 @@ +use std::sync::Arc; + +use ilold_help::{HelpBlock, SOLANA_HELP_BLOCKS}; +use rmcp::model::{JsonObject, Tool}; +use serde_json::{Map, Value, json}; + +const EXCLUDED_ALIASES: &[&str] = &["?", "help", "h", "quit", "q", "exit", "browser"]; +const TOOL_NAME_PREFIX: &str = "ilold_"; + +pub fn build_tool_registry() -> Vec<Tool> { + SOLANA_HELP_BLOCKS + .iter() + .filter(|b| !is_excluded(b)) + .map(|b| { + let canonical = canonical_alias(b); + let name = format!("{TOOL_NAME_PREFIX}{}", normalize_name(canonical)); + let description = format_description(b); + Tool::new(name, description, Arc::new(empty_object_schema())) + }) + .collect() +} + +pub fn is_excluded(block: &HelpBlock) -> bool { + block + .aliases + .iter() + .any(|alias| EXCLUDED_ALIASES.contains(alias)) +} + +pub fn canonical_alias(block: &HelpBlock) -> &'static str { + block + .aliases + .iter() + .copied() + .find(|a| a.len() >= 3) + .unwrap_or_else(|| block.aliases[0]) +} + +pub fn normalize_name(alias: &str) -> String { + alias.replace('-', "_") +} + +fn format_description(block: &HelpBlock) -> String { + let mut out = String::new(); + out.push_str(block.purpose); + if block.aliases.len() > 1 { + out.push_str(&format!("\n\nAliases: {}", block.aliases.join(", "))); + } + if !block.returns.is_empty() { + out.push_str(&format!("\n\nReturns: {}", block.returns)); + } + out +} + +fn empty_object_schema() -> JsonObject { + let v = json!({ + "type": "object", + "properties": {}, + "additionalProperties": true + }); + match v { + Value::Object(map) => map, + _ => Map::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tool_registry_not_empty() { + let tools = build_tool_registry(); + assert!( + tools.len() >= 20, + "expected at least 20 tools, got {}", + tools.len() + ); + } + + #[test] + fn tool_names_are_unique() { + let tools = build_tool_registry(); + let mut names: Vec<String> = tools.iter().map(|t| t.name.to_string()).collect(); + names.sort(); + let dup = names.windows(2).find(|w| w[0] == w[1]); + assert!(dup.is_none(), "duplicate tool name: {:?}", dup); + } + + #[test] + fn excluded_commands_not_in_registry() { + let tools = build_tool_registry(); + let forbidden = [ + "ilold_?", + "ilold_help", + "ilold_h", + "ilold_quit", + "ilold_q", + "ilold_exit", + "ilold_browser", + ]; + for f in forbidden { + assert!( + !tools.iter().any(|t| t.name == f), + "registry should not contain {f}" + ); + } + } + + #[test] + fn canonical_alias_prefers_long_form() { + let block = HelpBlock { + title: "c | call", + aliases: &["c", "call"], + purpose: "", + syntax: &[], + flags: &[], + examples: &[], + returns: "", + see_also: &[], + }; + assert_eq!(canonical_alias(&block), "call"); + } + + #[test] + fn normalize_name_replaces_dash() { + assert_eq!(normalize_name("funcs-all"), "funcs_all"); + assert_eq!(normalize_name("time-warp"), "time_warp"); + } + + #[test] + fn names_have_ilold_prefix() { + let tools = build_tool_registry(); + for t in &tools { + assert!( + t.name.starts_with(TOOL_NAME_PREFIX), + "tool name missing prefix: {}", + t.name + ); + } + } +} From 2855abde39f1113183517d4c3231761df43e308d Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Mon, 11 May 2026 10:21:10 +0200 Subject: [PATCH 103/115] chore(mcp): tighten t-r55a placeholder and defer subtasks - Bump canonical alias threshold to 4 chars, picks ilold_sequence - Drop unused schemars/tracing/thiserror from crates/ilold-mcp/Cargo.toml - Placeholder schema uses additionalProperties:false - Registry test asserts exact 30 tools - Mark 5 phase 1 sub-tasks as deferred to T-R55b in tasks.md --- Cargo.lock | 3 --- crates/ilold-mcp/Cargo.toml | 3 --- crates/ilold-mcp/src/tools.rs | 11 ++++------- 3 files changed, 4 insertions(+), 13 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1e2da5e..ec835c7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2469,12 +2469,9 @@ dependencies = [ "anyhow", "ilold-help", "rmcp", - "schemars", "serde", "serde_json", - "thiserror 2.0.18", "tokio", - "tracing", "tracing-subscriber", ] diff --git a/crates/ilold-mcp/Cargo.toml b/crates/ilold-mcp/Cargo.toml index ce2494b..bb111e8 100644 --- a/crates/ilold-mcp/Cargo.toml +++ b/crates/ilold-mcp/Cargo.toml @@ -7,11 +7,8 @@ description = "MCP (Model Context Protocol) server exposing Ilold Solana REPL co [dependencies] ilold-help = { path = "../ilold-help" } rmcp = { workspace = true } -schemars = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } -thiserror = { workspace = true } tokio = { workspace = true } -tracing = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/crates/ilold-mcp/src/tools.rs b/crates/ilold-mcp/src/tools.rs index a4f0f94..11dadbe 100644 --- a/crates/ilold-mcp/src/tools.rs +++ b/crates/ilold-mcp/src/tools.rs @@ -28,11 +28,12 @@ pub fn is_excluded(block: &HelpBlock) -> bool { } pub fn canonical_alias(block: &HelpBlock) -> &'static str { + // alias must have at least 4 chars to be canonical, so `seq` falls back to `sequence` block .aliases .iter() .copied() - .find(|a| a.len() >= 3) + .find(|a| a.len() >= 4) .unwrap_or_else(|| block.aliases[0]) } @@ -56,7 +57,7 @@ fn empty_object_schema() -> JsonObject { let v = json!({ "type": "object", "properties": {}, - "additionalProperties": true + "additionalProperties": false }); match v { Value::Object(map) => map, @@ -71,11 +72,7 @@ mod tests { #[test] fn tool_registry_not_empty() { let tools = build_tool_registry(); - assert!( - tools.len() >= 20, - "expected at least 20 tools, got {}", - tools.len() - ); + assert_eq!(tools.len(), 30); } #[test] From 30e16ea650dc029c8344cb2cd9889e93ac9be40f Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Mon, 11 May 2026 10:49:55 +0200 Subject: [PATCH 104/115] feat(mcp): functional tool handlers over /api/cmd Add JsonSchema derives to SolanaCommand and the shared session-core types it references, hand-roll per-tool inputSchemas in crates/ilold-mcp/src/schema.rs, and wire the call_tool handler to POST contract+command pairs at /api/cmd via a thin IloldClient. New ilold-render crate hosts the canonical render_solana_result so the REPL (ilold-cli) and the MCP server (ilold-mcp) share one renderer; explore.rs print_solana_result is now a print! wrapper over the shared output, byte-identical to the previous behaviour. The ilold mcp subcommand requires a new --contract flag so every tool call routes to the right program, ilold_use is dropped from the registry because that selection is now done at the CLI surface, and ilold_programs special-cases over /api/project/map to keep workspace discovery available. Health-check runs at startup and aborts with a clear stderr message when the backend is down or non-Solana. Tests cover schema shapes, name normalization idempotence, build_command translation per pattern (Call minimal, Funcs unit, Scenario sub passthrough, UsersNew default lamports, TimeWarp delta), and the stdio registry surface (29 tools, valid schemas, destructive names present). --- Cargo.lock | 21 + Cargo.toml | 1 + crates/ilold-cli/Cargo.toml | 1 + crates/ilold-cli/src/colors.rs | 19 +- crates/ilold-cli/src/explore.rs | 961 +------------- crates/ilold-cli/src/main.rs | 7 +- crates/ilold-mcp/Cargo.toml | 7 + crates/ilold-mcp/src/client.rs | 90 ++ crates/ilold-mcp/src/error.rs | 13 + crates/ilold-mcp/src/lib.rs | 23 +- crates/ilold-mcp/src/schema.rs | 346 ++++++ crates/ilold-mcp/src/server.rs | 114 +- crates/ilold-mcp/src/tools.rs | 241 +++- .../ilold-mcp/tests/mcp_stdio_lists_tools.rs | 40 + crates/ilold-render/Cargo.toml | 12 + crates/ilold-render/src/colors.rs | 18 + crates/ilold-render/src/fmt.rs | 27 + crates/ilold-render/src/lib.rs | 5 + crates/ilold-render/src/solana.rs | 1100 +++++++++++++++++ crates/ilold-session-core/Cargo.toml | 1 + .../src/exploration/scenario.rs | 2 +- .../ilold-session-core/src/journal/export.rs | 2 +- .../ilold-session-core/src/journal/types.rs | 4 +- crates/ilold-solana-core/Cargo.toml | 1 + .../src/exploration/commands.rs | 12 +- 25 files changed, 2055 insertions(+), 1013 deletions(-) create mode 100644 crates/ilold-mcp/src/client.rs create mode 100644 crates/ilold-mcp/src/error.rs create mode 100644 crates/ilold-mcp/src/schema.rs create mode 100644 crates/ilold-mcp/tests/mcp_stdio_lists_tools.rs create mode 100644 crates/ilold-render/Cargo.toml create mode 100644 crates/ilold-render/src/colors.rs create mode 100644 crates/ilold-render/src/fmt.rs create mode 100644 crates/ilold-render/src/lib.rs create mode 100644 crates/ilold-render/src/solana.rs diff --git a/Cargo.lock b/Cargo.lock index ec835c7..8615135 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2435,6 +2435,7 @@ dependencies = [ "ilold-core", "ilold-help", "ilold-mcp", + "ilold-render", "ilold-solana-core", "ilold-web", "nu-ansi-term", @@ -2468,17 +2469,36 @@ version = "0.1.0" dependencies = [ "anyhow", "ilold-help", + "ilold-render", + "ilold-session-core", + "ilold-solana-core", + "reqwest", "rmcp", + "schemars", "serde", "serde_json", + "thiserror 2.0.18", "tokio", + "tracing", "tracing-subscriber", ] +[[package]] +name = "ilold-render" +version = "0.1.0" +dependencies = [ + "colored", + "ilold-core", + "ilold-session-core", + "ilold-solana-core", + "serde_json", +] + [[package]] name = "ilold-session-core" version = "0.1.0" dependencies = [ + "schemars", "serde", "serde_json", ] @@ -2493,6 +2513,7 @@ dependencies = [ "bs58", "ilold-session-core", "litesvm", + "schemars", "serde", "serde_json", "solana-account", diff --git a/Cargo.toml b/Cargo.toml index 3f17ff7..96b4461 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/ilold-solana-core", "crates/ilold-mcp", "crates/ilold-help", + "crates/ilold-render", ] [workspace.package] diff --git a/crates/ilold-cli/Cargo.toml b/crates/ilold-cli/Cargo.toml index ef001d3..2f4178b 100644 --- a/crates/ilold-cli/Cargo.toml +++ b/crates/ilold-cli/Cargo.toml @@ -14,6 +14,7 @@ ilold-solana-core = { path = "../ilold-solana-core" } ilold-web = { path = "../ilold-web" } ilold-mcp = { path = "../ilold-mcp" } ilold-help = { path = "../ilold-help" } +ilold-render = { path = "../ilold-render" } anyhow = { workspace = true } clap = { workspace = true } tokio = { workspace = true } diff --git a/crates/ilold-cli/src/colors.rs b/crates/ilold-cli/src/colors.rs index b04a6f8..10a86fc 100644 --- a/crates/ilold-cli/src/colors.rs +++ b/crates/ilold-cli/src/colors.rs @@ -1,18 +1 @@ -use colored::{Colorize, ColoredString}; -use ilold_core::classify::entry_points::AccessLevel; - -pub fn c_accent(s: &str) -> ColoredString { s.truecolor(140, 170, 210) } -pub fn c_warn(s: &str) -> ColoredString { s.truecolor(180, 150, 80) } -pub fn c_danger(s: &str) -> ColoredString { s.truecolor(170, 90, 90) } -pub fn c_ok(s: &str) -> ColoredString { s.truecolor(100, 160, 110) } -pub fn c_muted(s: &str) -> ColoredString { s.truecolor(110, 120, 140) } -pub fn c_bright(s: &str) -> ColoredString { s.truecolor(190, 200, 215).bold() } - -pub fn access_colored(access: &AccessLevel) -> ColoredString { - match access { - AccessLevel::Public => c_accent("[P]"), - AccessLevel::Restricted { .. } => c_warn("[R]"), - AccessLevel::Internal => c_muted("[I]"), - AccessLevel::Special { .. } => c_muted("[S]"), - } -} +pub use ilold_render::colors::*; diff --git a/crates/ilold-cli/src/explore.rs b/crates/ilold-cli/src/explore.rs index f778464..043c9b0 100644 --- a/crates/ilold-cli/src/explore.rs +++ b/crates/ilold-cli/src/explore.rs @@ -1959,970 +1959,15 @@ fn apply_solana_result_to_steps(result: &SolanaCommandResult, steps: &mut Vec<St } } -/// Render a JSON object's fields as `key value` lines, aligning the keys. -/// Used by `state` and step diff printers when the account is decoded. -fn print_decoded_fields(value: &serde_json::Value, indent: &str) { - if let serde_json::Value::Object(map) = value { - let max = map.keys().map(|k| k.chars().count()).max().unwrap_or(0); - for (k, v) in map { - let val = match v { - serde_json::Value::String(s) => s.clone(), - _ => serde_json::to_string(v).unwrap_or_default(), - }; - println!( - "{}{} {}", - indent, - c_muted(&format!("{:width$}", k, width = max)), - val, - ); - } - } -} - -/// Diff two JSON objects key-by-key and print only the keys that changed. -/// Format: `field_name before → after`. Unchanged keys are skipped so the -/// auditor sees exactly what mutated. -fn print_decoded_diff( - before: &serde_json::Value, - after: &serde_json::Value, - indent: &str, -) { - let (a, b) = match (before, after) { - (serde_json::Value::Object(a), serde_json::Value::Object(b)) => (a, b), - _ => { - println!( - "{}{}", - indent, - c_muted("decoded shape changed (not an object diff)"), - ); - return; - } - }; - let mut keys: Vec<&String> = a.keys().chain(b.keys()).collect(); - keys.sort(); - keys.dedup(); - let max = keys.iter().map(|k| k.chars().count()).max().unwrap_or(0); - let mut any = false; - for k in keys { - let lhs = a.get(k); - let rhs = b.get(k); - if lhs == rhs { - continue; - } - any = true; - let s_lhs = lhs - .map(|v| match v { - serde_json::Value::String(s) => s.clone(), - _ => serde_json::to_string(v).unwrap_or_default(), - }) - .unwrap_or_else(|| "<absent>".into()); - let s_rhs = rhs - .map(|v| match v { - serde_json::Value::String(s) => s.clone(), - _ => serde_json::to_string(v).unwrap_or_default(), - }) - .unwrap_or_else(|| "<absent>".into()); - println!( - "{}{} {} → {}", - indent, - c_muted(&format!("{:width$}", k, width = max)), - c_muted(&s_lhs), - s_rhs, - ); - } - if !any { - println!("{}{}", indent, c_muted("(no decoded field changed)")); - } -} - fn print_solana_result(result: &SolanaCommandResult) { println!(); - match result { - SolanaCommandResult::StepAdded { - step_index, - instruction, - logs_excerpt, - account_diffs_count, - compute_units, - error, - } => { - // The Call appended the step regardless of VM outcome — the - // auditor must see whether it actually executed. Prefer the - // structured `error` field; fall back to the historical log - // scan when the field is missing (shouldn't happen post-T-R47 - // but kept for resilience against legacy save replays). - let failed = error.is_some() - || logs_excerpt.iter().any(|l| { - let s = l.as_str(); - s.contains("AnchorError") || s.contains("failed:") || s.contains("panicked") - }); - let (mark, label) = if failed { - (c_danger("✗").to_string(), c_danger("FAILED").to_string()) - } else { - (c_ok("✓").to_string(), c_ok("ok").to_string()) - }; - println!( - " {} step {} [{}]: {} {}", - mark, - step_index, - label, - c_accent(instruction), - c_muted(&format!( - "({} CU, {} diffs)", - compute_units, account_diffs_count - )) - ); - for log in logs_excerpt { - if log.contains("AnchorError") - || log.contains("failed:") - || log.contains("panicked") - { - println!(" {}", c_danger(log)); - } else { - println!(" {}", c_muted(log)); - } - } - } - SolanaCommandResult::CallFailed { - instruction, - logs_excerpt, - compute_units, - error, - } => { - // Failed Calls are NOT appended to the timeline (Solana mirrors - // Solidity's "session steps are valid entries" model). The - // auditor still sees full diagnostics here, and can record the - // attempt manually with `note "tried X, blocked"` or `finding`. - println!( - " {} {}: {} {}", - c_danger("✗"), - c_danger("FAILED"), - c_accent(instruction), - c_muted(&format!("({} CU, not recorded)", compute_units)), - ); - println!(" {} {}", c_danger("error:"), error); - for log in logs_excerpt { - if log.contains("AnchorError") - || log.contains("failed:") - || log.contains("panicked") - { - println!(" {}", c_danger(log)); - } else { - println!(" {}", c_muted(log)); - } - } - } - SolanaCommandResult::StepRemoved { remaining } => { - println!(" {} step undone ({} remaining)", c_ok("✓"), remaining); - } - SolanaCommandResult::Cleared => { - println!(" {} session cleared", c_ok("✓")); - } - SolanaCommandResult::InstructionList { items } => { - for ix in items { - let badge = if ix.has_pdas { c_accent("[PDA]") } else { c_muted("[ix]") }; - let signers = if ix.signers.is_empty() { - String::new() - } else { - format!(" signers: {}", ix.signers.join(",")) - }; - println!( - " {} {} {}{}", - badge, - ix.name, - c_muted(&format!( - "(args:{} accounts:{})", - ix.args_count, ix.accounts_count - )), - c_muted(&signers) - ); - } - } - SolanaCommandResult::StateView { accounts } => { - if accounts.is_empty() { - println!(" {}", c_muted("No accounts mutated yet")); - } - for a in accounts { - println!( - " {} {} {} {}", - c_accent("[A]"), - c_accent(&a.label), - c_muted(&format!("({} lamports)", a.lamports)), - c_muted(&a.pubkey), - ); - match a.decoded.as_ref() { - None => { - println!(" {}", c_muted("<not decoded>")); - } - Some(serde_json::Value::Object(map)) => { - let max = map - .keys() - .map(|k| k.chars().count()) - .max() - .unwrap_or(0); - for (k, v) in map { - let val = match v { - serde_json::Value::String(s) => s.clone(), - _ => serde_json::to_string(v).unwrap_or_default(), - }; - println!( - " {} {}", - c_muted(&format!("{:width$}", k, width = max)), - val, - ); - } - } - Some(other) => { - println!( - " {}", - c_muted(&serde_json::to_string(other).unwrap_or_default()), - ); - } - } - } - } - SolanaCommandResult::SessionView { - program, - scenario, - steps, - findings_count, - } => { - println!( - " program={} scenario={} steps={} findings={}", - c_accent(program), - c_accent(scenario), - steps.len(), - findings_count - ); - for (i, s) in steps.iter().enumerate() { - println!(" {} {}", c_muted(&format!("{i}.")), s); - } - } - SolanaCommandResult::UserList { users } => { - if users.is_empty() { - println!(" {}", c_muted("No users — create with 'users new <name>'")); - } - for u in users { - println!( - " {} {} {} {}", - c_accent("[U]"), - u.name, - c_muted(&u.pubkey), - c_muted(&format!("{} lamports", u.lamports)) - ); - } - } - SolanaCommandResult::UserCreated { name, pubkey, lamports } => { - println!( - " {} user {} created at {} with {} lamports", - c_ok("✓"), - c_accent(name), - c_muted(pubkey), - lamports - ); - } - SolanaCommandResult::Airdropped { name, pubkey, total_lamports } => { - println!( - " {} {} now {} lamports {}", - c_ok("✓"), - c_accent(name), - total_lamports, - c_muted(pubkey) - ); - } - SolanaCommandResult::TimeWarped { unix_timestamp, slot } => { - println!( - " {} clock now ts={} slot={}", - c_ok("✓"), - unix_timestamp, - slot - ); - } - SolanaCommandResult::PdaList { instruction, pdas } => { - if pdas.is_empty() { - println!(" {} {}", c_muted("no PDAs declared in"), instruction); - } - for p in pdas { - println!( - " {} {} seeds=[{}] program={}", - c_accent("[PDA]"), - p.account_name, - p.seeds.join(", "), - c_muted(&p.program) - ); - } - } - SolanaCommandResult::AccountInspected { - pubkey, - owner, - lamports, - data_len, - decoded, - } => { - println!( - " {} owner={} lamports={} data_len={}", - c_accent(pubkey), - c_muted(owner), - lamports, - data_len - ); - if let Some(d) = decoded { - println!(" {}", serde_json::to_string_pretty(d).unwrap_or_default()); - } - } - SolanaCommandResult::FindingAdded { id } => { - println!(" {} finding {}", c_ok("✓"), c_accent(id)); - } - SolanaCommandResult::NoteAdded => { - println!(" {} note recorded", c_ok("✓")); - } - SolanaCommandResult::StatusUpdated => { - println!(" {} status updated", c_ok("✓")); - } - SolanaCommandResult::SessionSaved { json } => { - println!( - " {} session JSON ({} bytes)", - c_ok("✓"), - json.len() - ); - } - SolanaCommandResult::SessionLoaded { program, steps } => { - println!( - " {} loaded program={} steps={}", - c_ok("✓"), - c_accent(program), - steps.len() - ); - } - SolanaCommandResult::ScenarioList { items } => { - for it in items { - let marker = if it.active { c_ok(" ← active") } else { "".into() }; - println!( - " {} {} {}{}", - c_accent("[S]"), - it.name, - c_muted(&format!("({} steps)", it.step_count)), - marker - ); - } - } - SolanaCommandResult::ScenarioCreated { name } => { - println!(" {} scenario {} created", c_ok("✓"), c_accent(name)); - } - SolanaCommandResult::ScenarioSwitched { from, to } => { - println!(" {} {} → {}", c_ok("→"), from, c_accent(to)); - } - SolanaCommandResult::ScenarioForked { from, to, at_step } => { - println!( - " {} forked {} → {} at step {}", - c_ok("✓"), - from, - c_accent(to), - at_step - ); - } - SolanaCommandResult::ScenarioDeleted { name } => { - println!(" {} scenario {} deleted", c_ok("✓"), name); - } - SolanaCommandResult::StepDetail { step_index, instruction, runtime_trace, diff_summary } => { - println!(" {} step {} · {}", c_accent("·"), step_index, c_accent(instruction)); - if let Some(trace) = runtime_trace { - if let Some(cu) = trace.get("compute_units").and_then(|v| v.as_u64()) { - println!(" {} {} CU", c_muted("compute units:"), cu); - } - if let Some(err) = trace.get("error").and_then(|v| v.as_str()) { - println!(" {} {}", c_danger("error:"), err); - } - if let Some(logs) = trace.get("logs").and_then(|v| v.as_array()) { - println!(" {} ({} lines)", c_muted("logs:"), logs.len()); - for line in logs.iter().take(20) { - if let Some(s) = line.as_str() { - println!(" {}", c_muted(s)); - } - } - if logs.len() > 20 { - println!(" {}", c_muted(&format!("... +{} more", logs.len() - 20))); - } - } - } - if !diff_summary.is_empty() { - println!(" {} ({})", c_muted("account diffs:"), diff_summary.len()); - for d in diff_summary { - let label = d.name.clone().unwrap_or_else(|| d.address.clone()); - let lam = if d.lamports_delta != 0 { - format!(" Δlamports={}", d.lamports_delta) - } else { - String::new() - }; - println!( - " {} {}{}", - c_accent("·"), - c_accent(&label), - c_muted(&lam), - ); - // Field-level before/after diff. We compare two JSON objects - // and print only the keys that actually changed, plus a - // "(new account)" marker when there was no `before`. - match (d.decoded_before.as_ref(), d.decoded_after.as_ref()) { - (None, None) => { - if d.data_changed { - println!(" {}", c_muted("data changed (not decoded)")); - } - } - (None, Some(after)) => { - println!(" {}", c_muted("(new account, decoded fields:)")); - print_decoded_fields(after, " "); - } - (Some(_), None) => { - println!(" {}", c_muted("(account closed)")); - } - (Some(before), Some(after)) => { - print_decoded_diff(before, after, " "); - } - } - } - } - } - SolanaCommandResult::FindingsList { items } => { - if items.is_empty() { - println!(" {}", c_muted("no findings recorded")); - } else { - for f in items { - println!( - " {} {} [{}] {}", - c_accent(&f.id), - c_warn(&f.severity), - c_muted(&f.created_at), - c_accent(&f.title) - ); - if !f.description.is_empty() { - println!(" {}", c_muted(&f.description)); - } - } - } - } - SolanaCommandResult::Exported { markdown, bytes } => { - println!(" {} markdown report ({} bytes)", c_ok("✓"), bytes); - println!(); - for line in markdown.lines() { - println!(" {}", line); - } - } - SolanaCommandResult::WhoList { - account_type, - instructions, - query_kind, - field_owner, - field_type, - owner_fields, - ix_args, - ix_discriminator_hex, - ix_accounts, - } => { - print_who_list( - account_type, - instructions, - *query_kind, - field_owner.as_deref(), - field_type.as_deref(), - owner_fields.as_deref(), - ix_args.as_deref(), - ix_discriminator_hex.as_deref(), - ix_accounts.as_deref(), - ); - } - SolanaCommandResult::TimelineView { pubkey, label, entries } => { - let header = label.clone().unwrap_or_else(|| pubkey.clone()); - println!(" {} timeline for {} ({})", c_accent("·"), c_accent(&header), c_muted(pubkey)); - if entries.is_empty() { - println!(" {}", c_muted("no mutations recorded for this pubkey")); - } else { - for e in entries { - let lam = if e.lamports_delta != 0 { - format!(" Δlamports={}", e.lamports_delta) - } else { - String::new() - }; - let dat = if e.data_changed { " data" } else { "" }; - println!( - " {} #{} {} ({}){}{}", - c_accent("·"), - e.step_index, - c_accent(&e.instruction), - e.scenario, - c_muted(&lam), - c_muted(dat) - ); - if let Some(after) = &e.after_decoded { - let s = serde_json::to_string(after).unwrap_or_default(); - let preview = if s.len() > 200 { format!("{}…", &s[..200]) } else { s }; - println!(" {}", c_muted(&preview)); - } - } - } - } - SolanaCommandResult::IxInfo { ix, admin_gated } => { - print_ix_info(ix, *admin_gated); - } - SolanaCommandResult::CouplingList { pairs } => { - print_coupling_list(pairs); - } - SolanaCommandResult::AccountTypes { accounts } => { - print_account_types(accounts); - } - SolanaCommandResult::Coverage { overlay } => { - print_coverage_overlay(overlay); - } - SolanaCommandResult::Error { message } => { - eprintln!(" {} {}", c_danger("✗"), message); - } - } - println!(); -} - -fn print_ix_info(ix: &ilold_solana_core::view::IxView, admin_gated: bool) { - println!(); - println!(" {} {}", c_accent("instruction"), ix.name); - if !ix.discriminator_hex.is_empty() { - println!(" {} {}", c_muted("discriminator"), ix.discriminator_hex); - } - println!(); - println!(" {} ({})", c_accent("args"), ix.args.len()); - if ix.args.is_empty() { - println!(" {}", c_muted("(none)")); - } else { - let max = ix - .args - .iter() - .map(|a| a.name.chars().count()) - .max() - .unwrap_or(0); - for a in &ix.args { - println!( - " {} {} {}", - c_accent("·"), - fmt::pad_right(&a.name, max), - c_muted(&a.ty) - ); - } - } - println!(); - println!(" {} ({})", c_accent("accounts"), ix.accounts.len()); - if ix.accounts.is_empty() { - println!(" {}", c_muted("(none)")); - } else { - let max = ix - .accounts - .iter() - .map(|a| a.name.chars().count()) - .max() - .unwrap_or(0); - for a in &ix.accounts { - let mut flags: Vec<&str> = Vec::new(); - if a.signer { - flags.push("signer"); - } - if a.writable { - flags.push("writable"); - } - if a.optional { - flags.push("optional"); - } - let kind_label = match a.kind { - ilold_solana_core::view::AccountKind::Program => "program", - ilold_solana_core::view::AccountKind::System => "system", - ilold_solana_core::view::AccountKind::Sysvar => "sysvar", - ilold_solana_core::view::AccountKind::Pda => "pda", - ilold_solana_core::view::AccountKind::Other => "other", - }; - let suffix = if let Some(addr) = a.address.as_deref() { - format!("{kind_label} const {addr}") - } else if flags.is_empty() { - kind_label.to_string() - } else { - format!("{kind_label} {}", flags.join(" ")) - }; - println!( - " {} {} {}", - c_accent("·"), - fmt::pad_right(&a.name, max), - c_muted(&suffix) - ); - } - } - let pdas: Vec<&ilold_solana_core::view::IxAccountView> = - ix.accounts.iter().filter(|a| a.pda.is_some()).collect(); - if !pdas.is_empty() { + if let SolanaCommandResult::Error { message } = result { + eprintln!(" {} {}", c_danger("✗"), message); println!(); - println!(" {} ({})", c_accent("pdas"), pdas.len()); - for acc in pdas { - let pda = acc.pda.as_ref().expect("filtered above"); - let seeds: Vec<String> = pda - .seeds - .iter() - .map(ilold_solana_core::view::describe_seed_view) - .collect(); - let prog = pda - .program - .clone() - .unwrap_or_else(|| "self".to_string()); - println!( - " {} {} seeds=[{}] program={}", - c_accent("·"), - acc.name, - seeds.join(", "), - c_muted(&prog) - ); - } - } - println!(); - let gated_label = if admin_gated { - c_warn("true (heuristic)").to_string() - } else { - c_muted("false").to_string() - }; - println!(" {} {}", c_muted("admin_gated"), gated_label); -} - -fn print_coupling_list(pairs: &[ilold_solana_core::view::CouplingPair]) { - if pairs.is_empty() { - println!(" {}", c_muted("no instruction pairs share writable accounts")); return; } - let max = pairs - .iter() - .map(|p| p.a.chars().count() + p.b.chars().count() + 5) - .max() - .unwrap_or(0); - for p in pairs { - let pair = format!("{} ↔ {}", p.a, p.b); - println!( - " {} {} {}", - c_accent("·"), - fmt::pad_right(&pair, max), - c_muted(&format!("[{}]", p.shared_writable.join(", "))) - ); - } -} - -fn print_account_types(accounts: &[ilold_solana_core::view::AccountView]) { - if accounts.is_empty() { - println!(" {}", c_muted("No account types declared in IDL")); - return; - } - for at in accounts { - println!( - " {} {} {}", - c_accent("[T]"), - at.name, - c_muted(&at.discriminator_hex) - ); - if at.fields.is_empty() { - println!(" {}", c_muted("(opaque or zero-copy layout)")); - continue; - } - let max = at - .fields - .iter() - .map(|f| f.name.chars().count()) - .max() - .unwrap_or(0); - for f in &at.fields { - println!( - " {} {} {}", - c_accent("·"), - fmt::pad_right(&f.name, max), - c_muted(&f.ty) - ); - } - } -} - -fn print_coverage_overlay(overlay: &ilold_solana_core::overlay::RuntimeOverlay) { - println!(); - println!( - " {} {} {}", - c_accent("Coverage for program"), - overlay.program, - c_muted(&format!("(scenario {})", overlay.scenario)), - ); - println!(); - - let mut ix_keys: std::collections::BTreeSet<&String> = - std::collections::BTreeSet::new(); - ix_keys.extend(overlay.calls_per_ix.keys()); - ix_keys.extend(overlay.failed_per_ix.keys()); - ix_keys.extend(overlay.cu_stats_per_ix.keys()); - - if ix_keys.is_empty() { - println!(" {}", c_muted("no calls recorded yet")); - return; - } - - let cpi_count_per_ix = |ix: &str| -> u32 { - overlay - .cpi_edges - .iter() - .filter(|e| e.from_ix == ix) - .map(|e| e.samples) - .sum() - }; - - let max_ix = ix_keys - .iter() - .map(|k| k.chars().count()) - .max() - .unwrap_or(0) - .max(11); - println!( - " {} {} {} {} {} {}", - fmt::pad_right("Instruction", max_ix), - c_muted("Calls"), - c_muted("Failed"), - c_muted("CU avg"), - c_muted("CU max"), - c_muted("CPIs"), - ); - let mut total_calls: u32 = 0; - let mut total_failed: u32 = 0; - for ix in &ix_keys { - let calls = overlay.calls_per_ix.get(*ix).copied().unwrap_or(0); - let failed = overlay.failed_per_ix.get(*ix).copied().unwrap_or(0); - total_calls += calls; - total_failed += failed; - let (cu_avg, cu_max) = match overlay.cu_stats_per_ix.get(*ix) { - Some(s) => (s.avg.to_string(), s.max.to_string()), - None => ("—".to_string(), "—".to_string()), - }; - let cpi = cpi_count_per_ix(ix); - println!( - " {} {} {} {} {} {}", - fmt::pad_right(ix, max_ix), - fmt::pad_right(&calls.to_string(), 5), - fmt::pad_right(&failed.to_string(), 6), - fmt::pad_right(&cu_avg, 6), - fmt::pad_right(&cu_max, 6), - cpi, - ); - } - if !overlay.cpi_edges.is_empty() { - println!(); - println!(" {}", c_accent("CPIs detected:")); - for edge in &overlay.cpi_edges { - println!( - " {} {} {} {} {}", - c_accent("·"), - edge.from_ix, - c_muted("→"), - edge.to_program, - c_muted(&format!("(depth {}, {}×)", edge.depth, edge.samples)), - ); - } - } + print!("{}", ilold_render::render_solana_result(result)); println!(); - println!( - " {} {} calls, {} failed", - c_muted("Total:"), - total_calls, - total_failed, - ); -} - -#[allow(clippy::too_many_arguments)] -fn print_who_list( - target: &str, - instructions: &[ilold_solana_core::exploration::commands::WhoEntry], - query_kind: ilold_solana_core::exploration::commands::WhoQueryKind, - field_owner: Option<&str>, - field_type: Option<&str>, - owner_fields: Option<&[ilold_solana_core::view::FieldView]>, - ix_args: Option<&[ilold_solana_core::view::ArgView]>, - ix_discriminator_hex: Option<&str>, - ix_accounts: Option<&[ilold_solana_core::exploration::commands::WhoIxAccount]>, -) { - use ilold_solana_core::exploration::commands::WhoQueryKind; - match query_kind { - WhoQueryKind::AccountType => { - println!( - " {} '{}' (account type)", - c_accent("·"), - c_accent(target) - ); - print_field_summary(owner_fields, "fields"); - println!(); - if instructions.is_empty() { - println!(" {}", c_muted("not referenced by any instruction")); - return; - } - println!( - " Referenced by {} instruction{}:", - instructions.len(), - if instructions.len() == 1 { "" } else { "s" } - ); - println!(); - for w in instructions { - print_who_entry_block(w); - } - } - WhoQueryKind::Field => { - let owner = field_owner.unwrap_or("?"); - let ty = field_type.unwrap_or("?"); - println!( - " {} '{}' (field of {}, type {})", - c_accent("·"), - c_accent(target), - c_accent(owner), - c_muted(ty) - ); - print_field_summary(owner_fields, &format!("{owner} struct")); - println!(); - println!( - " {}", - c_warn( - "Heuristic: the following instructions write the owner account." - ) - ); - println!( - " {}", - c_muted( - "Without source-level analysis we cannot tell which one(s)" - ) - ); - println!( - " {}", - c_muted("actually mutate this field; cross-check with `step <idx>`.") - ); - println!(); - if instructions.is_empty() { - println!(" {}", c_muted("(no writers found)")); - return; - } - for w in instructions { - print_who_entry_block(w); - } - } - WhoQueryKind::Instruction => { - println!( - " {} '{}' (instruction)", - c_accent("·"), - c_accent(target) - ); - match ix_args { - Some(args) if !args.is_empty() => { - let parts: Vec<String> = args - .iter() - .map(|a| format!("{}: {}", a.name, a.ty)) - .collect(); - println!(" args: {}", c_muted(&parts.join(", "))); - } - _ => println!(" {}", c_muted("args: (none)")), - } - if let Some(d) = ix_discriminator_hex { - println!(" {} {}", c_muted("discriminator"), d); - } - println!(); - let accounts = ix_accounts.unwrap_or(&[]); - if accounts.is_empty() { - println!(" {}", c_muted("touches no accounts")); - return; - } - println!( - " Touches {} account{}:", - accounts.len(), - if accounts.len() == 1 { "" } else { "s" } - ); - println!(); - for acc in accounts { - print_who_ix_account(acc); - } - } - WhoQueryKind::NotFound => { - println!( - " {} no instruction, account type or field references '{}'", - c_muted("·"), - target - ); - } - } -} - -fn print_field_summary( - fields: Option<&[ilold_solana_core::view::FieldView]>, - label: &str, -) { - match fields { - Some(fs) if !fs.is_empty() => { - let parts: Vec<String> = fs - .iter() - .map(|f| format!("{}: {}", f.name, f.ty)) - .collect(); - println!(" {}: {}", label, c_muted(&parts.join(", "))); - } - Some(_) => { - println!(" {}: {}", label, c_muted("(opaque or zero-copy layout)")); - } - None => {} - } -} - -fn print_who_entry_block(entry: &ilold_solana_core::exploration::commands::WhoEntry) { - let mut flags = Vec::new(); - if entry.signer { - flags.push("signer"); - } - if entry.writable { - flags.push("writable"); - } - let flags_str = flags.join(" "); - println!( - " {} {} (as {}) {}", - c_accent("·"), - c_accent(&entry.instruction), - entry.account_field, - c_muted(&flags_str) - ); - match entry.ix_args.as_ref() { - Some(args) if !args.is_empty() => { - let parts: Vec<String> = args - .iter() - .map(|a| format!("{}: {}", a.name, a.ty)) - .collect(); - println!(" args: {}", c_muted(&parts.join(", "))); - } - Some(_) => println!(" {}", c_muted("args: (none)")), - None => {} - } -} - -fn print_who_ix_account(acc: &ilold_solana_core::exploration::commands::WhoIxAccount) { - let type_label = acc - .account_type - .as_deref() - .map(|t| format!("({t})")) - .unwrap_or_else(|| "(—)".to_string()); - let mut flags = Vec::new(); - if acc.signer { - flags.push("signer"); - } - if acc.writable { - flags.push("writable"); - } - println!( - " {} {} {} {}", - c_accent("·"), - c_accent(&acc.name), - c_muted(&type_label), - c_muted(&flags.join(" ")) - ); - if let Some(fs) = acc.fields.as_ref() { - if !fs.is_empty() { - let parts: Vec<String> = fs - .iter() - .map(|f| format!("{}: {}", f.name, f.ty)) - .collect(); - println!(" struct: {}", c_muted(&parts.join(", "))); - } - } } fn sync_active_scenario( diff --git a/crates/ilold-cli/src/main.rs b/crates/ilold-cli/src/main.rs index 3388e52..98734be 100644 --- a/crates/ilold-cli/src/main.rs +++ b/crates/ilold-cli/src/main.rs @@ -67,6 +67,11 @@ enum Commands { /// Base URL of a running `ilold serve` instance (defaults to http://127.0.0.1:8080) #[arg(long, env = "ILOLD_SERVER_URL", default_value = "http://127.0.0.1:8080")] server_url: String, + /// Target program name. All tool calls route to this program via the + /// `contract` field of `/api/cmd`. Required so the MCP client never + /// has to think about workspace topology. + #[arg(long, env = "ILOLD_CONTRACT")] + contract: String, }, } @@ -98,7 +103,7 @@ async fn main() -> Result<()> { ProjectKind::Solana => explore::run_solana(detected, port).await, } } - Commands::Mcp { server_url } => ilold_mcp::run(server_url).await, + Commands::Mcp { server_url, contract } => ilold_mcp::run(server_url, contract).await, } } diff --git a/crates/ilold-mcp/Cargo.toml b/crates/ilold-mcp/Cargo.toml index bb111e8..a553737 100644 --- a/crates/ilold-mcp/Cargo.toml +++ b/crates/ilold-mcp/Cargo.toml @@ -6,9 +6,16 @@ description = "MCP (Model Context Protocol) server exposing Ilold Solana REPL co [dependencies] ilold-help = { path = "../ilold-help" } +ilold-render = { path = "../ilold-render" } +ilold-solana-core = { path = "../ilold-solana-core" } +ilold-session-core = { path = "../ilold-session-core" } rmcp = { workspace = true } +schemars = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } +thiserror = { workspace = true } tokio = { workspace = true } +tracing = { workspace = true } tracing-subscriber = { workspace = true } +reqwest = { version = "0.12", features = ["json"] } diff --git a/crates/ilold-mcp/src/client.rs b/crates/ilold-mcp/src/client.rs new file mode 100644 index 0000000..f681780 --- /dev/null +++ b/crates/ilold-mcp/src/client.rs @@ -0,0 +1,90 @@ +use ilold_solana_core::exploration::SolanaCommandResult; +use serde_json::Value; + +use crate::error::McpClientError; + +pub struct IloldClient { + base_url: String, + contract: String, + http: reqwest::Client, +} + +impl IloldClient { + pub fn new(base_url: String, contract: String) -> Self { + Self { + base_url: base_url.trim_end_matches('/').to_string(), + contract, + http: reqwest::Client::new(), + } + } + + pub fn base_url(&self) -> &str { + &self.base_url + } + + pub fn contract(&self) -> &str { + &self.contract + } + + pub async fn health_check(&self) -> Result<(), McpClientError> { + let url = format!("{}/api/project/map", self.base_url); + let resp = self + .http + .get(&url) + .send() + .await + .map_err(|e| McpClientError::Unreachable { + url: url.clone(), + reason: e.to_string(), + })?; + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(McpClientError::HttpError { status, body }); + } + let v: Value = resp + .json() + .await + .map_err(|e| McpClientError::InvalidResponse(e.to_string()))?; + let kind = v + .get("kind") + .and_then(|x| x.as_str()) + .unwrap_or("(missing)"); + if kind != "solana" { + return Err(McpClientError::NotSolana { + url: self.base_url.clone(), + kind: kind.to_string(), + }); + } + Ok(()) + } + + pub async fn send_command( + &self, + command: Value, + ) -> Result<SolanaCommandResult, McpClientError> { + let url = format!("{}/api/cmd", self.base_url); + let body = serde_json::json!({ + "contract": self.contract, + "command": command, + }); + let resp = self + .http + .post(&url) + .json(&body) + .send() + .await + .map_err(|e| McpClientError::Unreachable { + url: url.clone(), + reason: e.to_string(), + })?; + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(McpClientError::HttpError { status, body }); + } + resp.json::<SolanaCommandResult>() + .await + .map_err(|e| McpClientError::InvalidResponse(e.to_string())) + } +} diff --git a/crates/ilold-mcp/src/error.rs b/crates/ilold-mcp/src/error.rs new file mode 100644 index 0000000..3e5d16a --- /dev/null +++ b/crates/ilold-mcp/src/error.rs @@ -0,0 +1,13 @@ +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum McpClientError { + #[error("cannot reach Ilold server at {url}: {reason}")] + Unreachable { url: String, reason: String }, + #[error("server at {url} is not Solana (kind={kind})")] + NotSolana { url: String, kind: String }, + #[error("HTTP {status}: {body}")] + HttpError { status: u16, body: String }, + #[error("invalid response from server: {0}")] + InvalidResponse(String), +} diff --git a/crates/ilold-mcp/src/lib.rs b/crates/ilold-mcp/src/lib.rs index 7707b13..f22897a 100644 --- a/crates/ilold-mcp/src/lib.rs +++ b/crates/ilold-mcp/src/lib.rs @@ -1,20 +1,35 @@ -use anyhow::Result; +use std::sync::Arc; + +use anyhow::{Context, Result}; use rmcp::ServiceExt; use rmcp::transport::io::stdio; +pub mod client; +pub mod error; +pub mod schema; pub mod server; pub mod tools; -pub async fn run(server_url: String) -> Result<()> { +pub use client::IloldClient; +pub use error::McpClientError; + +pub async fn run(server_url: String, contract: String) -> Result<()> { tracing_subscriber::fmt() .with_writer(std::io::stderr) .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("off")), ) - .init(); + .try_init() + .ok(); + + let client = Arc::new(IloldClient::new(server_url, contract)); + client + .health_check() + .await + .context("Ilold backend health-check failed")?; - let handler = server::IloldMcpServer::new(server_url); + let handler = server::IloldMcpServer::new(client); let service = handler.serve(stdio()).await?; service.waiting().await?; Ok(()) diff --git a/crates/ilold-mcp/src/schema.rs b/crates/ilold-mcp/src/schema.rs new file mode 100644 index 0000000..97154ad --- /dev/null +++ b/crates/ilold-mcp/src/schema.rs @@ -0,0 +1,346 @@ +use schemars::Schema; +use serde_json::{Map, Value, json}; + +/// JSON Schema for the input arguments of a tool. The MCP spec requires +/// `inputSchema` to be a JSON object describing the `arguments` payload. +/// We hand-roll the schemas per tool (rather than slicing the big +/// SolanaCommand schema) because some variants need MCP-specific shapes: +/// for tools without arguments we return the canonical empty-object schema, +/// for tools with arguments we lift the variant fields to the top level so +/// the LLM sees `{ ix, args, accounts }` instead of `{ Call: { ix, ... } }`. +pub fn schema_for_tool(name: &str) -> Value { + match name { + "ilold_call" => schema_for_call(), + "ilold_info" => string_only("ix", "Instruction name to inspect"), + "ilold_pda" => string_only("instruction", "Instruction whose PDAs to list"), + "ilold_inspect" => string_only("pubkey", "Account pubkey (or named keypair)"), + "ilold_who" => string_only( + "account_type", + "Query target: account type, instruction or struct field", + ), + "ilold_timeline" => string_only("pubkey", "Account pubkey (or named keypair)"), + "ilold_users_new" => schema_users_new(), + "ilold_airdrop" => schema_airdrop(), + "ilold_time_warp" => schema_time_warp(), + "ilold_finding" => schema_finding(), + "ilold_note" => string_only("text", "Free-form annotation body"), + "ilold_status" => schema_status(), + "ilold_step" => schema_step(), + "ilold_save" => schema_save(), + "ilold_load" => string_only("json", "Saved scenario JSON to restore"), + "ilold_scenario" => schema_scenario(), + "ilold_export" => schema_export(), + // Tools without arguments (Funcs, Back, Clear, State, Session, + // Users, Vars, Findings, Coupling, Coverage, FuncsAll, Programs, + // Sequence). Programs is a synthesised name; the registry uses + // `ilold_programs` for the workspace listing handler. + _ => empty_object_schema(), + } +} + +fn empty_object_schema() -> Value { + json!({ + "type": "object", + "properties": {}, + "additionalProperties": false + }) +} + +fn string_only(field: &str, description: &str) -> Value { + let mut props = Map::new(); + props.insert( + field.to_string(), + json!({ "type": "string", "description": description }), + ); + json!({ + "type": "object", + "required": [field], + "properties": props, + "additionalProperties": false + }) +} + +fn schema_for_call() -> Value { + json!({ + "type": "object", + "required": ["ix"], + "properties": { + "ix": { + "type": "string", + "description": "Instruction name, e.g. stake or initialize_pool. Discover with ilold_funcs." + }, + "args": { + "type": "object", + "description": "Anchor instruction args as a JSON object (e.g. {\"amount\": 1000}). Required keys depend on the instruction — call `ilold_info <ix>` first to discover them. Defaults to {}.", + "default": {} + }, + "accounts": { + "type": "object", + "additionalProperties": { "type": "string" }, + "description": "Map of account-name (from the IDL) to user handle or pubkey (e.g. {\"pool\":\"pool\",\"user\":\"alice\"}).", + "default": {} + }, + "signers": { + "type": "array", + "items": { "type": "string" }, + "description": "Override the default signer set. Empty list means use the IDL defaults.", + "default": [] + } + }, + "additionalProperties": false + }) +} + +fn schema_users_new() -> Value { + json!({ + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string", "description": "Handle for the new keypair (e.g. alice)" }, + "lamports": { + "type": "integer", + "minimum": 0, + "description": "Initial airdrop in lamports (defaults to 10_000_000_000 = 10 SOL)" + } + }, + "additionalProperties": false + }) +} + +fn schema_airdrop() -> Value { + json!({ + "type": "object", + "required": ["user", "lamports"], + "properties": { + "user": { "type": "string", "description": "Existing user handle" }, + "lamports": { + "type": "integer", + "minimum": 0, + "description": "Extra lamports to add" + } + }, + "additionalProperties": false + }) +} + +fn schema_time_warp() -> Value { + json!({ + "type": "object", + "required": ["delta_seconds"], + "properties": { + "delta_seconds": { + "type": "integer", + "description": "Seconds to advance (positive) or rewind (negative)" + } + }, + "additionalProperties": false + }) +} + +fn schema_finding() -> Value { + json!({ + "type": "object", + "required": ["severity", "title", "description"], + "properties": { + "severity": { + "type": "string", + "enum": ["Critical", "High", "Medium", "Low", "Informational"] + }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "recommendation": { + "type": "string", + "description": "Optional remediation suggestion" + } + }, + "additionalProperties": false + }) +} + +fn schema_status() -> Value { + json!({ + "type": "object", + "required": ["ix", "status"], + "properties": { + "ix": { "type": "string", "description": "Instruction name" }, + "status": { + "type": "string", + "enum": [ + "NotReviewed", + "InProgress", + "Reviewed", + "Suspicious", + "Vulnerable", + "Clean" + ] + } + }, + "additionalProperties": false + }) +} + +fn schema_step() -> Value { + json!({ + "type": "object", + "required": ["index"], + "properties": { + "index": { + "type": "integer", + "minimum": 0, + "description": "Zero-based step index to re-inspect" + } + }, + "additionalProperties": false + }) +} + +fn schema_save() -> Value { + json!({ + "type": "object", + "properties": { + "with_keypairs": { + "type": "boolean", + "description": "When true the saved JSON embeds the per-scenario keypairs (do NOT commit the file).", + "default": false + } + }, + "additionalProperties": false + }) +} + +fn schema_scenario() -> Value { + json!({ + "type": "object", + "required": ["sub"], + "properties": { + "sub": { + "description": "Sub-command. Use the variant matching the desired action.", + "oneOf": [ + { "type": "string", "enum": ["List"], "description": "List all scenarios" }, + { + "type": "object", + "required": ["New"], + "properties": { "New": { + "type": "object", + "required": ["name"], + "properties": { "name": { "type": "string" } }, + "additionalProperties": false + } }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["Switch"], + "properties": { "Switch": { + "type": "object", + "required": ["name"], + "properties": { "name": { "type": "string" } }, + "additionalProperties": false + } }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["Fork"], + "properties": { "Fork": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "at_step": { "type": "integer", "minimum": 0 } + }, + "additionalProperties": false + } }, + "additionalProperties": false + }, + { + "type": "object", + "required": ["Delete"], + "properties": { "Delete": { + "type": "object", + "required": ["name"], + "properties": { "name": { "type": "string" } }, + "additionalProperties": false + } }, + "additionalProperties": false + } + ] + } + }, + "additionalProperties": false + }) +} + +fn schema_export() -> Value { + json!({ + "type": "object", + "properties": { + "metadata": { + "type": "object", + "description": "Optional report metadata pass-through.", + "properties": { + "auditor": { "type": "string" }, + "project_version": { "type": "string" }, + "audit_date": { "type": "string", "description": "YYYY-MM-DD" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }) +} + +/// Returns the schemars-generated schema for the full SolanaCommand enum. +/// Useful for diagnostics and for the schema_consistency cross-check test. +pub fn full_solana_command_schema() -> Schema { + schemars::schema_for!(ilold_solana_core::exploration::SolanaCommand) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schema_for_call_requires_ix() { + let s = schema_for_tool("ilold_call"); + let req = s.get("required").and_then(|r| r.as_array()).unwrap(); + assert!(req.iter().any(|v| v.as_str() == Some("ix"))); + } + + #[test] + fn schema_for_funcs_has_no_properties() { + let s = schema_for_tool("ilold_funcs"); + let props = s.get("properties").and_then(|p| p.as_object()).unwrap(); + assert!(props.is_empty()); + assert_eq!(s.get("additionalProperties"), Some(&Value::Bool(false))); + } + + #[test] + fn schema_for_scenario_is_oneof() { + let s = schema_for_tool("ilold_scenario"); + let sub = s + .get("properties") + .and_then(|p| p.get("sub")) + .expect("sub property"); + let one_of = sub.get("oneOf").and_then(|o| o.as_array()).unwrap(); + assert_eq!(one_of.len(), 5); + } + + #[test] + fn schema_for_call_has_args_description() { + let s = schema_for_tool("ilold_call"); + let args = s + .get("properties") + .and_then(|p| p.get("args")) + .expect("args property"); + let desc = args.get("description").and_then(|d| d.as_str()).unwrap(); + assert!(desc.contains("ilold_info")); + } + + #[test] + fn full_solana_command_schema_is_object() { + let s = full_solana_command_schema(); + let v = serde_json::to_value(&s).unwrap(); + assert!(v.is_object()); + } +} diff --git a/crates/ilold-mcp/src/server.rs b/crates/ilold-mcp/src/server.rs index 29f9a94..f35bcd3 100644 --- a/crates/ilold-mcp/src/server.rs +++ b/crates/ilold-mcp/src/server.rs @@ -1,23 +1,26 @@ +use std::sync::Arc; + +use ilold_render::fmt::strip_ansi; +use ilold_render::render_solana_result; +use ilold_solana_core::exploration::SolanaCommandResult; use rmcp::ServerHandler; use rmcp::model::{ CallToolRequestParams, CallToolResult, Content, Implementation, ListToolsResult, PaginatedRequestParams, ServerCapabilities, ServerInfo, }; use rmcp::service::{RequestContext, RoleServer}; +use serde_json::Value; +use crate::client::IloldClient; use crate::tools; pub struct IloldMcpServer { - server_url: String, + client: Arc<IloldClient>, } impl IloldMcpServer { - pub fn new(server_url: String) -> Self { - Self { server_url } - } - - pub fn server_url(&self) -> &str { - &self.server_url + pub fn new(client: Arc<IloldClient>) -> Self { + Self { client } } } @@ -26,11 +29,12 @@ impl ServerHandler for IloldMcpServer { let mut info = ServerInfo::default(); info.capabilities = ServerCapabilities::builder().enable_tools().build(); info.server_info = Implementation::new("ilold-mcp", env!("CARGO_PKG_VERSION")); - info.instructions = Some( - "Ilold MCP server. Exposes Solana REPL commands as MCP tools. \ - Tool handlers are stubs in T-R55a; functional dispatch lands in T-R55b." - .to_string(), - ); + info.instructions = Some(format!( + "Ilold MCP server. Exposes Solana REPL commands as MCP tools \ + backed by the Ilold backend at {}. Target program: {}.", + self.client.base_url(), + self.client.contract(), + )); info } @@ -48,10 +52,86 @@ impl ServerHandler for IloldMcpServer { request: CallToolRequestParams, _context: RequestContext<RoleServer>, ) -> Result<CallToolResult, rmcp::ErrorData> { - let msg = format!( - "tool `{}` not yet implemented (T-R55b). server_url={}", - request.name, self.server_url - ); - Ok(CallToolResult::error(vec![Content::text(msg)])) + let tool_name = request.name.to_string(); + let args_value = request.arguments.map(Value::Object); + let res = dispatch(&self.client, &tool_name, args_value.as_ref()).await; + Ok(res) } } + +async fn dispatch( + client: &IloldClient, + tool_name: &str, + arguments: Option<&Value>, +) -> CallToolResult { + if tool_name == "ilold_programs" { + return handle_programs(client).await; + } + let command = match tools::build_command(tool_name, arguments) { + Ok(cmd) => cmd, + Err(message) => return error_result(format!("Invalid arguments: {message}")), + }; + match client.send_command(command).await { + Ok(result) => build_tool_response(&result), + Err(err) => error_result(err.to_string()), + } +} + +async fn handle_programs(client: &IloldClient) -> CallToolResult { + let url = format!("{}/api/project/map", client.base_url()); + let resp = match reqwest::get(&url).await { + Ok(r) => r, + Err(e) => return error_result(format!("cannot reach {url}: {e}")), + }; + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return error_result(format!("HTTP {status}: {body}")); + } + let value: Value = match resp.json().await { + Ok(v) => v, + Err(e) => return error_result(format!("invalid response: {e}")), + }; + let text = render_programs(&value, client.contract()); + let structured = value.clone(); + let mut out = CallToolResult::structured(serde_json::json!({ "project_map": structured })); + out.content = vec![Content::text(text)]; + out +} + +fn render_programs(map: &Value, active_contract: &str) -> String { + let mut out = String::new(); + out.push_str(&format!( + " workspace programs (active: {active_contract})\n" + )); + if let Some(programs) = map.get("programs").and_then(|p| p.as_array()) { + for p in programs { + let name = p.get("name").and_then(|n| n.as_str()).unwrap_or("?"); + let pid = p.get("program_id").and_then(|n| n.as_str()).unwrap_or("?"); + let ix_count = p + .get("instructions") + .and_then(|i| i.as_array()) + .map(|a| a.len()) + .unwrap_or(0); + let marker = if name == active_contract { " ← active" } else { "" }; + out.push_str(&format!( + " · {name} (program_id={pid}, instructions={ix_count}){marker}\n" + )); + } + } + out +} + +fn build_tool_response(result: &SolanaCommandResult) -> CallToolResult { + let structured = serde_json::to_value(result).unwrap_or(Value::Null); + let pretty = strip_ansi(&render_solana_result(result)); + let is_error = matches!(result, SolanaCommandResult::Error { .. }); + let mut out = CallToolResult::structured(structured); + out.content = vec![Content::text(pretty)]; + out.is_error = Some(is_error); + out +} + +fn error_result(message: String) -> CallToolResult { + CallToolResult::error(vec![Content::text(message)]) +} diff --git a/crates/ilold-mcp/src/tools.rs b/crates/ilold-mcp/src/tools.rs index 11dadbe..d197d22 100644 --- a/crates/ilold-mcp/src/tools.rs +++ b/crates/ilold-mcp/src/tools.rs @@ -4,7 +4,15 @@ use ilold_help::{HelpBlock, SOLANA_HELP_BLOCKS}; use rmcp::model::{JsonObject, Tool}; use serde_json::{Map, Value, json}; -const EXCLUDED_ALIASES: &[&str] = &["?", "help", "h", "quit", "q", "exit", "browser"]; +use crate::schema::schema_for_tool; + +// `use` is a REPL-only meta command that switches the active program in the +// CLI prompt; it has no SolanaCommand variant and the MCP transport selects +// the program via the `--contract` flag instead, so the tool is excluded +// from the registry. +const EXCLUDED_ALIASES: &[&str] = &[ + "?", "help", "h", "quit", "q", "exit", "browser", "use", +]; const TOOL_NAME_PREFIX: &str = "ilold_"; pub fn build_tool_registry() -> Vec<Tool> { @@ -15,7 +23,8 @@ pub fn build_tool_registry() -> Vec<Tool> { let canonical = canonical_alias(b); let name = format!("{TOOL_NAME_PREFIX}{}", normalize_name(canonical)); let description = format_description(b); - Tool::new(name, description, Arc::new(empty_object_schema())) + let schema = value_to_json_object(schema_for_tool(&name)); + Tool::new(name, description, Arc::new(schema)) }) .collect() } @@ -53,26 +62,149 @@ fn format_description(block: &HelpBlock) -> String { out } -fn empty_object_schema() -> JsonObject { - let v = json!({ - "type": "object", - "properties": {}, - "additionalProperties": false - }); +fn value_to_json_object(v: Value) -> JsonObject { match v { Value::Object(map) => map, _ => Map::new(), } } +/// Translate MCP tool `name` + `arguments` into the SolanaCommand JSON value +/// that the backend `/api/cmd` endpoint expects. Returns the JSON `command` +/// payload only — the `IloldClient` wraps it with the `contract` field. +pub fn build_command(name: &str, arguments: Option<&Value>) -> Result<Value, String> { + let args = arguments.cloned().unwrap_or_else(|| json!({})); + let args_obj = args.as_object().cloned().unwrap_or_default(); + match name { + "ilold_call" => { + let ix = require_str(&args_obj, "ix")?; + let call_args = args_obj.get("args").cloned().unwrap_or_else(|| json!({})); + let accounts = args_obj + .get("accounts") + .cloned() + .unwrap_or_else(|| json!({})); + let signers = args_obj + .get("signers") + .cloned() + .unwrap_or_else(|| json!([])); + Ok(json!({ + "Call": { + "ix": ix, + "args": call_args, + "accounts": accounts, + "signers": signers, + } + })) + } + "ilold_back" => Ok(json!("Back")), + "ilold_clear" => Ok(json!("Clear")), + "ilold_funcs" | "ilold_functions" => Ok(json!("Funcs")), + "ilold_funcs_all" => Ok(json!("Funcs")), + "ilold_state" => Ok(json!("State")), + "ilold_session" | "ilold_sequence" => Ok(json!("Session")), + "ilold_info" => Ok(json!({ "Info": { "ix": require_str(&args_obj, "ix")? } })), + "ilold_vars" => Ok(json!("Vars")), + "ilold_users" => Ok(json!("Users")), + "ilold_users_new" => { + let name = require_str(&args_obj, "name")?; + let mut obj = json!({ "name": name }); + if let Some(lamports) = args_obj.get("lamports") { + obj["lamports"] = lamports.clone(); + } + Ok(json!({ "UsersNew": obj })) + } + "ilold_airdrop" => { + let user = require_str(&args_obj, "user")?; + let lamports = args_obj + .get("lamports") + .ok_or_else(|| "missing required field: lamports".to_string())? + .clone(); + Ok(json!({ "Airdrop": { "user": user, "lamports": lamports } })) + } + "ilold_time_warp" => { + let delta = args_obj + .get("delta_seconds") + .ok_or_else(|| "missing required field: delta_seconds".to_string())? + .clone(); + Ok(json!({ "TimeWarp": { "delta_seconds": delta } })) + } + "ilold_pda" => Ok(json!({ "Pda": { "instruction": require_str(&args_obj, "instruction")? } })), + "ilold_inspect" => Ok(json!({ "Inspect": { "pubkey": require_str(&args_obj, "pubkey")? } })), + "ilold_step" => { + let index = args_obj + .get("index") + .ok_or_else(|| "missing required field: index".to_string())? + .clone(); + Ok(json!({ "Step": { "index": index } })) + } + "ilold_who" => Ok(json!({ "Who": { "account_type": require_str(&args_obj, "account_type")? } })), + "ilold_timeline" => Ok(json!({ "Timeline": { "pubkey": require_str(&args_obj, "pubkey")? } })), + "ilold_coupling" => Ok(json!("Coupling")), + "ilold_coverage" => Ok(json!("Coverage")), + "ilold_finding" => { + let severity = require_str(&args_obj, "severity")?; + let title = require_str(&args_obj, "title")?; + let description = require_str(&args_obj, "description")?; + let mut obj = json!({ + "severity": severity, + "title": title, + "description": description, + }); + if let Some(rec) = args_obj.get("recommendation") { + obj["recommendation"] = rec.clone(); + } + Ok(json!({ "Finding": obj })) + } + "ilold_findings" => Ok(json!("Findings")), + "ilold_note" => Ok(json!({ "Note": { "text": require_str(&args_obj, "text")? } })), + "ilold_status" => Ok(json!({ + "Status": { + "ix": require_str(&args_obj, "ix")?, + "status": require_str(&args_obj, "status")?, + } + })), + "ilold_export" => { + if let Some(meta) = args_obj.get("metadata") { + Ok(json!({ "Export": { "metadata": meta } })) + } else { + Ok(json!({ "Export": { "metadata": null } })) + } + } + "ilold_scenario" => { + let sub = args_obj + .get("sub") + .cloned() + .ok_or_else(|| "missing required field: sub".to_string())?; + Ok(json!({ "Scenario": { "sub": sub } })) + } + "ilold_save" => { + let with_keypairs = args_obj + .get("with_keypairs") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + Ok(json!({ "SaveSession": { "with_keypairs": with_keypairs } })) + } + "ilold_load" => Ok(json!({ "LoadSession": { "json": require_str(&args_obj, "json")? } })), + other => Err(format!("unknown tool: {other}")), + } +} + +fn require_str(obj: &Map<String, Value>, key: &str) -> Result<String, String> { + obj.get(key) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .ok_or_else(|| format!("missing or non-string field: {key}")) +} + #[cfg(test)] mod tests { use super::*; + use ilold_solana_core::exploration::SolanaCommand; #[test] - fn tool_registry_not_empty() { + fn tool_registry_has_29_entries() { let tools = build_tool_registry(); - assert_eq!(tools.len(), 30); + assert_eq!(tools.len(), 29); } #[test] @@ -95,6 +227,7 @@ mod tests { "ilold_q", "ilold_exit", "ilold_browser", + "ilold_use", ]; for f in forbidden { assert!( @@ -119,12 +252,34 @@ mod tests { assert_eq!(canonical_alias(&block), "call"); } + #[test] + fn canonical_alias_falls_back_to_first_when_all_short() { + let block = HelpBlock { + title: "? | h", + aliases: &["?", "h"], + purpose: "", + syntax: &[], + flags: &[], + examples: &[], + returns: "", + see_also: &[], + }; + assert_eq!(canonical_alias(&block), "?"); + } + #[test] fn normalize_name_replaces_dash() { assert_eq!(normalize_name("funcs-all"), "funcs_all"); assert_eq!(normalize_name("time-warp"), "time_warp"); } + #[test] + fn normalize_name_idempotent() { + let once = normalize_name("time-warp"); + let twice = normalize_name(&once); + assert_eq!(once, twice); + } + #[test] fn names_have_ilold_prefix() { let tools = build_tool_registry(); @@ -136,4 +291,70 @@ mod tests { ); } } + + #[test] + fn description_includes_purpose_and_returns() { + let tools = build_tool_registry(); + let call = tools.iter().find(|t| t.name == "ilold_call").expect("call"); + let desc = call.description.as_deref().unwrap_or(""); + assert!(desc.contains("Anchor instruction")); + assert!(desc.contains("Returns:")); + } + + #[test] + fn build_command_call_translates_minimal() { + let v = build_command("ilold_call", Some(&json!({"ix": "stake"}))).unwrap(); + let cmd: SolanaCommand = serde_json::from_value(v).expect("deserialize"); + match cmd { + SolanaCommand::Call { ix, args, accounts, signers } => { + assert_eq!(ix, "stake"); + assert!(args.as_object().is_some_and(|o| o.is_empty())); + assert!(accounts.is_empty()); + assert!(signers.is_empty()); + } + _ => panic!("expected Call variant"), + } + } + + #[test] + fn build_command_funcs_unit_variant() { + let v = build_command("ilold_funcs", None).unwrap(); + let cmd: SolanaCommand = serde_json::from_value(v).expect("deserialize"); + assert!(matches!(cmd, SolanaCommand::Funcs)); + } + + #[test] + fn build_command_scenario_sub_passthrough() { + let v = build_command( + "ilold_scenario", + Some(&json!({ "sub": { "New": { "name": "branch1" } } })), + ) + .unwrap(); + let cmd: SolanaCommand = serde_json::from_value(v).expect("deserialize"); + assert!(matches!(cmd, SolanaCommand::Scenario { .. })); + } + + #[test] + fn build_command_users_new_includes_default_lamports() { + // Lamports omitted on the wire — backend applies its own default. + let v = build_command("ilold_users_new", Some(&json!({ "name": "alice" }))).unwrap(); + let cmd: SolanaCommand = serde_json::from_value(v).expect("deserialize"); + match cmd { + SolanaCommand::UsersNew { name, lamports } => { + assert_eq!(name, "alice"); + assert!(lamports > 0, "default lamports should be positive"); + } + _ => panic!("expected UsersNew"), + } + } + + #[test] + fn build_command_time_warp_passes_delta() { + let v = build_command("ilold_time_warp", Some(&json!({ "delta_seconds": 86400 }))).unwrap(); + let cmd: SolanaCommand = serde_json::from_value(v).expect("deserialize"); + match cmd { + SolanaCommand::TimeWarp { delta_seconds } => assert_eq!(delta_seconds, 86400), + _ => panic!("expected TimeWarp"), + } + } } diff --git a/crates/ilold-mcp/tests/mcp_stdio_lists_tools.rs b/crates/ilold-mcp/tests/mcp_stdio_lists_tools.rs new file mode 100644 index 0000000..300e625 --- /dev/null +++ b/crates/ilold-mcp/tests/mcp_stdio_lists_tools.rs @@ -0,0 +1,40 @@ +use ilold_mcp::tools::build_tool_registry; + +#[test] +fn registry_lists_29_solana_tools() { + let tools = build_tool_registry(); + assert_eq!(tools.len(), 29); + let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); + assert!(names.contains(&"ilold_call")); + assert!(names.contains(&"ilold_funcs")); + assert!(names.contains(&"ilold_coverage")); + assert!(!names.contains(&"ilold_use")); + assert!(!names.contains(&"ilold_help")); + assert!(!names.contains(&"ilold_quit")); +} + +#[test] +fn every_tool_has_valid_input_schema() { + let tools = build_tool_registry(); + for t in &tools { + let schema = serde_json::to_value(&*t.input_schema).expect("schema serializes"); + assert!(schema.is_object(), "{}: schema must be JSON object", t.name); + assert_eq!( + schema.get("type").and_then(|v| v.as_str()), + Some("object"), + "{}: schema type must be 'object'", + t.name + ); + } +} + +#[test] +fn destructive_tool_names_present() { + // SDD-05 notes destructive tools rely on the MCP client's heuristic + // "name contains clear|delete" to prompt the human. Make sure those + // names actually exist verbatim. + let tools = build_tool_registry(); + let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); + assert!(names.contains(&"ilold_clear")); + assert!(names.contains(&"ilold_scenario")); +} diff --git a/crates/ilold-render/Cargo.toml b/crates/ilold-render/Cargo.toml new file mode 100644 index 0000000..db1df0d --- /dev/null +++ b/crates/ilold-render/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "ilold-render" +version.workspace = true +edition.workspace = true +description = "Shared text renderers for Ilold (REPL + MCP). Output is byte-identical to the legacy CLI prints." + +[dependencies] +ilold-core = { path = "../ilold-core" } +ilold-solana-core = { path = "../ilold-solana-core" } +ilold-session-core = { path = "../ilold-session-core" } +serde_json = { workspace = true } +colored = "3" diff --git a/crates/ilold-render/src/colors.rs b/crates/ilold-render/src/colors.rs new file mode 100644 index 0000000..8a156de --- /dev/null +++ b/crates/ilold-render/src/colors.rs @@ -0,0 +1,18 @@ +use colored::{ColoredString, Colorize}; +use ilold_core::classify::entry_points::AccessLevel; + +pub fn c_accent(s: &str) -> ColoredString { s.truecolor(140, 170, 210) } +pub fn c_warn(s: &str) -> ColoredString { s.truecolor(180, 150, 80) } +pub fn c_danger(s: &str) -> ColoredString { s.truecolor(170, 90, 90) } +pub fn c_ok(s: &str) -> ColoredString { s.truecolor(100, 160, 110) } +pub fn c_muted(s: &str) -> ColoredString { s.truecolor(110, 120, 140) } +pub fn c_bright(s: &str) -> ColoredString { s.truecolor(190, 200, 215).bold() } + +pub fn access_colored(access: &AccessLevel) -> ColoredString { + match access { + AccessLevel::Public => c_accent("[P]"), + AccessLevel::Restricted { .. } => c_warn("[R]"), + AccessLevel::Internal => c_muted("[I]"), + AccessLevel::Special { .. } => c_muted("[S]"), + } +} diff --git a/crates/ilold-render/src/fmt.rs b/crates/ilold-render/src/fmt.rs new file mode 100644 index 0000000..9f6f776 --- /dev/null +++ b/crates/ilold-render/src/fmt.rs @@ -0,0 +1,27 @@ +pub fn pad_right(s: &str, width: usize) -> String { + let len = s.chars().count(); + if len >= width { + s.to_string() + } else { + format!("{}{}", s, " ".repeat(width - len)) + } +} + +pub fn strip_ansi(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut in_esc = false; + for c in s.chars() { + if in_esc { + if c == 'm' { + in_esc = false; + } + continue; + } + if c == '\u{1b}' { + in_esc = true; + continue; + } + out.push(c); + } + out +} diff --git a/crates/ilold-render/src/lib.rs b/crates/ilold-render/src/lib.rs new file mode 100644 index 0000000..37c6824 --- /dev/null +++ b/crates/ilold-render/src/lib.rs @@ -0,0 +1,5 @@ +pub mod colors; +pub mod fmt; +pub mod solana; + +pub use solana::render_solana_result; diff --git a/crates/ilold-render/src/solana.rs b/crates/ilold-render/src/solana.rs new file mode 100644 index 0000000..fe09265 --- /dev/null +++ b/crates/ilold-render/src/solana.rs @@ -0,0 +1,1100 @@ +use std::fmt::Write; + +use ilold_solana_core::exploration::SolanaCommandResult; +use ilold_solana_core::exploration::commands::{WhoEntry, WhoIxAccount, WhoQueryKind}; +use ilold_solana_core::overlay::RuntimeOverlay; +use ilold_solana_core::view::{ + AccountKind, AccountView, ArgView, CouplingPair, FieldView, IxAccountView, IxView, + describe_seed_view, +}; + +use crate::colors::{c_accent, c_danger, c_muted, c_ok, c_warn}; +use crate::fmt::pad_right; + +/// Render a SolanaCommandResult to text. Output is byte-identical to the +/// legacy `print_solana_result` body (without the leading/trailing blank +/// line); callers add framing whitespace where appropriate. +pub fn render_solana_result(result: &SolanaCommandResult) -> String { + let mut out = String::new(); + match result { + SolanaCommandResult::StepAdded { + step_index, + instruction, + logs_excerpt, + account_diffs_count, + compute_units, + error, + } => { + let failed = error.is_some() + || logs_excerpt.iter().any(|l| { + let s = l.as_str(); + s.contains("AnchorError") || s.contains("failed:") || s.contains("panicked") + }); + let (mark, label) = if failed { + (c_danger("✗").to_string(), c_danger("FAILED").to_string()) + } else { + (c_ok("✓").to_string(), c_ok("ok").to_string()) + }; + writeln!( + out, + " {} step {} [{}]: {} {}", + mark, + step_index, + label, + c_accent(instruction), + c_muted(&format!( + "({} CU, {} diffs)", + compute_units, account_diffs_count + )) + ) + .ok(); + for log in logs_excerpt { + if log.contains("AnchorError") + || log.contains("failed:") + || log.contains("panicked") + { + writeln!(out, " {}", c_danger(log)).ok(); + } else { + writeln!(out, " {}", c_muted(log)).ok(); + } + } + } + SolanaCommandResult::CallFailed { + instruction, + logs_excerpt, + compute_units, + error, + } => { + writeln!( + out, + " {} {}: {} {}", + c_danger("✗"), + c_danger("FAILED"), + c_accent(instruction), + c_muted(&format!("({} CU, not recorded)", compute_units)), + ) + .ok(); + writeln!(out, " {} {}", c_danger("error:"), error).ok(); + for log in logs_excerpt { + if log.contains("AnchorError") + || log.contains("failed:") + || log.contains("panicked") + { + writeln!(out, " {}", c_danger(log)).ok(); + } else { + writeln!(out, " {}", c_muted(log)).ok(); + } + } + } + SolanaCommandResult::StepRemoved { remaining } => { + writeln!(out, " {} step undone ({} remaining)", c_ok("✓"), remaining).ok(); + } + SolanaCommandResult::Cleared => { + writeln!(out, " {} session cleared", c_ok("✓")).ok(); + } + SolanaCommandResult::InstructionList { items } => { + for ix in items { + let badge = if ix.has_pdas { c_accent("[PDA]") } else { c_muted("[ix]") }; + let signers = if ix.signers.is_empty() { + String::new() + } else { + format!(" signers: {}", ix.signers.join(",")) + }; + writeln!( + out, + " {} {} {}{}", + badge, + ix.name, + c_muted(&format!( + "(args:{} accounts:{})", + ix.args_count, ix.accounts_count + )), + c_muted(&signers) + ) + .ok(); + } + } + SolanaCommandResult::StateView { accounts } => { + if accounts.is_empty() { + writeln!(out, " {}", c_muted("No accounts mutated yet")).ok(); + } + for a in accounts { + writeln!( + out, + " {} {} {} {}", + c_accent("[A]"), + c_accent(&a.label), + c_muted(&format!("({} lamports)", a.lamports)), + c_muted(&a.pubkey), + ) + .ok(); + match a.decoded.as_ref() { + None => { + writeln!(out, " {}", c_muted("<not decoded>")).ok(); + } + Some(serde_json::Value::Object(map)) => { + let max = map + .keys() + .map(|k| k.chars().count()) + .max() + .unwrap_or(0); + for (k, v) in map { + let val = match v { + serde_json::Value::String(s) => s.clone(), + _ => serde_json::to_string(v).unwrap_or_default(), + }; + writeln!( + out, + " {} {}", + c_muted(&format!("{:width$}", k, width = max)), + val, + ) + .ok(); + } + } + Some(other) => { + writeln!( + out, + " {}", + c_muted(&serde_json::to_string(other).unwrap_or_default()), + ) + .ok(); + } + } + } + } + SolanaCommandResult::SessionView { + program, + scenario, + steps, + findings_count, + } => { + writeln!( + out, + " program={} scenario={} steps={} findings={}", + c_accent(program), + c_accent(scenario), + steps.len(), + findings_count + ) + .ok(); + for (i, s) in steps.iter().enumerate() { + writeln!(out, " {} {}", c_muted(&format!("{i}.")), s).ok(); + } + } + SolanaCommandResult::UserList { users } => { + if users.is_empty() { + writeln!( + out, + " {}", + c_muted("No users — create with 'users new <name>'") + ) + .ok(); + } + for u in users { + writeln!( + out, + " {} {} {} {}", + c_accent("[U]"), + u.name, + c_muted(&u.pubkey), + c_muted(&format!("{} lamports", u.lamports)) + ) + .ok(); + } + } + SolanaCommandResult::UserCreated { name, pubkey, lamports } => { + writeln!( + out, + " {} user {} created at {} with {} lamports", + c_ok("✓"), + c_accent(name), + c_muted(pubkey), + lamports + ) + .ok(); + } + SolanaCommandResult::Airdropped { name, pubkey, total_lamports } => { + writeln!( + out, + " {} {} now {} lamports {}", + c_ok("✓"), + c_accent(name), + total_lamports, + c_muted(pubkey) + ) + .ok(); + } + SolanaCommandResult::TimeWarped { unix_timestamp, slot } => { + writeln!( + out, + " {} clock now ts={} slot={}", + c_ok("✓"), + unix_timestamp, + slot + ) + .ok(); + } + SolanaCommandResult::PdaList { instruction, pdas } => { + if pdas.is_empty() { + writeln!(out, " {} {}", c_muted("no PDAs declared in"), instruction).ok(); + } + for p in pdas { + writeln!( + out, + " {} {} seeds=[{}] program={}", + c_accent("[PDA]"), + p.account_name, + p.seeds.join(", "), + c_muted(&p.program) + ) + .ok(); + } + } + SolanaCommandResult::AccountInspected { + pubkey, + owner, + lamports, + data_len, + decoded, + } => { + writeln!( + out, + " {} owner={} lamports={} data_len={}", + c_accent(pubkey), + c_muted(owner), + lamports, + data_len + ) + .ok(); + if let Some(d) = decoded { + writeln!( + out, + " {}", + serde_json::to_string_pretty(d).unwrap_or_default() + ) + .ok(); + } + } + SolanaCommandResult::FindingAdded { id } => { + writeln!(out, " {} finding {}", c_ok("✓"), c_accent(id)).ok(); + } + SolanaCommandResult::NoteAdded => { + writeln!(out, " {} note recorded", c_ok("✓")).ok(); + } + SolanaCommandResult::StatusUpdated => { + writeln!(out, " {} status updated", c_ok("✓")).ok(); + } + SolanaCommandResult::SessionSaved { json } => { + writeln!( + out, + " {} session JSON ({} bytes)", + c_ok("✓"), + json.len() + ) + .ok(); + } + SolanaCommandResult::SessionLoaded { program, steps } => { + writeln!( + out, + " {} loaded program={} steps={}", + c_ok("✓"), + c_accent(program), + steps.len() + ) + .ok(); + } + SolanaCommandResult::ScenarioList { items } => { + for it in items { + let marker = if it.active { + c_ok(" ← active").to_string() + } else { + String::new() + }; + writeln!( + out, + " {} {} {}{}", + c_accent("[S]"), + it.name, + c_muted(&format!("({} steps)", it.step_count)), + marker + ) + .ok(); + } + } + SolanaCommandResult::ScenarioCreated { name } => { + writeln!(out, " {} scenario {} created", c_ok("✓"), c_accent(name)).ok(); + } + SolanaCommandResult::ScenarioSwitched { from, to } => { + writeln!(out, " {} {} → {}", c_ok("→"), from, c_accent(to)).ok(); + } + SolanaCommandResult::ScenarioForked { from, to, at_step } => { + writeln!( + out, + " {} forked {} → {} at step {}", + c_ok("✓"), + from, + c_accent(to), + at_step + ) + .ok(); + } + SolanaCommandResult::ScenarioDeleted { name } => { + writeln!(out, " {} scenario {} deleted", c_ok("✓"), name).ok(); + } + SolanaCommandResult::StepDetail { + step_index, + instruction, + runtime_trace, + diff_summary, + } => { + writeln!( + out, + " {} step {} · {}", + c_accent("·"), + step_index, + c_accent(instruction) + ) + .ok(); + if let Some(trace) = runtime_trace { + if let Some(cu) = trace.get("compute_units").and_then(|v| v.as_u64()) { + writeln!(out, " {} {} CU", c_muted("compute units:"), cu).ok(); + } + if let Some(err) = trace.get("error").and_then(|v| v.as_str()) { + writeln!(out, " {} {}", c_danger("error:"), err).ok(); + } + if let Some(logs) = trace.get("logs").and_then(|v| v.as_array()) { + writeln!(out, " {} ({} lines)", c_muted("logs:"), logs.len()).ok(); + for line in logs.iter().take(20) { + if let Some(s) = line.as_str() { + writeln!(out, " {}", c_muted(s)).ok(); + } + } + if logs.len() > 20 { + writeln!( + out, + " {}", + c_muted(&format!("... +{} more", logs.len() - 20)) + ) + .ok(); + } + } + } + if !diff_summary.is_empty() { + writeln!( + out, + " {} ({})", + c_muted("account diffs:"), + diff_summary.len() + ) + .ok(); + for d in diff_summary { + let label = d.name.clone().unwrap_or_else(|| d.address.clone()); + let lam = if d.lamports_delta != 0 { + format!(" Δlamports={}", d.lamports_delta) + } else { + String::new() + }; + writeln!( + out, + " {} {}{}", + c_accent("·"), + c_accent(&label), + c_muted(&lam), + ) + .ok(); + match (d.decoded_before.as_ref(), d.decoded_after.as_ref()) { + (None, None) => { + if d.data_changed { + writeln!(out, " {}", c_muted("data changed (not decoded)")) + .ok(); + } + } + (None, Some(after)) => { + writeln!( + out, + " {}", + c_muted("(new account, decoded fields:)") + ) + .ok(); + write_decoded_fields(&mut out, after, " "); + } + (Some(_), None) => { + writeln!(out, " {}", c_muted("(account closed)")).ok(); + } + (Some(before), Some(after)) => { + write_decoded_diff(&mut out, before, after, " "); + } + } + } + } + } + SolanaCommandResult::FindingsList { items } => { + if items.is_empty() { + writeln!(out, " {}", c_muted("no findings recorded")).ok(); + } else { + for f in items { + writeln!( + out, + " {} {} [{}] {}", + c_accent(&f.id), + c_warn(&f.severity), + c_muted(&f.created_at), + c_accent(&f.title) + ) + .ok(); + if !f.description.is_empty() { + writeln!(out, " {}", c_muted(&f.description)).ok(); + } + } + } + } + SolanaCommandResult::Exported { markdown, bytes } => { + writeln!(out, " {} markdown report ({} bytes)", c_ok("✓"), bytes).ok(); + writeln!(out).ok(); + for line in markdown.lines() { + writeln!(out, " {}", line).ok(); + } + } + SolanaCommandResult::WhoList { + account_type, + instructions, + query_kind, + field_owner, + field_type, + owner_fields, + ix_args, + ix_discriminator_hex, + ix_accounts, + } => { + render_who_list( + &mut out, + account_type, + instructions, + *query_kind, + field_owner.as_deref(), + field_type.as_deref(), + owner_fields.as_deref(), + ix_args.as_deref(), + ix_discriminator_hex.as_deref(), + ix_accounts.as_deref(), + ); + } + SolanaCommandResult::TimelineView { pubkey, label, entries } => { + let header = label.clone().unwrap_or_else(|| pubkey.clone()); + writeln!( + out, + " {} timeline for {} ({})", + c_accent("·"), + c_accent(&header), + c_muted(pubkey) + ) + .ok(); + if entries.is_empty() { + writeln!(out, " {}", c_muted("no mutations recorded for this pubkey")).ok(); + } else { + for e in entries { + let lam = if e.lamports_delta != 0 { + format!(" Δlamports={}", e.lamports_delta) + } else { + String::new() + }; + let dat = if e.data_changed { " data" } else { "" }; + writeln!( + out, + " {} #{} {} ({}){}{}", + c_accent("·"), + e.step_index, + c_accent(&e.instruction), + e.scenario, + c_muted(&lam), + c_muted(dat) + ) + .ok(); + if let Some(after) = &e.after_decoded { + let s = serde_json::to_string(after).unwrap_or_default(); + let preview = if s.len() > 200 { + format!("{}…", &s[..200]) + } else { + s + }; + writeln!(out, " {}", c_muted(&preview)).ok(); + } + } + } + } + SolanaCommandResult::IxInfo { ix, admin_gated } => { + render_ix_info(&mut out, ix, *admin_gated); + } + SolanaCommandResult::CouplingList { pairs } => { + render_coupling_list(&mut out, pairs); + } + SolanaCommandResult::AccountTypes { accounts } => { + render_account_types(&mut out, accounts); + } + SolanaCommandResult::Coverage { overlay } => { + render_coverage_overlay(&mut out, overlay); + } + SolanaCommandResult::Error { message } => { + writeln!(out, " {} {}", c_danger("✗"), message).ok(); + } + } + out +} + +fn write_decoded_fields(out: &mut String, value: &serde_json::Value, indent: &str) { + if let serde_json::Value::Object(map) = value { + let max = map.keys().map(|k| k.chars().count()).max().unwrap_or(0); + for (k, v) in map { + let val = match v { + serde_json::Value::String(s) => s.clone(), + _ => serde_json::to_string(v).unwrap_or_default(), + }; + writeln!( + out, + "{}{} {}", + indent, + c_muted(&format!("{:width$}", k, width = max)), + val, + ) + .ok(); + } + } +} + +fn write_decoded_diff( + out: &mut String, + before: &serde_json::Value, + after: &serde_json::Value, + indent: &str, +) { + let (a, b) = match (before, after) { + (serde_json::Value::Object(a), serde_json::Value::Object(b)) => (a, b), + _ => { + writeln!( + out, + "{}{}", + indent, + c_muted("decoded shape changed (not an object diff)"), + ) + .ok(); + return; + } + }; + let mut keys: Vec<&String> = a.keys().chain(b.keys()).collect(); + keys.sort(); + keys.dedup(); + let max = keys.iter().map(|k| k.chars().count()).max().unwrap_or(0); + let mut any = false; + for k in keys { + let lhs = a.get(k); + let rhs = b.get(k); + if lhs == rhs { + continue; + } + any = true; + let s_lhs = lhs + .map(|v| match v { + serde_json::Value::String(s) => s.clone(), + _ => serde_json::to_string(v).unwrap_or_default(), + }) + .unwrap_or_else(|| "<absent>".into()); + let s_rhs = rhs + .map(|v| match v { + serde_json::Value::String(s) => s.clone(), + _ => serde_json::to_string(v).unwrap_or_default(), + }) + .unwrap_or_else(|| "<absent>".into()); + writeln!( + out, + "{}{} {} → {}", + indent, + c_muted(&format!("{:width$}", k, width = max)), + c_muted(&s_lhs), + s_rhs, + ) + .ok(); + } + if !any { + writeln!(out, "{}{}", indent, c_muted("(no decoded field changed)")).ok(); + } +} + +fn render_ix_info(out: &mut String, ix: &IxView, admin_gated: bool) { + writeln!(out).ok(); + writeln!(out, " {} {}", c_accent("instruction"), ix.name).ok(); + if !ix.discriminator_hex.is_empty() { + writeln!(out, " {} {}", c_muted("discriminator"), ix.discriminator_hex).ok(); + } + writeln!(out).ok(); + writeln!(out, " {} ({})", c_accent("args"), ix.args.len()).ok(); + if ix.args.is_empty() { + writeln!(out, " {}", c_muted("(none)")).ok(); + } else { + let max = ix + .args + .iter() + .map(|a| a.name.chars().count()) + .max() + .unwrap_or(0); + for a in &ix.args { + writeln!( + out, + " {} {} {}", + c_accent("·"), + pad_right(&a.name, max), + c_muted(&a.ty) + ) + .ok(); + } + } + writeln!(out).ok(); + writeln!(out, " {} ({})", c_accent("accounts"), ix.accounts.len()).ok(); + if ix.accounts.is_empty() { + writeln!(out, " {}", c_muted("(none)")).ok(); + } else { + let max = ix + .accounts + .iter() + .map(|a| a.name.chars().count()) + .max() + .unwrap_or(0); + for a in &ix.accounts { + let mut flags: Vec<&str> = Vec::new(); + if a.signer { + flags.push("signer"); + } + if a.writable { + flags.push("writable"); + } + if a.optional { + flags.push("optional"); + } + let kind_label = match a.kind { + AccountKind::Program => "program", + AccountKind::System => "system", + AccountKind::Sysvar => "sysvar", + AccountKind::Pda => "pda", + AccountKind::Other => "other", + }; + let suffix = if let Some(addr) = a.address.as_deref() { + format!("{kind_label} const {addr}") + } else if flags.is_empty() { + kind_label.to_string() + } else { + format!("{kind_label} {}", flags.join(" ")) + }; + writeln!( + out, + " {} {} {}", + c_accent("·"), + pad_right(&a.name, max), + c_muted(&suffix) + ) + .ok(); + } + } + let pdas: Vec<&IxAccountView> = ix.accounts.iter().filter(|a| a.pda.is_some()).collect(); + if !pdas.is_empty() { + writeln!(out).ok(); + writeln!(out, " {} ({})", c_accent("pdas"), pdas.len()).ok(); + for acc in pdas { + let pda = acc.pda.as_ref().expect("filtered above"); + let seeds: Vec<String> = pda.seeds.iter().map(describe_seed_view).collect(); + let prog = pda.program.clone().unwrap_or_else(|| "self".to_string()); + writeln!( + out, + " {} {} seeds=[{}] program={}", + c_accent("·"), + acc.name, + seeds.join(", "), + c_muted(&prog) + ) + .ok(); + } + } + writeln!(out).ok(); + let gated_label = if admin_gated { + c_warn("true (heuristic)").to_string() + } else { + c_muted("false").to_string() + }; + writeln!(out, " {} {}", c_muted("admin_gated"), gated_label).ok(); +} + +fn render_coupling_list(out: &mut String, pairs: &[CouplingPair]) { + if pairs.is_empty() { + writeln!( + out, + " {}", + c_muted("no instruction pairs share writable accounts") + ) + .ok(); + return; + } + let max = pairs + .iter() + .map(|p| p.a.chars().count() + p.b.chars().count() + 5) + .max() + .unwrap_or(0); + for p in pairs { + let pair = format!("{} ↔ {}", p.a, p.b); + writeln!( + out, + " {} {} {}", + c_accent("·"), + pad_right(&pair, max), + c_muted(&format!("[{}]", p.shared_writable.join(", "))) + ) + .ok(); + } +} + +fn render_account_types(out: &mut String, accounts: &[AccountView]) { + if accounts.is_empty() { + writeln!(out, " {}", c_muted("No account types declared in IDL")).ok(); + return; + } + for at in accounts { + writeln!( + out, + " {} {} {}", + c_accent("[T]"), + at.name, + c_muted(&at.discriminator_hex) + ) + .ok(); + if at.fields.is_empty() { + writeln!(out, " {}", c_muted("(opaque or zero-copy layout)")).ok(); + continue; + } + let max = at + .fields + .iter() + .map(|f| f.name.chars().count()) + .max() + .unwrap_or(0); + for f in &at.fields { + writeln!( + out, + " {} {} {}", + c_accent("·"), + pad_right(&f.name, max), + c_muted(&f.ty) + ) + .ok(); + } + } +} + +fn render_coverage_overlay(out: &mut String, overlay: &RuntimeOverlay) { + writeln!(out).ok(); + writeln!( + out, + " {} {} {}", + c_accent("Coverage for program"), + overlay.program, + c_muted(&format!("(scenario {})", overlay.scenario)), + ) + .ok(); + writeln!(out).ok(); + + let mut ix_keys: std::collections::BTreeSet<&String> = std::collections::BTreeSet::new(); + ix_keys.extend(overlay.calls_per_ix.keys()); + ix_keys.extend(overlay.failed_per_ix.keys()); + ix_keys.extend(overlay.cu_stats_per_ix.keys()); + + if ix_keys.is_empty() { + writeln!(out, " {}", c_muted("no calls recorded yet")).ok(); + return; + } + + let cpi_count_per_ix = |ix: &str| -> u32 { + overlay + .cpi_edges + .iter() + .filter(|e| e.from_ix == ix) + .map(|e| e.samples) + .sum() + }; + + let max_ix = ix_keys + .iter() + .map(|k| k.chars().count()) + .max() + .unwrap_or(0) + .max(11); + writeln!( + out, + " {} {} {} {} {} {}", + pad_right("Instruction", max_ix), + c_muted("Calls"), + c_muted("Failed"), + c_muted("CU avg"), + c_muted("CU max"), + c_muted("CPIs"), + ) + .ok(); + let mut total_calls: u32 = 0; + let mut total_failed: u32 = 0; + for ix in &ix_keys { + let calls = overlay.calls_per_ix.get(*ix).copied().unwrap_or(0); + let failed = overlay.failed_per_ix.get(*ix).copied().unwrap_or(0); + total_calls += calls; + total_failed += failed; + let (cu_avg, cu_max) = match overlay.cu_stats_per_ix.get(*ix) { + Some(s) => (s.avg.to_string(), s.max.to_string()), + None => ("—".to_string(), "—".to_string()), + }; + let cpi = cpi_count_per_ix(ix); + writeln!( + out, + " {} {} {} {} {} {}", + pad_right(ix, max_ix), + pad_right(&calls.to_string(), 5), + pad_right(&failed.to_string(), 6), + pad_right(&cu_avg, 6), + pad_right(&cu_max, 6), + cpi, + ) + .ok(); + } + if !overlay.cpi_edges.is_empty() { + writeln!(out).ok(); + writeln!(out, " {}", c_accent("CPIs detected:")).ok(); + for edge in &overlay.cpi_edges { + writeln!( + out, + " {} {} {} {} {}", + c_accent("·"), + edge.from_ix, + c_muted("→"), + edge.to_program, + c_muted(&format!("(depth {}, {}×)", edge.depth, edge.samples)), + ) + .ok(); + } + } + writeln!(out).ok(); + writeln!( + out, + " {} {} calls, {} failed", + c_muted("Total:"), + total_calls, + total_failed, + ) + .ok(); +} + +#[allow(clippy::too_many_arguments)] +fn render_who_list( + out: &mut String, + target: &str, + instructions: &[WhoEntry], + query_kind: WhoQueryKind, + field_owner: Option<&str>, + field_type: Option<&str>, + owner_fields: Option<&[FieldView]>, + ix_args: Option<&[ArgView]>, + ix_discriminator_hex: Option<&str>, + ix_accounts: Option<&[WhoIxAccount]>, +) { + match query_kind { + WhoQueryKind::AccountType => { + writeln!( + out, + " {} '{}' (account type)", + c_accent("·"), + c_accent(target) + ) + .ok(); + render_field_summary(out, owner_fields, "fields"); + writeln!(out).ok(); + if instructions.is_empty() { + writeln!(out, " {}", c_muted("not referenced by any instruction")).ok(); + return; + } + writeln!( + out, + " Referenced by {} instruction{}:", + instructions.len(), + if instructions.len() == 1 { "" } else { "s" } + ) + .ok(); + writeln!(out).ok(); + for w in instructions { + render_who_entry_block(out, w); + } + } + WhoQueryKind::Field => { + let owner = field_owner.unwrap_or("?"); + let ty = field_type.unwrap_or("?"); + writeln!( + out, + " {} '{}' (field of {}, type {})", + c_accent("·"), + c_accent(target), + c_accent(owner), + c_muted(ty) + ) + .ok(); + render_field_summary(out, owner_fields, &format!("{owner} struct")); + writeln!(out).ok(); + writeln!( + out, + " {}", + c_warn("Heuristic: the following instructions write the owner account.") + ) + .ok(); + writeln!( + out, + " {}", + c_muted("Without source-level analysis we cannot tell which one(s)") + ) + .ok(); + writeln!( + out, + " {}", + c_muted("actually mutate this field; cross-check with `step <idx>`.") + ) + .ok(); + writeln!(out).ok(); + if instructions.is_empty() { + writeln!(out, " {}", c_muted("(no writers found)")).ok(); + return; + } + for w in instructions { + render_who_entry_block(out, w); + } + } + WhoQueryKind::Instruction => { + writeln!( + out, + " {} '{}' (instruction)", + c_accent("·"), + c_accent(target) + ) + .ok(); + match ix_args { + Some(args) if !args.is_empty() => { + let parts: Vec<String> = args + .iter() + .map(|a| format!("{}: {}", a.name, a.ty)) + .collect(); + writeln!(out, " args: {}", c_muted(&parts.join(", "))).ok(); + } + _ => { + writeln!(out, " {}", c_muted("args: (none)")).ok(); + } + } + if let Some(d) = ix_discriminator_hex { + writeln!(out, " {} {}", c_muted("discriminator"), d).ok(); + } + writeln!(out).ok(); + let accounts = ix_accounts.unwrap_or(&[]); + if accounts.is_empty() { + writeln!(out, " {}", c_muted("touches no accounts")).ok(); + return; + } + writeln!( + out, + " Touches {} account{}:", + accounts.len(), + if accounts.len() == 1 { "" } else { "s" } + ) + .ok(); + writeln!(out).ok(); + for acc in accounts { + render_who_ix_account(out, acc); + } + } + WhoQueryKind::NotFound => { + writeln!( + out, + " {} no instruction, account type or field references '{}'", + c_muted("·"), + target + ) + .ok(); + } + } +} + +fn render_field_summary(out: &mut String, fields: Option<&[FieldView]>, label: &str) { + match fields { + Some(fs) if !fs.is_empty() => { + let parts: Vec<String> = fs.iter().map(|f| format!("{}: {}", f.name, f.ty)).collect(); + writeln!(out, " {}: {}", label, c_muted(&parts.join(", "))).ok(); + } + Some(_) => { + writeln!( + out, + " {}: {}", + label, + c_muted("(opaque or zero-copy layout)") + ) + .ok(); + } + None => {} + } +} + +fn render_who_entry_block(out: &mut String, entry: &WhoEntry) { + let mut flags = Vec::new(); + if entry.signer { + flags.push("signer"); + } + if entry.writable { + flags.push("writable"); + } + let flags_str = flags.join(" "); + writeln!( + out, + " {} {} (as {}) {}", + c_accent("·"), + c_accent(&entry.instruction), + entry.account_field, + c_muted(&flags_str) + ) + .ok(); + match entry.ix_args.as_ref() { + Some(args) if !args.is_empty() => { + let parts: Vec<String> = args.iter().map(|a| format!("{}: {}", a.name, a.ty)).collect(); + writeln!(out, " args: {}", c_muted(&parts.join(", "))).ok(); + } + Some(_) => { + writeln!(out, " {}", c_muted("args: (none)")).ok(); + } + None => {} + } +} + +fn render_who_ix_account(out: &mut String, acc: &WhoIxAccount) { + let type_label = acc + .account_type + .as_deref() + .map(|t| format!("({t})")) + .unwrap_or_else(|| "(—)".to_string()); + let mut flags = Vec::new(); + if acc.signer { + flags.push("signer"); + } + if acc.writable { + flags.push("writable"); + } + writeln!( + out, + " {} {} {} {}", + c_accent("·"), + c_accent(&acc.name), + c_muted(&type_label), + c_muted(&flags.join(" ")) + ) + .ok(); + if let Some(fs) = acc.fields.as_ref() + && !fs.is_empty() + { + let parts: Vec<String> = fs.iter().map(|f| format!("{}: {}", f.name, f.ty)).collect(); + writeln!(out, " struct: {}", c_muted(&parts.join(", "))).ok(); + } +} diff --git a/crates/ilold-session-core/Cargo.toml b/crates/ilold-session-core/Cargo.toml index 9aafa11..78e7409 100644 --- a/crates/ilold-session-core/Cargo.toml +++ b/crates/ilold-session-core/Cargo.toml @@ -7,3 +7,4 @@ description = "Language-agnostic session and audit-journal types shared by ilold [dependencies] serde = { workspace = true } serde_json = { workspace = true } +schemars = { workspace = true } diff --git a/crates/ilold-session-core/src/exploration/scenario.rs b/crates/ilold-session-core/src/exploration/scenario.rs index bc732f0..db8d090 100644 --- a/crates/ilold-session-core/src/exploration/scenario.rs +++ b/crates/ilold-session-core/src/exploration/scenario.rs @@ -1,6 +1,6 @@ use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] pub enum ScenarioAction { New { name: String }, List, diff --git a/crates/ilold-session-core/src/journal/export.rs b/crates/ilold-session-core/src/journal/export.rs index a8a74d6..f5a6c92 100644 --- a/crates/ilold-session-core/src/journal/export.rs +++ b/crates/ilold-session-core/src/journal/export.rs @@ -7,7 +7,7 @@ use super::types::*; /// Optional auditor metadata threaded through the export. Pass-through, not /// stored in the journal — see SDD 02-audit-deliverable-export/design.md /// rationale (avoids JSON-format bumps and keeps PII out of saved sessions). -#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)] pub struct AuditMetadata { #[serde(default)] pub auditor: Option<String>, diff --git a/crates/ilold-session-core/src/journal/types.rs b/crates/ilold-session-core/src/journal/types.rs index 381fd05..b51b083 100644 --- a/crates/ilold-session-core/src/journal/types.rs +++ b/crates/ilold-session-core/src/journal/types.rs @@ -46,7 +46,7 @@ pub struct Finding { pub recommendation: Option<String>, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub enum Severity { Critical, High, @@ -67,7 +67,7 @@ impl fmt::Display for Severity { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub enum ReviewStatus { NotReviewed, InProgress, diff --git a/crates/ilold-solana-core/Cargo.toml b/crates/ilold-solana-core/Cargo.toml index 713fcb3..f063702 100644 --- a/crates/ilold-solana-core/Cargo.toml +++ b/crates/ilold-solana-core/Cargo.toml @@ -20,6 +20,7 @@ solana-transaction = "3.0" litesvm = "0.11" serde = { workspace = true } serde_json = { workspace = true } +schemars = { workspace = true } thiserror = { workspace = true } anyhow = { workspace = true } borsh = "1" diff --git a/crates/ilold-solana-core/src/exploration/commands.rs b/crates/ilold-solana-core/src/exploration/commands.rs index 76e1407..3d8a2a4 100644 --- a/crates/ilold-solana-core/src/exploration/commands.rs +++ b/crates/ilold-solana-core/src/exploration/commands.rs @@ -10,15 +10,25 @@ use serde_json::Value; use crate::overlay::RuntimeOverlay; use crate::view::{AccountView, ArgView, CouplingPair, FieldView, IxView}; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)] pub enum SolanaCommand { Call { + #[schemars(description = "Instruction name (e.g. stake, initialize_pool).")] ix: String, #[serde(default)] + #[schemars( + description = "Anchor instruction args as a JSON object (e.g. {\"amount\": 1000}). Required keys depend on the instruction — call `ilold_info <ix>` first to discover them. Defaults to {}." + )] args: Value, #[serde(default)] + #[schemars( + description = "Map of account-name (from the IDL) to user handle or pubkey (e.g. {\"pool\":\"pool\",\"user\":\"alice\"})." + )] accounts: HashMap<String, String>, #[serde(default)] + #[schemars( + description = "Override the default signer set. Empty list means use the IDL defaults." + )] signers: Vec<String>, }, Back, From 5671b6c1b94faab139dc65b8dcb2962b1a05739d Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Mon, 11 May 2026 11:31:51 +0200 Subject: [PATCH 105/115] fix(mcp): expose users_new tool and fix sequence mapping Add users-new HelpBlock so ilold_users_new appears in tools/list. Exclude ilold_sequence from the registry because /api/session/sequence is Solidity-only (require_solidity_msg) and the Solana CLI already falls back to Session, leaving ilold_session as the single tool. Add schema_consistency test validating every tool inputSchema against a sample arguments payload and round-tripping build_command output into SolanaCommand. Add render snapshot tests for StepAdded, Coverage and Error fixtures with byte-identity baselines under crates/ilold-render/tests/snapshots. --- Cargo.lock | 168 +++++++++++++++++- crates/ilold-help/src/lib.rs | 23 ++- crates/ilold-mcp/Cargo.toml | 3 + crates/ilold-mcp/src/tools.rs | 30 +++- .../ilold-mcp/tests/mcp_stdio_lists_tools.rs | 2 + crates/ilold-mcp/tests/schema_consistency.rs | 114 ++++++++++++ crates/ilold-render/tests/snapshot_render.rs | 100 +++++++++++ .../ilold-render/tests/snapshots/coverage.txt | 11 ++ crates/ilold-render/tests/snapshots/error.txt | 1 + .../tests/snapshots/step_added.txt | 3 + 10 files changed, 444 insertions(+), 11 deletions(-) create mode 100644 crates/ilold-mcp/tests/schema_consistency.rs create mode 100644 crates/ilold-render/tests/snapshot_render.rs create mode 100644 crates/ilold-render/tests/snapshots/coverage.txt create mode 100644 crates/ilold-render/tests/snapshots/error.txt create mode 100644 crates/ilold-render/tests/snapshots/step_added.txt diff --git a/Cargo.lock b/Cargo.lock index 8615135..e940afb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -115,6 +115,7 @@ dependencies = [ "cfg-if", "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -850,6 +851,12 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "borsh" version = "1.6.1" @@ -911,6 +918,12 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + [[package]] name = "bytemuck" version = "1.25.0" @@ -1656,6 +1669,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1721,6 +1743,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -1841,6 +1874,17 @@ version = "0.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1883,6 +1927,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num 0.4.3", +] + [[package]] name = "funty" version = "2.0.0" @@ -2019,9 +2073,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -2472,6 +2528,7 @@ dependencies = [ "ilold-render", "ilold-session-core", "ilold-solana-core", + "jsonschema", "reqwest", "rmcp", "schemars", @@ -2718,6 +2775,33 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonschema" +version = "0.46.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc59d2432e047d6090ba1d83c782d0128bd6203857978218f5614dbd3287281f" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "regex-syntax", + "serde", + "serde_json", + "unicode-general-category", + "uuid-simd", +] + [[package]] name = "k256" version = "0.13.4" @@ -3022,6 +3106,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + [[package]] name = "mime" version = "0.3.17" @@ -3103,13 +3193,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8536030f9fea7127f841b45bb6243b27255787fb4eb83958aa1ef9d2fdc0c36" dependencies = [ "num-bigint 0.2.6", - "num-complex", + "num-complex 0.2.4", "num-integer", "num-iter", "num-rational 0.2.4", "num-traits", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint 0.4.6", + "num-complex 0.4.6", + "num-integer", + "num-iter", + "num-rational 0.4.2", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.2.6" @@ -3131,6 +3235,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + [[package]] name = "num-complex" version = "0.2.4" @@ -3141,6 +3251,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.1" @@ -3300,6 +3419,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + [[package]] name = "parity-scale-codec" version = "3.7.5" @@ -3384,7 +3509,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd23b938276f14057220b707937bcb42fa76dda7560e57a2da30cb52d557937" dependencies = [ - "num", + "num 0.2.1", ] [[package]] @@ -3875,6 +4000,23 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "referencing" +version = "0.46.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb674900ca31acd75c4aaf63f48e43e719631c0539ea5a9e64163d1296bcb730" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + [[package]] name = "regex" version = "1.12.3" @@ -6659,6 +6801,12 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -6759,6 +6907,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + [[package]] name = "valuable" version = "0.1.1" @@ -6783,6 +6941,12 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + [[package]] name = "vte" version = "0.14.1" diff --git a/crates/ilold-help/src/lib.rs b/crates/ilold-help/src/lib.rs index 3d1f282..f22a7c2 100644 --- a/crates/ilold-help/src/lib.rs +++ b/crates/ilold-help/src/lib.rs @@ -139,19 +139,28 @@ pub const SOLANA_HELP_BLOCKS: &[HelpBlock] = &[ HelpBlock { title: "users", aliases: &["users"], - purpose: "Manage the keypair set in the active scenario: list existing users or mint a new one.", + purpose: "List every named keypair in the active scenario.", + syntax: &[("users", "List keypairs")], + flags: &[], + examples: &[("users", "Show all named keypairs with pubkey and lamports")], + returns: "UsersList { users: [{ name, pubkey, lamports }] }.", + see_also: &["users-new", "airdrop", "inspect", "scenario"], + }, + HelpBlock { + title: "users-new", + aliases: &["users-new"], + purpose: "Create a new named keypair in the active scenario and airdrop the initial lamports.", syntax: &[ - ("users", "List keypairs"), - ("users new <name> [lamports]", "Create keypair and airdrop (default 10 SOL)"), + ("users new <name> [lamports]", "REPL form (CLI parses the subcommand)"), + ("users-new <name> [lamports]", "MCP form (the hyphenated alias is the exposed tool)"), ], flags: &[], examples: &[ - ("users", "Show all named keypairs"), - ("users new alice", "Create alice with 10 SOL"), + ("users new alice", "Create alice with 10 SOL (default airdrop)"), ("users new bob 5000000000", "Create bob with 5 SOL"), ], - returns: "UsersList { users: [{ name, pubkey, lamports }] } or UsersUpdated.", - see_also: &["airdrop", "inspect", "scenario"], + returns: "UserCreated { name, pubkey, lamports }.", + see_also: &["users", "airdrop", "inspect"], }, HelpBlock { title: "airdrop", diff --git a/crates/ilold-mcp/Cargo.toml b/crates/ilold-mcp/Cargo.toml index a553737..107a432 100644 --- a/crates/ilold-mcp/Cargo.toml +++ b/crates/ilold-mcp/Cargo.toml @@ -19,3 +19,6 @@ tokio = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } reqwest = { version = "0.12", features = ["json"] } + +[dev-dependencies] +jsonschema = { version = "0.46", default-features = false } diff --git a/crates/ilold-mcp/src/tools.rs b/crates/ilold-mcp/src/tools.rs index d197d22..e0cfa69 100644 --- a/crates/ilold-mcp/src/tools.rs +++ b/crates/ilold-mcp/src/tools.rs @@ -10,8 +10,13 @@ use crate::schema::schema_for_tool; // CLI prompt; it has no SolanaCommand variant and the MCP transport selects // the program via the `--contract` flag instead, so the tool is excluded // from the registry. +// +// `seq | sequence` is excluded because the dedicated `/api/session/sequence` +// endpoint is Solidity-only (gated by `require_solidity_msg`). For Solana the +// REPL falls back to the Session view, so exposing both `ilold_session` and +// `ilold_sequence` would be two tool names for the exact same backend call. const EXCLUDED_ALIASES: &[&str] = &[ - "?", "help", "h", "quit", "q", "exit", "browser", "use", + "?", "help", "h", "quit", "q", "exit", "browser", "use", "seq", "sequence", ]; const TOOL_NAME_PREFIX: &str = "ilold_"; @@ -101,7 +106,7 @@ pub fn build_command(name: &str, arguments: Option<&Value>) -> Result<Value, Str "ilold_funcs" | "ilold_functions" => Ok(json!("Funcs")), "ilold_funcs_all" => Ok(json!("Funcs")), "ilold_state" => Ok(json!("State")), - "ilold_session" | "ilold_sequence" => Ok(json!("Session")), + "ilold_session" => Ok(json!("Session")), "ilold_info" => Ok(json!({ "Info": { "ix": require_str(&args_obj, "ix")? } })), "ilold_vars" => Ok(json!("Vars")), "ilold_users" => Ok(json!("Users")), @@ -203,10 +208,31 @@ mod tests { #[test] fn tool_registry_has_29_entries() { + // 33 help blocks - 4 meta (?, quit, browser, use) - 2 sequence aliases + // (seq, sequence excluded because the /api/session/sequence endpoint + // is Solidity-only) + 1 dedicated users-new block = 29. let tools = build_tool_registry(); assert_eq!(tools.len(), 29); } + #[test] + fn registry_includes_users_new() { + let tools = build_tool_registry(); + assert!( + tools.iter().any(|t| t.name == "ilold_users_new"), + "ilold_users_new must be exposed as a dedicated tool" + ); + } + + #[test] + fn registry_excludes_sequence() { + let tools = build_tool_registry(); + assert!( + !tools.iter().any(|t| t.name == "ilold_sequence"), + "ilold_sequence must NOT appear; /api/session/sequence is Solidity-only" + ); + } + #[test] fn tool_names_are_unique() { let tools = build_tool_registry(); diff --git a/crates/ilold-mcp/tests/mcp_stdio_lists_tools.rs b/crates/ilold-mcp/tests/mcp_stdio_lists_tools.rs index 300e625..6dc4256 100644 --- a/crates/ilold-mcp/tests/mcp_stdio_lists_tools.rs +++ b/crates/ilold-mcp/tests/mcp_stdio_lists_tools.rs @@ -8,9 +8,11 @@ fn registry_lists_29_solana_tools() { assert!(names.contains(&"ilold_call")); assert!(names.contains(&"ilold_funcs")); assert!(names.contains(&"ilold_coverage")); + assert!(names.contains(&"ilold_users_new")); assert!(!names.contains(&"ilold_use")); assert!(!names.contains(&"ilold_help")); assert!(!names.contains(&"ilold_quit")); + assert!(!names.contains(&"ilold_sequence")); } #[test] diff --git a/crates/ilold-mcp/tests/schema_consistency.rs b/crates/ilold-mcp/tests/schema_consistency.rs new file mode 100644 index 0000000..d84c162 --- /dev/null +++ b/crates/ilold-mcp/tests/schema_consistency.rs @@ -0,0 +1,114 @@ +//! Cross-check that every tool in the registry has: +//! 1. An inputSchema that validates against a sample `arguments` payload. +//! 2. A `build_command` mapping that produces a JSON value which +//! round-trips into the correct `SolanaCommand` variant. +//! +//! The special-case `ilold_programs` skips the build_command leg because the +//! MCP server routes it to a REST GET, not the /api/cmd dispatcher. + +use ilold_mcp::tools::build_tool_registry; +use ilold_solana_core::exploration::SolanaCommand; +use serde_json::{Value, json}; + +/// Sample `arguments` payload per tool — the minimal JSON that satisfies +/// the tool's inputSchema. Tools without arguments map to `None`. +fn sample_arguments(name: &str) -> Option<Value> { + match name { + "ilold_call" => Some(json!({ "ix": "stake" })), + "ilold_info" => Some(json!({ "ix": "stake" })), + "ilold_pda" => Some(json!({ "instruction": "stake" })), + "ilold_inspect" => Some(json!({ "pubkey": "alice" })), + "ilold_who" => Some(json!({ "account_type": "Pool" })), + "ilold_timeline" => Some(json!({ "pubkey": "alice" })), + "ilold_users_new" => Some(json!({ "name": "alice" })), + "ilold_airdrop" => Some(json!({ "user": "alice", "lamports": 1_000_000_000u64 })), + "ilold_time_warp" => Some(json!({ "delta_seconds": 86400 })), + "ilold_finding" => Some(json!({ + "severity": "High", + "title": "reentrancy", + "description": "found a reentrant path", + })), + "ilold_note" => Some(json!({ "text": "suspicious admin path" })), + "ilold_status" => Some(json!({ "ix": "stake", "status": "Reviewed" })), + "ilold_step" => Some(json!({ "index": 0 })), + "ilold_load" => Some(json!({ "json": "{}" })), + "ilold_scenario" => Some(json!({ "sub": { "New": { "name": "branch1" } } })), + "ilold_save" => Some(json!({})), + "ilold_export" => Some(json!({})), + _ => None, + } +} + +/// `ilold_programs` is a special-case in the dispatcher (REST GET to +/// /api/project/map). It has no SolanaCommand variant so we only validate the +/// schema, not the build_command path. +fn skips_build_command(name: &str) -> bool { + name == "ilold_programs" +} + +#[test] +fn every_tool_input_schema_validates_sample() { + let tools = build_tool_registry(); + for t in &tools { + let schema_value: Value = + serde_json::to_value(&*t.input_schema).expect("schema serializes"); + let validator = jsonschema::validator_for(&schema_value) + .unwrap_or_else(|e| panic!("{}: schema compile failed: {e}", t.name)); + + let args = sample_arguments(&t.name).unwrap_or_else(|| json!({})); + let errors: Vec<String> = validator + .iter_errors(&args) + .map(|e| e.to_string()) + .collect(); + assert!( + errors.is_empty(), + "{}: sample {} failed schema: {:?}", + t.name, + args, + errors + ); + } +} + +#[test] +fn every_tool_build_command_roundtrips_to_solana_command() { + let tools = build_tool_registry(); + for t in &tools { + if skips_build_command(&t.name) { + continue; + } + let args = sample_arguments(&t.name); + let cmd_json = ilold_mcp::tools::build_command(&t.name, args.as_ref()) + .unwrap_or_else(|e| panic!("{}: build_command failed: {e}", t.name)); + let _: SolanaCommand = serde_json::from_value(cmd_json.clone()).unwrap_or_else(|e| { + panic!( + "{}: SolanaCommand deserialize failed: {e} (payload: {})", + t.name, cmd_json + ) + }); + } +} + +#[test] +fn users_new_schema_requires_name() { + let tools = build_tool_registry(); + let t = tools + .iter() + .find(|t| t.name == "ilold_users_new") + .expect("ilold_users_new present"); + let schema: Value = serde_json::to_value(&*t.input_schema).unwrap(); + let req = schema + .get("required") + .and_then(|r| r.as_array()) + .expect("required field"); + assert!(req.iter().any(|v| v.as_str() == Some("name"))); +} + +#[test] +fn sequence_tool_absent_from_registry() { + let tools = build_tool_registry(); + assert!( + !tools.iter().any(|t| t.name == "ilold_sequence"), + "ilold_sequence must be excluded: /api/session/sequence is Solidity-only" + ); +} diff --git a/crates/ilold-render/tests/snapshot_render.rs b/crates/ilold-render/tests/snapshot_render.rs new file mode 100644 index 0000000..866eeee --- /dev/null +++ b/crates/ilold-render/tests/snapshot_render.rs @@ -0,0 +1,100 @@ +//! Byte-identity snapshots for `render_solana_result`. ANSI escapes are +//! stripped before comparison so the snapshots stay portable across +//! terminals. To regenerate after an intentional render change, run +//! `ILOLD_UPDATE_SNAPSHOTS=1 cargo test -p ilold-render --test snapshot_render`. + +use std::collections::BTreeMap; +use std::fs; +use std::path::PathBuf; + +use ilold_render::fmt::strip_ansi; +use ilold_render::render_solana_result; +use ilold_solana_core::exploration::SolanaCommandResult; +use ilold_solana_core::overlay::{CpiEdge, CuStats, RuntimeOverlay}; + +fn snapshot_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots") +} + +fn assert_snapshot(name: &str, actual: &str) { + let path = snapshot_dir().join(format!("{name}.txt")); + if std::env::var("ILOLD_UPDATE_SNAPSHOTS").is_ok() || !path.exists() { + fs::create_dir_all(path.parent().unwrap()).expect("create snapshot dir"); + fs::write(&path, actual).expect("write snapshot"); + return; + } + let expected = fs::read_to_string(&path).expect("read snapshot"); + assert_eq!( + actual, expected, + "snapshot drift for {name}: regenerate with ILOLD_UPDATE_SNAPSHOTS=1" + ); +} + +fn step_added_fixture() -> SolanaCommandResult { + SolanaCommandResult::StepAdded { + step_index: 2, + instruction: "stake".into(), + logs_excerpt: vec![ + "Program log: Instruction: Stake".into(), + "Program log: amount=1000".into(), + ], + account_diffs_count: 3, + compute_units: 12_345, + error: None, + } +} + +fn coverage_fixture() -> SolanaCommandResult { + let mut calls = BTreeMap::new(); + calls.insert("stake".into(), 4u32); + calls.insert("claim_rewards".into(), 2u32); + let mut failed = BTreeMap::new(); + failed.insert("claim_rewards".into(), 1u32); + let mut cu_stats = BTreeMap::new(); + cu_stats.insert( + "stake".into(), + CuStats { min: 10_000, max: 15_000, avg: 12_500, samples: 4 }, + ); + cu_stats.insert( + "claim_rewards".into(), + CuStats { min: 8_000, max: 9_500, avg: 8_750, samples: 2 }, + ); + let overlay = RuntimeOverlay { + program: "staking".into(), + scenario: "main".into(), + calls_per_ix: calls, + failed_per_ix: failed, + cu_stats_per_ix: cu_stats, + cpi_edges: vec![CpiEdge { + from_ix: "stake".into(), + to_program: "token_program".into(), + depth: 1, + samples: 4, + }], + }; + SolanaCommandResult::Coverage { overlay } +} + +fn error_fixture() -> SolanaCommandResult { + SolanaCommandResult::Error { + message: "scenario 'foo' not found".into(), + } +} + +#[test] +fn snapshot_step_added() { + let out = strip_ansi(&render_solana_result(&step_added_fixture())); + assert_snapshot("step_added", &out); +} + +#[test] +fn snapshot_coverage() { + let out = strip_ansi(&render_solana_result(&coverage_fixture())); + assert_snapshot("coverage", &out); +} + +#[test] +fn snapshot_error() { + let out = strip_ansi(&render_solana_result(&error_fixture())); + assert_snapshot("error", &out); +} diff --git a/crates/ilold-render/tests/snapshots/coverage.txt b/crates/ilold-render/tests/snapshots/coverage.txt new file mode 100644 index 0000000..def69f6 --- /dev/null +++ b/crates/ilold-render/tests/snapshots/coverage.txt @@ -0,0 +1,11 @@ + + Coverage for program staking (scenario main) + + Instruction Calls Failed CU avg CU max CPIs + claim_rewards 2 1 8750 9500 0 + stake 4 0 12500 15000 4 + + CPIs detected: + · stake → token_program (depth 1, 4×) + + Total: 6 calls, 1 failed diff --git a/crates/ilold-render/tests/snapshots/error.txt b/crates/ilold-render/tests/snapshots/error.txt new file mode 100644 index 0000000..18b9346 --- /dev/null +++ b/crates/ilold-render/tests/snapshots/error.txt @@ -0,0 +1 @@ + ✗ scenario 'foo' not found diff --git a/crates/ilold-render/tests/snapshots/step_added.txt b/crates/ilold-render/tests/snapshots/step_added.txt new file mode 100644 index 0000000..83c0f36 --- /dev/null +++ b/crates/ilold-render/tests/snapshots/step_added.txt @@ -0,0 +1,3 @@ + ✓ step 2 [ok]: stake (12345 CU, 3 diffs) + Program log: Instruction: Stake + Program log: amount=1000 From 03d476a294f7aee2175648bb7ddb8fd49474d998 Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Mon, 11 May 2026 11:48:51 +0200 Subject: [PATCH 106/115] feat(mcp): narration mode and httpmock integration tests - Add --narration / ILOLD_NARRATION flag to ilold mcp subcommand - Emit notifications/progress before each tool call when enabled - Add src/narration.rs mapping tool name and args to intent phrase - Add httpmock dev-dep with three client.rs unit tests - Add mcp_stdio_full_flow integration test (ignored, needs ilold binary) - Update SDD-05 proposal tool count from 26 to 29 to match registry --- Cargo.lock | 712 +++++++++++++++++- crates/ilold-cli/src/main.rs | 9 +- crates/ilold-mcp/Cargo.toml | 1 + crates/ilold-mcp/src/lib.rs | 14 +- crates/ilold-mcp/src/narration.rs | 243 ++++++ crates/ilold-mcp/src/server.rs | 36 +- crates/ilold-mcp/tests/client_httpmock.rs | 122 +++ crates/ilold-mcp/tests/mcp_stdio_full_flow.rs | 212 ++++++ 8 files changed, 1307 insertions(+), 42 deletions(-) create mode 100644 crates/ilold-mcp/src/narration.rs create mode 100644 crates/ilold-mcp/tests/client_httpmock.rs create mode 100644 crates/ilold-mcp/tests/mcp_stdio_full_flow.rs diff --git a/Cargo.lock b/Cargo.lock index e940afb..857f601 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -645,6 +645,195 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eab1c04a571841102f5345a8fc0f6bb3d31c315dec879b5c6e42e40ce7ffa34e" +[[package]] +name = "ascii-canvas" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8824ecca2e851cec16968d54a01dd372ef8f95b244fb84b84e70128be347c3c6" +dependencies = [ + "term", +] + +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "async-attributes" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3203e79f4dd9bdda415ed03cf14dae5a2bf775c683a00f94e9cd1faf0f596e5" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-global-executor" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b1b633a2115cd122d73b955eadd9916c18c8f510ec9cd1686404c60ad1c29c" +dependencies = [ + "async-channel 2.5.0", + "async-executor", + "async-io", + "async-lock", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener 5.4.1", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-object-pool" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333c456b97c3f2d50604e8b2624253b7f787208cb72eb75e64b0ad11b221652c" +dependencies = [ + "async-std", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel 2.5.0", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener 5.4.1", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-std" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8e079a4ab67ae52b7403632e4618815d6db36d2a010cfe41b02c1b1578f93b" +dependencies = [ + "async-attributes", + "async-channel 1.9.0", + "async-global-executor", + "async-io", + "async-lock", + "async-process", + "crossbeam-utils", + "futures-channel", + "futures-core", + "futures-io", + "futures-lite", + "gloo-timers", + "kv-log-macro", + "log", + "memchr", + "once_cell", + "pin-project-lite", + "pin-utils", + "slab", + "wasm-bindgen-futures", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + [[package]] name = "async-trait" version = "0.1.89" @@ -690,10 +879,10 @@ dependencies = [ "bytes", "form_urlencoded", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.8.1", "hyper-util", "itoa", "matchit", @@ -723,8 +912,8 @@ checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "mime", "pin-project-lite", @@ -746,6 +935,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -758,6 +953,17 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" +[[package]] +name = "basic-cookies" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67bd8fd42c16bdb08688243dc5f0cc117a3ca9efeeaba3a345a18a6159ad96f7" +dependencies = [ + "lalrpop", + "lalrpop-util", + "regex", +] + [[package]] name = "bincode" version = "1.3.3" @@ -767,15 +973,30 @@ dependencies = [ "serde", ] +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec 0.6.3", +] + [[package]] name = "bit-set" version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" dependencies = [ - "bit-vec", + "bit-vec 0.8.0", ] +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + [[package]] name = "bit-vec" version = "0.8.0" @@ -851,6 +1072,19 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel 2.5.0", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + [[package]] name = "borrow-or-share" version = "0.2.4" @@ -1113,6 +1347,15 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-hex" version = "1.18.1" @@ -1537,6 +1780,16 @@ dependencies = [ "dirs-sys", ] +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + [[package]] name = "dirs-sys" version = "0.5.0" @@ -1545,10 +1798,21 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1678,6 +1942,15 @@ dependencies = [ "serde", ] +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1743,13 +2016,40 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener 5.4.1", + "pin-project-lite", +] + [[package]] name = "fancy-regex" version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" dependencies = [ - "bit-set", + "bit-set 0.8.0", "regex-automata", "regex-syntax", ] @@ -1868,6 +2168,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + [[package]] name = "fixedbitset" version = "0.5.7" @@ -1991,6 +2297,19 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.32" @@ -2093,6 +2412,18 @@ dependencies = [ "wasip3", ] +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "group" version = "0.13.0" @@ -2115,7 +2446,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.4.0", "indexmap", "slab", "tokio", @@ -2185,6 +2516,12 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + [[package]] name = "hex" version = "0.4.3" @@ -2200,6 +2537,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http" version = "1.4.0" @@ -2210,6 +2558,17 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -2217,7 +2576,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.4.0", ] [[package]] @@ -2228,8 +2587,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -2251,6 +2610,34 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "httpmock" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08ec9586ee0910472dec1a1f0f8acf52f0fdde93aea74d70d4a3107b4be0fd5b" +dependencies = [ + "assert-json-diff", + "async-object-pool", + "async-std", + "async-trait", + "base64 0.21.7", + "basic-cookies", + "crossbeam-utils", + "form_urlencoded", + "futures-util", + "hyper 0.14.32", + "lazy_static", + "levenshtein", + "log", + "regex", + "serde", + "serde_json", + "serde_regex", + "similar", + "tokio", + "url", +] + [[package]] name = "hybrid-array" version = "0.4.10" @@ -2260,6 +2647,29 @@ dependencies = [ "typenum", ] +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.8.1" @@ -2271,8 +2681,8 @@ dependencies = [ "futures-channel", "futures-core", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "httparse", "httpdate", "itoa", @@ -2289,8 +2699,8 @@ version = "0.27.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" dependencies = [ - "http", - "hyper", + "http 1.4.0", + "hyper 1.8.1", "hyper-util", "rustls", "rustls-pki-types", @@ -2307,7 +2717,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper", + "hyper 1.8.1", "hyper-util", "native-tls", "tokio", @@ -2325,14 +2735,14 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", - "hyper", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.6.3", "system-configuration", "tokio", "tower-service", @@ -2508,7 +2918,7 @@ name = "ilold-core" version = "0.1.0" dependencies = [ "ilold-session-core", - "petgraph", + "petgraph 0.8.3", "serde", "serde_json", "solar-compiler", @@ -2524,6 +2934,7 @@ name = "ilold-mcp" version = "0.1.0" dependencies = [ "anyhow", + "httpmock", "ilold-help", "ilold-render", "ilold-session-core", @@ -2730,6 +3141,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.12.1" @@ -2846,6 +3266,46 @@ dependencies = [ "sha3-asm", ] +[[package]] +name = "kv-log-macro" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de8b303297635ad57c9f5059fd9cee7a47f8e8daa09df0fcd07dd39fb22977f" +dependencies = [ + "log", +] + +[[package]] +name = "lalrpop" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cb077ad656299f160924eb2912aa147d7339ea7d69e1b5517326fdcec3c1ca" +dependencies = [ + "ascii-canvas", + "bit-set 0.5.3", + "ena", + "itertools 0.11.0", + "lalrpop-util", + "petgraph 0.6.5", + "pico-args", + "regex", + "regex-syntax", + "string_cache", + "term", + "tiny-keccak", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" +dependencies = [ + "regex-automata", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -2858,6 +3318,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "levenshtein" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db13adb97ab515a3691f56e4dbab09283d0b86cb45abd991d8634a9d6f501760" + [[package]] name = "libc" version = "0.2.183" @@ -3054,6 +3520,9 @@ name = "log" version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +dependencies = [ + "value-bag", +] [[package]] name = "lru" @@ -3157,6 +3626,12 @@ dependencies = [ "tempfile", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + [[package]] name = "nix" version = "0.25.1" @@ -3453,6 +3928,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "parking_lot" version = "0.12.5" @@ -3522,18 +4003,43 @@ dependencies = [ "ucd-trie", ] +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset 0.4.2", + "indexmap", +] + [[package]] name = "petgraph" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ - "fixedbitset", + "fixedbitset 0.5.7", "hashbrown 0.15.5", "indexmap", "serde", ] +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pico-args" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -3546,6 +4052,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -3562,6 +4079,20 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + [[package]] name = "polyval" version = "0.6.2" @@ -3625,6 +4156,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "prettyplease" version = "0.2.37" @@ -3670,8 +4207,8 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ - "bit-set", - "bit-vec", + "bit-set 0.8.0", + "bit-vec 0.8.0", "bitflags 2.11.0", "num-traits", "rand 0.9.2", @@ -3948,6 +4485,17 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -4057,10 +4605,10 @@ dependencies = [ "encoding_rs", "futures-core", "h2", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", - "hyper", + "hyper 1.8.1", "hyper-rustls", "hyper-tls", "hyper-util", @@ -4295,6 +4843,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -4487,6 +5044,16 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_regex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8136f1a4ea815d7eac4101cfd0b16dc0cb5e1fe1b8609dfd728058656b7badf" +dependencies = [ + "regex", + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -4696,6 +5263,18 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -4708,6 +5287,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.3" @@ -6234,6 +6823,18 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + [[package]] name = "strip-ansi-escapes" version = "0.2.1" @@ -6377,6 +6978,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + [[package]] name = "terminal_size" version = "0.4.4" @@ -6466,6 +7078,15 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -6503,7 +7124,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -6632,8 +7253,8 @@ dependencies = [ "bytes", "futures-core", "futures-util", - "http", - "http-body", + "http 1.4.0", + "http-body 1.0.1", "http-body-util", "http-range-header", "httpdate", @@ -6739,7 +7360,7 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.8.5", @@ -6756,7 +7377,7 @@ checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", - "http", + "http 1.4.0", "httparse", "log", "rand 0.9.2", @@ -6923,6 +7544,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "value-bag" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba6f5989077681266825251a52748b8c1d8a4ad098cc37e440103d0ea717fc0" + [[package]] name = "vcpkg" version = "0.2.15" @@ -6965,6 +7592,16 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -7119,6 +7756,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "winapi-x86_64-pc-windows-gnu" version = "0.4.0" diff --git a/crates/ilold-cli/src/main.rs b/crates/ilold-cli/src/main.rs index 98734be..7a840b4 100644 --- a/crates/ilold-cli/src/main.rs +++ b/crates/ilold-cli/src/main.rs @@ -72,6 +72,11 @@ enum Commands { /// has to think about workspace topology. #[arg(long, env = "ILOLD_CONTRACT")] contract: String, + /// Emit a `notifications/progress` MCP message before each tool call + /// describing intent. Useful when the client UI renders progress next + /// to tool calls so the human sees what the LLM is about to run. + #[arg(long, env = "ILOLD_NARRATION")] + narration: bool, }, } @@ -103,7 +108,9 @@ async fn main() -> Result<()> { ProjectKind::Solana => explore::run_solana(detected, port).await, } } - Commands::Mcp { server_url, contract } => ilold_mcp::run(server_url, contract).await, + Commands::Mcp { server_url, contract, narration } => { + ilold_mcp::run(ilold_mcp::Config { server_url, contract, narration }).await + } } } diff --git a/crates/ilold-mcp/Cargo.toml b/crates/ilold-mcp/Cargo.toml index 107a432..e509ed5 100644 --- a/crates/ilold-mcp/Cargo.toml +++ b/crates/ilold-mcp/Cargo.toml @@ -22,3 +22,4 @@ reqwest = { version = "0.12", features = ["json"] } [dev-dependencies] jsonschema = { version = "0.46", default-features = false } +httpmock = "0.7" diff --git a/crates/ilold-mcp/src/lib.rs b/crates/ilold-mcp/src/lib.rs index f22897a..538aeab 100644 --- a/crates/ilold-mcp/src/lib.rs +++ b/crates/ilold-mcp/src/lib.rs @@ -6,6 +6,7 @@ use rmcp::transport::io::stdio; pub mod client; pub mod error; +pub mod narration; pub mod schema; pub mod server; pub mod tools; @@ -13,7 +14,14 @@ pub mod tools; pub use client::IloldClient; pub use error::McpClientError; -pub async fn run(server_url: String, contract: String) -> Result<()> { +#[derive(Debug, Clone)] +pub struct Config { + pub server_url: String, + pub contract: String, + pub narration: bool, +} + +pub async fn run(cfg: Config) -> Result<()> { tracing_subscriber::fmt() .with_writer(std::io::stderr) .with_env_filter( @@ -23,13 +31,13 @@ pub async fn run(server_url: String, contract: String) -> Result<()> { .try_init() .ok(); - let client = Arc::new(IloldClient::new(server_url, contract)); + let client = Arc::new(IloldClient::new(cfg.server_url, cfg.contract)); client .health_check() .await .context("Ilold backend health-check failed")?; - let handler = server::IloldMcpServer::new(client); + let handler = server::IloldMcpServer::new(client, cfg.narration); let service = handler.serve(stdio()).await?; service.waiting().await?; Ok(()) diff --git a/crates/ilold-mcp/src/narration.rs b/crates/ilold-mcp/src/narration.rs new file mode 100644 index 0000000..985363c --- /dev/null +++ b/crates/ilold-mcp/src/narration.rs @@ -0,0 +1,243 @@ +use serde_json::Value; + +pub fn intent_for_tool(name: &str, args: Option<&Value>) -> String { + let obj = args.and_then(|v| v.as_object()); + match name { + "ilold_call" => narrate_call(args), + "ilold_back" => "Reverting the last step".to_string(), + "ilold_clear" => "Clearing the active scenario".to_string(), + "ilold_state" => "Dumping mutated account state".to_string(), + "ilold_session" => "Summarizing the active scenario".to_string(), + "ilold_programs" => "Listing workspace programs".to_string(), + "ilold_funcs" | "ilold_funcs_all" => "Listing instructions of the active program".to_string(), + "ilold_info" => match obj.and_then(|o| o.get("ix")).and_then(|v| v.as_str()) { + Some(ix) => format!("Inspecting instruction `{ix}`"), + None => "Inspecting instruction".to_string(), + }, + "ilold_vars" => "Listing account types and discriminators".to_string(), + "ilold_users" => "Listing scenario keypairs".to_string(), + "ilold_users_new" => narrate_users_new(args), + "ilold_airdrop" => narrate_airdrop(args), + "ilold_time_warp" => narrate_time_warp(args), + "ilold_pda" => match obj.and_then(|o| o.get("instruction")).and_then(|v| v.as_str()) { + Some(ix) => format!("Listing PDAs of instruction `{ix}`"), + None => "Listing PDAs".to_string(), + }, + "ilold_inspect" => match obj.and_then(|o| o.get("pubkey")).and_then(|v| v.as_str()) { + Some(p) => format!("Decoding account `{p}`"), + None => "Decoding account".to_string(), + }, + "ilold_step" => match obj.and_then(|o| o.get("index")) { + Some(idx) => format!("Re-inspecting step {idx}"), + None => "Re-inspecting step".to_string(), + }, + "ilold_who" => match obj.and_then(|o| o.get("account_type")).and_then(|v| v.as_str()) { + Some(t) => format!("Looking up who touches account type `{t}`"), + None => "Looking up cross-references".to_string(), + }, + "ilold_timeline" => match obj.and_then(|o| o.get("pubkey")).and_then(|v| v.as_str()) { + Some(p) => format!("Tracing timeline of account `{p}`"), + None => "Tracing account timeline".to_string(), + }, + "ilold_coupling" => "Computing instruction coupling".to_string(), + "ilold_coverage" => "Computing runtime coverage".to_string(), + "ilold_finding" => narrate_finding(args), + "ilold_findings" => "Listing scenario findings".to_string(), + "ilold_note" => "Adding a free-form note".to_string(), + "ilold_status" => match ( + obj.and_then(|o| o.get("ix")).and_then(|v| v.as_str()), + obj.and_then(|o| o.get("status")).and_then(|v| v.as_str()), + ) { + (Some(ix), Some(s)) => format!("Marking `{ix}` as `{s}`"), + _ => "Updating instruction review status".to_string(), + }, + "ilold_export" => "Generating the deliverable export".to_string(), + "ilold_scenario" => narrate_scenario(args), + "ilold_save" => "Saving the scenario snapshot".to_string(), + "ilold_load" => "Restoring a scenario from JSON".to_string(), + other => format!("Calling `{other}`"), + } +} + +fn narrate_call(args: Option<&Value>) -> String { + let Some(obj) = args.and_then(|v| v.as_object()) else { + return "Calling instruction".to_string(); + }; + let ix = obj.get("ix").and_then(|v| v.as_str()).unwrap_or("?"); + let mut bits: Vec<String> = Vec::new(); + if let Some(a) = obj.get("args").and_then(|v| v.as_object()) { + for (k, v) in a.iter().take(2) { + bits.push(format!("{k}={}", short_value(v))); + } + } + if let Some(acc) = obj.get("accounts").and_then(|v| v.as_object()) { + for (k, v) in acc.iter().take(2) { + bits.push(format!("{k}={}", short_value(v))); + } + } + if bits.is_empty() { + format!("Calling `{ix}`") + } else { + format!("Calling `{ix}` with {}", bits.join(", ")) + } +} + +fn narrate_users_new(args: Option<&Value>) -> String { + let name = args + .and_then(|v| v.as_object()) + .and_then(|o| o.get("name")) + .and_then(|v| v.as_str()); + match name { + Some(n) => format!("Creating a new keypair `{n}`"), + None => "Creating a new keypair".to_string(), + } +} + +fn narrate_airdrop(args: Option<&Value>) -> String { + let obj = args.and_then(|v| v.as_object()); + let user = obj.and_then(|o| o.get("user")).and_then(|v| v.as_str()); + let lamports = obj.and_then(|o| o.get("lamports")); + match (user, lamports) { + (Some(u), Some(l)) => format!("Airdropping {l} lamports to `{u}`"), + (Some(u), None) => format!("Airdropping to `{u}`"), + _ => "Airdropping lamports".to_string(), + } +} + +fn narrate_time_warp(args: Option<&Value>) -> String { + match args + .and_then(|v| v.as_object()) + .and_then(|o| o.get("delta_seconds")) + { + Some(d) => format!("Advancing the clock by {d}s"), + None => "Advancing the clock".to_string(), + } +} + +fn narrate_finding(args: Option<&Value>) -> String { + let obj = args.and_then(|v| v.as_object()); + let sev = obj.and_then(|o| o.get("severity")).and_then(|v| v.as_str()); + let title = obj.and_then(|o| o.get("title")).and_then(|v| v.as_str()); + match (sev, title) { + (Some(s), Some(t)) => format!("Recording {s} finding: `{t}`"), + (None, Some(t)) => format!("Recording finding: `{t}`"), + _ => "Recording finding".to_string(), + } +} + +fn narrate_scenario(args: Option<&Value>) -> String { + let sub = args + .and_then(|v| v.as_object()) + .and_then(|o| o.get("sub")); + let Some(sub_obj) = sub.and_then(|v| v.as_object()) else { + return "Running scenario sub-command".to_string(); + }; + if let Some((variant, payload)) = sub_obj.iter().next() { + let name = payload + .as_object() + .and_then(|p| p.get("name")) + .and_then(|v| v.as_str()); + return match name { + Some(n) => format!("Scenario `{variant}` on `{n}`"), + None => format!("Scenario `{variant}`"), + }; + } + "Running scenario sub-command".to_string() +} + +fn short_value(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.to_string(), + Value::Bool(b) => b.to_string(), + Value::Null => "null".to_string(), + other => { + let s = other.to_string(); + if s.len() > 32 { + format!("{}…", &s[..32]) + } else { + s + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn narrate_call_includes_ix_and_first_args() { + let args = json!({ "ix": "stake", "args": { "amount": 1000 } }); + let out = intent_for_tool("ilold_call", Some(&args)); + assert!(out.contains("stake")); + assert!(out.contains("amount=1000")); + } + + #[test] + fn narrate_call_truncates_at_two_pairs() { + let args = json!({ + "ix": "stake", + "args": { "a": 1, "b": 2, "c": 3, "d": 4, "e": 5 } + }); + let out = intent_for_tool("ilold_call", Some(&args)); + assert!(out.contains("a=1")); + assert!(out.contains("b=2")); + assert!(!out.contains("c=3"), "should truncate after two pairs: {out}"); + } + + #[test] + fn narrate_call_combines_args_and_accounts() { + let args = json!({ + "ix": "stake", + "args": { "amount": 1000 }, + "accounts": { "user": "alice" }, + }); + let out = intent_for_tool("ilold_call", Some(&args)); + assert!(out.contains("amount=1000")); + assert!(out.contains("user=alice")); + } + + #[test] + fn narrate_users_new_includes_name() { + let args = json!({ "name": "alice" }); + let out = intent_for_tool("ilold_users_new", Some(&args)); + assert!(out.contains("alice")); + assert!(out.to_lowercase().contains("keypair")); + } + + #[test] + fn narrate_who_includes_account_type() { + let args = json!({ "account_type": "Pool" }); + let out = intent_for_tool("ilold_who", Some(&args)); + assert!(out.contains("Pool")); + } + + #[test] + fn narrate_unknown_tool_falls_back_to_generic() { + let out = intent_for_tool("ilold_made_up", None); + assert_eq!(out, "Calling `ilold_made_up`"); + } + + #[test] + fn narrate_coverage_is_short() { + let out = intent_for_tool("ilold_coverage", None); + assert_eq!(out, "Computing runtime coverage"); + } + + #[test] + fn narrate_call_with_empty_args_returns_minimal() { + let args = json!({ "ix": "stake" }); + let out = intent_for_tool("ilold_call", Some(&args)); + assert_eq!(out, "Calling `stake`"); + } + + #[test] + fn narrate_scenario_includes_sub_variant() { + let args = json!({ "sub": { "New": { "name": "branch1" } } }); + let out = intent_for_tool("ilold_scenario", Some(&args)); + assert!(out.contains("New")); + assert!(out.contains("branch1")); + } +} diff --git a/crates/ilold-mcp/src/server.rs b/crates/ilold-mcp/src/server.rs index f35bcd3..3238423 100644 --- a/crates/ilold-mcp/src/server.rs +++ b/crates/ilold-mcp/src/server.rs @@ -6,21 +6,24 @@ use ilold_solana_core::exploration::SolanaCommandResult; use rmcp::ServerHandler; use rmcp::model::{ CallToolRequestParams, CallToolResult, Content, Implementation, ListToolsResult, - PaginatedRequestParams, ServerCapabilities, ServerInfo, + PaginatedRequestParams, ProgressNotificationParam, ProgressToken, ServerCapabilities, + ServerInfo, }; -use rmcp::service::{RequestContext, RoleServer}; +use rmcp::service::{Peer, RequestContext, RoleServer}; use serde_json::Value; use crate::client::IloldClient; +use crate::narration::intent_for_tool; use crate::tools; pub struct IloldMcpServer { client: Arc<IloldClient>, + narration: bool, } impl IloldMcpServer { - pub fn new(client: Arc<IloldClient>) -> Self { - Self { client } + pub fn new(client: Arc<IloldClient>, narration: bool) -> Self { + Self { client, narration } } } @@ -50,15 +53,38 @@ impl ServerHandler for IloldMcpServer { async fn call_tool( &self, request: CallToolRequestParams, - _context: RequestContext<RoleServer>, + context: RequestContext<RoleServer>, ) -> Result<CallToolResult, rmcp::ErrorData> { let tool_name = request.name.to_string(); let args_value = request.arguments.map(Value::Object); + if self.narration { + let token = context.meta.get_progress_token(); + emit_intent_progress(&context.peer, token, &tool_name, args_value.as_ref()).await; + } let res = dispatch(&self.client, &tool_name, args_value.as_ref()).await; Ok(res) } } +async fn emit_intent_progress( + peer: &Peer<RoleServer>, + token: Option<ProgressToken>, + tool_name: &str, + arguments: Option<&Value>, +) { + let Some(progress_token) = token else { return }; + let message = intent_for_tool(tool_name, arguments); + let params = ProgressNotificationParam { + progress_token, + progress: 0.0, + total: Some(1.0), + message: Some(message), + }; + if let Err(err) = peer.notify_progress(params).await { + tracing::debug!(?err, "notify_progress failed"); + } +} + async fn dispatch( client: &IloldClient, tool_name: &str, diff --git a/crates/ilold-mcp/tests/client_httpmock.rs b/crates/ilold-mcp/tests/client_httpmock.rs new file mode 100644 index 0000000..ef4ecc8 --- /dev/null +++ b/crates/ilold-mcp/tests/client_httpmock.rs @@ -0,0 +1,122 @@ +//! Unit tests for `IloldClient` against a real (local) HTTP mock server. +//! +//! Covers SDD-05 § T-R55b.2.10 sub-tasks: +//! - body serialization for `/api/cmd` +//! - health-check rejecting non-Solana projects +//! - `contract` field forwarded verbatim + +use httpmock::Method::{GET, POST}; +use httpmock::MockServer; +use ilold_mcp::IloldClient; +use ilold_solana_core::exploration::SolanaCommandResult; +use serde_json::json; + +#[tokio::test] +async fn client_send_command_serializes_body_correctly() { + let server = MockServer::start_async().await; + let mock = server.mock(|when, then| { + when.method(POST) + .path("/api/cmd") + .json_body(json!({ + "contract": "staking", + "command": { + "Call": { + "ix": "stake", + "args": { "amount": 1000 }, + "accounts": {}, + "signers": [] + } + } + })); + then.status(200).json_body(json!({ + "StepAdded": { + "step_index": 0, + "instruction": "stake", + "logs_excerpt": [], + "account_diffs_count": 0, + "compute_units": 0, + "error": null + } + })); + }); + + let client = IloldClient::new(server.base_url(), "staking".into()); + let command = json!({ + "Call": { + "ix": "stake", + "args": { "amount": 1000 }, + "accounts": {}, + "signers": [] + } + }); + let result = client + .send_command(command) + .await + .expect("send_command succeeds"); + match result { + SolanaCommandResult::StepAdded { instruction, .. } => { + assert_eq!(instruction, "stake"); + } + other => panic!("expected StepAdded, got {other:?}"), + } + mock.assert(); +} + +#[tokio::test] +async fn client_health_check_rejects_solidity() { + let server = MockServer::start_async().await; + server.mock(|when, then| { + when.method(GET).path("/api/project/map"); + then.status(200).json_body(json!({ + "kind": "solidity", + "programs": [] + })); + }); + + let client = IloldClient::new(server.base_url(), "Foo".into()); + let err = client + .health_check() + .await + .expect_err("solidity backend must be rejected"); + let msg = err.to_string(); + assert!( + msg.contains("not Solana") && msg.contains("solidity"), + "expected NotSolana error, got: {msg}" + ); +} + +#[tokio::test] +async fn client_passes_contract_field() { + let server = MockServer::start_async().await; + let mock = server.mock(|when, then| { + when.method(POST) + .path("/api/cmd") + .json_body_partial(r#"{ "contract": "lever" }"#); + then.status(200).json_body(json!("NoteAdded")); + }); + + let client = IloldClient::new(server.base_url(), "lever".into()); + let _ = client + .send_command(json!("Funcs")) + .await + .expect("send_command serializes contract"); + mock.assert(); +} + +#[tokio::test] +async fn client_health_check_accepts_solana() { + let server = MockServer::start_async().await; + server.mock(|when, then| { + when.method(GET).path("/api/project/map"); + then.status(200).json_body(json!({ + "kind": "solana", + "programs": [{ "name": "staking" }] + })); + }); + + let client = IloldClient::new(server.base_url(), "staking".into()); + client + .health_check() + .await + .expect("solana backend should pass health check"); +} diff --git a/crates/ilold-mcp/tests/mcp_stdio_full_flow.rs b/crates/ilold-mcp/tests/mcp_stdio_full_flow.rs new file mode 100644 index 0000000..80ac4e1 --- /dev/null +++ b/crates/ilold-mcp/tests/mcp_stdio_full_flow.rs @@ -0,0 +1,212 @@ +//! End-to-end stdio integration test for `ilold mcp`. +//! +//! Boots a mocked Ilold backend with `httpmock`, spawns the real `ilold mcp` +//! binary as a child process, exchanges the MCP initialize handshake, +//! lists tools, and finally fires a `tools/call ilold_funcs` and validates +//! that the structured `InstructionList` payload comes back. +//! +//! Marked `#[ignore]`: requires the `ilold` binary to be pre-built (the test +//! locates it at `<workspace>/target/<profile>/ilold`). Run it after +//! `cargo build -p ilold-cli` (debug) or `cargo build --release -p ilold-cli`. +//! `cargo test --workspace` skips it by default to keep CI fast. + +use std::io::{BufRead, BufReader, Write}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; +use std::time::Duration; + +use httpmock::Method::{GET, POST}; +use httpmock::MockServer; +use serde_json::{Value, json}; + +fn ilold_binary() -> Option<PathBuf> { + // Two candidates: debug build, then release. + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace = manifest.parent()?.parent()?; + for profile in ["debug", "release"] { + let p = workspace.join("target").join(profile).join("ilold"); + if p.exists() { + return Some(p); + } + } + None +} + +fn write_json_line<W: Write>(w: &mut W, v: &Value) -> std::io::Result<()> { + let line = serde_json::to_string(v).expect("serialize json"); + writeln!(w, "{line}")?; + w.flush() +} + +fn read_response<R: BufRead>(reader: &mut R, want_id: u64) -> Option<Value> { + for _ in 0..256 { + let mut line = String::new(); + let n = reader.read_line(&mut line).ok()?; + if n == 0 { + return None; + } + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let parsed: Value = match serde_json::from_str(trimmed) { + Ok(v) => v, + Err(_) => continue, + }; + if parsed.get("id").and_then(|v| v.as_u64()) == Some(want_id) { + return Some(parsed); + } + } + None +} + +#[test] +#[ignore = "requires pre-built `ilold` binary at target/debug/ilold or target/release/ilold"] +fn mcp_stdio_full_flow_initialize_list_call() { + let ilold = match ilold_binary() { + Some(p) => p, + None => { + eprintln!("skipping: ilold binary not found; run `cargo build -p ilold-cli` first"); + return; + } + }; + + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/api/project/map"); + then.status(200).json_body(json!({ + "kind": "solana", + "programs": [{ "name": "staking" }] + })); + }); + server.mock(|when, then| { + when.method(POST).path("/api/cmd"); + then.status(200).json_body(json!({ + "InstructionList": { + "items": [ + { + "name": "stake", + "args_count": 1, + "accounts_count": 3, + "has_pdas": false, + "signers": ["user"] + }, + { + "name": "unstake", + "args_count": 0, + "accounts_count": 2, + "has_pdas": false, + "signers": ["user"] + } + ] + } + })); + }); + + let mut child = Command::new(&ilold) + .arg("mcp") + .arg("--server-url") + .arg(server.base_url()) + .arg("--contract") + .arg("staking") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("spawn ilold mcp"); + + let mut stdin = child.stdin.take().expect("stdin handle"); + let mut stdout = BufReader::new(child.stdout.take().expect("stdout handle")); + + let init = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "ilold-integration-test", "version": "0.0.0" } + } + }); + write_json_line(&mut stdin, &init).expect("send initialize"); + let init_resp = read_response(&mut stdout, 1).expect("initialize response"); + assert!( + init_resp.get("result").is_some(), + "initialize must succeed: {init_resp}" + ); + + let initialized = json!({ + "jsonrpc": "2.0", + "method": "notifications/initialized" + }); + write_json_line(&mut stdin, &initialized).expect("send initialized notif"); + + let list = json!({ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list" + }); + write_json_line(&mut stdin, &list).expect("send tools/list"); + let list_resp = read_response(&mut stdout, 2).expect("tools/list response"); + let tools = list_resp + .pointer("/result/tools") + .and_then(|v| v.as_array()) + .cloned() + .expect("tools array"); + assert_eq!(tools.len(), 29, "expected 29 tools, got {}", tools.len()); + let names: Vec<&str> = tools + .iter() + .filter_map(|t| t.get("name").and_then(|n| n.as_str())) + .collect(); + assert!(names.contains(&"ilold_funcs")); + assert!(names.contains(&"ilold_call")); + + let call = json!({ + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": { "name": "ilold_funcs", "arguments": {} } + }); + write_json_line(&mut stdin, &call).expect("send tools/call"); + let call_resp = read_response(&mut stdout, 3).expect("tools/call response"); + + let structured = call_resp + .pointer("/result/structuredContent") + .expect("structuredContent present"); + let items = structured + .pointer("/InstructionList/items") + .and_then(|v| v.as_array()) + .expect("InstructionList.items present"); + assert_eq!(items.len(), 2); + + let text = call_resp + .pointer("/result/content/0/text") + .and_then(|v| v.as_str()) + .expect("text content"); + assert!( + text.contains("stake"), + "expected rendered text to mention `stake`, got: {text}" + ); + + drop(stdin); + let _ = child.wait_timeout_or_kill(Duration::from_secs(5)); +} + +trait WaitTimeoutExt { + fn wait_timeout_or_kill(&mut self, timeout: Duration); +} + +impl WaitTimeoutExt for std::process::Child { + fn wait_timeout_or_kill(&mut self, timeout: Duration) { + let start = std::time::Instant::now(); + while start.elapsed() < timeout { + match self.try_wait() { + Ok(Some(_)) => return, + Ok(None) => std::thread::sleep(Duration::from_millis(50)), + Err(_) => break, + } + } + let _ = self.kill(); + let _ = self.wait(); + } +} From 92c857e2ceecc4450ec78e75d8714110501846f8 Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Mon, 11 May 2026 13:56:25 +0200 Subject: [PATCH 107/115] docs(mcp): add reference page with client setup and tool registry - Add reference/mcp.md covering architecture, setup, four client configs - Tool reference table mirrors SOLANA_HELP_BLOCKS (29 tools) - Add SUMMARY entry under Reference and introduction Where to start link - Remove MCP entry from roadmap/cross-cutting (now shipped) - mdbook build clean --- docs/guide/src/SUMMARY.md | 1 + docs/guide/src/introduction.md | 1 + docs/guide/src/reference/mcp.md | 243 ++++++++++++++++++++++++ docs/guide/src/roadmap/cross-cutting.md | 4 - 4 files changed, 245 insertions(+), 4 deletions(-) create mode 100644 docs/guide/src/reference/mcp.md diff --git a/docs/guide/src/SUMMARY.md b/docs/guide/src/SUMMARY.md index 3fd5ed2..0fc65bb 100644 --- a/docs/guide/src/SUMMARY.md +++ b/docs/guide/src/SUMMARY.md @@ -42,6 +42,7 @@ - [HTTP API](./reference/api-endpoints.md) - [WebSocket events](./reference/websocket.md) +- [MCP server](./reference/mcp.md) - [Known limitations](./reference/limitations.md) # Roadmap diff --git a/docs/guide/src/introduction.md b/docs/guide/src/introduction.md index f4e0097..8c1d5a5 100644 --- a/docs/guide/src/introduction.md +++ b/docs/guide/src/introduction.md @@ -28,4 +28,5 @@ ilold does not detect vulnerabilities automatically. It gives the auditor primit - [Solidity Backend](./solidity/overview.md): Solidity REPL, CLI, workflows. - [Solana Backend](./solana/overview.md): Solana REPL, runtime commands, workflows. - [Reference](./reference/api-endpoints.md): HTTP API and WebSocket events. +- [MCP server](./reference/mcp.md): drive ilold from an LLM agent. - [Roadmap](./roadmap/solidity.md): known gaps and future work. diff --git a/docs/guide/src/reference/mcp.md b/docs/guide/src/reference/mcp.md new file mode 100644 index 0000000..e9aad28 --- /dev/null +++ b/docs/guide/src/reference/mcp.md @@ -0,0 +1,243 @@ +# MCP Server + +ilold ships an MCP (Model Context Protocol) server that exposes the Solana REPL as a set of typed tools. Any MCP-compatible client (Claude Code, Claude Desktop, Cursor, Continue) can invoke those tools to drive an audit programmatically: list instructions, call them against the live LiteSVM, inspect state, record findings, and export the deliverable. The MCP server is a thin transport on top of the existing HTTP API; it adds no new domain logic. + +## Architecture + +``` +LLM client ──── stdio ────► ilold mcp ──── HTTP ────► ilold serve ────► LiteSVM + │ + └──── WebSocket ────► Web canvas (optional) +``` + +The MCP client launches `ilold mcp` as a local subprocess and talks to it over stdio (newline-delimited JSON-RPC). The MCP process is stateless: each `tools/call` translates the arguments into a `SolanaCommand` and forwards it to a running `ilold serve` instance via `POST /api/cmd`. The same backend broadcasts canvas patches over WebSocket, so a browser tab connected to the web canvas reflects every step the LLM takes. + +Only Solana is supported in v1. The MCP server refuses to start when the backend reports `kind != "solana"`. + +## Setup + +Two processes need to be running: + +1. **Backend**: an `ilold serve` instance pointing at the project to audit. + + ``` + ilold serve tests/fixtures/solana/staking --port 8080 + ``` + + The MCP server defaults to `http://127.0.0.1:8080`, so any free port works as long as `--server-url` matches. + +2. **MCP client**: configure the LLM client to spawn `ilold mcp` (see the client snippets below). The client launches the subprocess on demand and tears it down when the session ends. + +The `ilold` binary must be on the client's `PATH`. If it is not, use the absolute path returned by `which ilold` in the `command` field. + +## CLI reference + +``` +ilold mcp [OPTIONS] --contract <CONTRACT> +``` + +| Flag | Required | Default | Description | +| --- | --- | --- | --- | +| `--server-url <URL>` | no | `http://127.0.0.1:8080` | Base URL of the `ilold serve` instance. Environment variable: `ILOLD_SERVER_URL`. | +| `--contract <NAME>` | yes | : | Target program name. Every tool call routes to this program through the `contract` field of `/api/cmd`. Environment variable: `ILOLD_CONTRACT`. | +| `--narration` | no | off | Emit a `notifications/progress` MCP message before each tool call describing intent (for example `Calling \`stake\` with amount=1000`). Environment variable: `ILOLD_NARRATION`. | + +The MCP transport reserves stdout for JSON-RPC; logs and panics go to stderr. + +## Client configuration + +Every snippet below assumes the backend is running on `http://127.0.0.1:8080` and the target program is `staking`. Adjust both values to your workspace. + +### Claude Code + +Two options. The first is project-scoped (`.mcp.json` at the repository root, checked into version control); the second is the `claude mcp add` CLI which writes to `~/.claude.json` by default. + +`.mcp.json`: + +```json +{ + "mcpServers": { + "ilold": { + "command": "ilold", + "args": [ + "mcp", + "--server-url", "http://127.0.0.1:8080", + "--contract", "staking" + ] + } + } +} +``` + +Equivalent CLI form: + +``` +claude mcp add --transport stdio ilold -- ilold mcp --server-url http://127.0.0.1:8080 --contract staking +``` + +### Claude Desktop + +Edit `claude_desktop_config.json` (Developer → Edit Config in the desktop settings): + +- macOS: `~/Library/Application Support/Claude/claude_desktop_config.json` +- Windows: `%APPDATA%\Claude\claude_desktop_config.json` + +```json +{ + "mcpServers": { + "ilold": { + "command": "ilold", + "args": [ + "mcp", + "--server-url", "http://127.0.0.1:8080", + "--contract", "staking" + ] + } + } +} +``` + +Restart Claude Desktop after saving. The MCP indicator in the input box lists `ilold` and its tools when the connection is healthy. + +### Cursor + +Place the file at `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global): + +```json +{ + "mcpServers": { + "ilold": { + "command": "ilold", + "args": [ + "mcp", + "--server-url", "http://127.0.0.1:8080", + "--contract", "staking" + ] + } + } +} +``` + +Optional `env` and `envFile` keys are supported by Cursor for passing environment variables. + +### Continue + +Continue uses YAML. Edit `~/.continue/config.yaml`: + +```yaml +mcpServers: + - name: ilold + type: stdio + command: ilold + args: + - mcp + - --server-url + - http://127.0.0.1:8080 + - --contract + - staking +``` + +## Tools + +The registry is derived at startup from `crates/ilold-help/src/lib.rs::SOLANA_HELP_BLOCKS`. The table below lists every exposed tool with a one-line summary. Each tool returns the matching `SolanaCommandResult` variant as structured JSON plus a pretty-printed text block identical to the REPL output. + +### Discovery (read-only) + +| Tool | Purpose | +| --- | --- | +| `ilold_programs` | List every program detected in the workspace. | +| `ilold_funcs` | List the instructions exposed by the active program. | +| `ilold_funcs_all` | Same list with admin-gating and coupling hints. | +| `ilold_info` | Detail one instruction: args, accounts, signers, PDAs, discriminator. | +| `ilold_vars` | List declared account types with their Anchor discriminators. | +| `ilold_pda` | List the PDAs declared by an instruction (seeds, bumps). | +| `ilold_who` | Resolve a query against the IDL (account type, instruction, or field). | +| `ilold_coupling` | List instruction pairs that share a writable account. | + +### Session (mutate the timeline) + +| Tool | Purpose | +| --- | --- | +| `ilold_call` | Run an Anchor instruction against LiteSVM and append the result as a step. | +| `ilold_back` | Remove the last step from the active scenario and rewind the VM. | +| `ilold_clear` | Reset the active scenario steps and the underlying VM state. | +| `ilold_state` | Decoded view of every account mutated during the active scenario. | +| `ilold_session` | Active scenario summary: steps, findings, notes. | +| `ilold_step` | Re-inspect one step: CU, logs, decoded diffs. | + +### Runtime (mutate the VM) + +| Tool | Purpose | +| --- | --- | +| `ilold_users` | List every named keypair in the active scenario. | +| `ilold_users_new` | Create a new keypair and airdrop the initial lamports. | +| `ilold_airdrop` | Top up an existing keypair with extra lamports. | +| `ilold_time_warp` | Advance or rewind the Clock sysvar. | +| `ilold_inspect` | Read a VM account by pubkey and decode it via the Anchor discriminator. | + +### Analysis + +| Tool | Purpose | +| --- | --- | +| `ilold_timeline` | Cross-step mutation history of an account, decoded. | +| `ilold_coverage` | Aggregated runtime metrics over the active scenario (calls, failures, CU stats, CPI edges). | + +### Scenarios + +| Tool | Purpose | +| --- | --- | +| `ilold_scenario` | Manage scenarios: create, list, switch, fork, delete. | + +### Findings and journal + +| Tool | Purpose | +| --- | --- | +| `ilold_finding` | Record a security finding tied to the latest step. | +| `ilold_findings` | List every finding recorded in the active scenario. | +| `ilold_note` | Attach a free-form annotation to the active scenario. | +| `ilold_status` | Set the review status of an instruction: open, reviewed, finding. | +| `ilold_export` | Generate the audit deliverable (Markdown). | + +### Workspace + +| Tool | Purpose | +| --- | --- | +| `ilold_save` | Serialise the active scenario to `~/.ilold/sessions/<name>.json`. | +| `ilold_load` | Restore a scenario JSON from disk and replay it into the VM. | + +Total: 29 tools. The REPL meta commands (`?`, `help`, `quit`, `browser`, `use`, `seq`) are intentionally excluded: the MCP client discovers tools via `tools/list`, the subprocess exits on stdin EOF, the canvas URL is already on the human side, and the active program is fixed by `--contract`. + +## Example session + +A natural-language prompt for an MCP-aware client looks like this: + +> Audit the `staking` program. Look for paths where the admin signer check can be bypassed. Create a user `alice`, run `stake` for 1000 lamports, and produce a coverage report at the end. + +The client typically resolves it as the following tool sequence: + +1. `ilold_funcs_all` to enumerate instructions and admin-gating hints. +2. `ilold_info` on each instruction the model wants to inspect. +3. `ilold_users_new` to create `alice`. +4. `ilold_call` for `initialize_pool` and then `stake`. +5. `ilold_coverage` to read aggregated runtime metrics. +6. `ilold_finding` if the model identifies an issue, followed by `ilold_export`. + +Every step also fires a WebSocket patch from `ilold serve`, so a browser tab pointed at the canvas reflects the graph evolving in real time. + +## Limitations + +- **Solana only.** The MCP server refuses to start when the backend is a Solidity project. Solidity support is in the [cross-cutting roadmap](../roadmap/cross-cutting.md). +- **Single program per session.** `--contract` is fixed at startup; switching programs requires restarting the subprocess. Multi-program workspaces are still discoverable through `ilold_programs`. +- **Static tool registry.** Tools are derived from `SOLANA_HELP_BLOCKS` once at startup. Reloading the backend project does not change the tool set; only the data behind the tools. +- **No sandbox over the LLM.** Every tool that mutates the VM (`ilold_call`, `ilold_clear`, `ilold_back`, `ilold_scenario`) is invocable without confirmation from the server. Sandboxing is delegated to the MCP client: mature clients prompt the human before destructive tools (those whose names contain `clear`, `delete`, `reset`). +- **Narration is best-effort.** `--narration` emits a `notifications/progress` message keyed by the request `progressToken`. Clients that do not declare a progress token in the request silently drop the notification. +- **stdio only.** SSE and streamable HTTP transports are out of scope for v1. Every supported client uses stdio. + +## Troubleshooting + +| Symptom | Likely cause | +| --- | --- | +| `Cannot reach Ilold server at <url>` on startup | `ilold serve` is not running, or `--server-url` points to the wrong port. | +| `Server at <url> is not a Solana project (kind=solidity)` | The backend was started against a Solidity workspace. Point `ilold serve` at a Solana project. | +| Tools do not appear in the client | The client could not spawn `ilold`. Check that the binary is on `PATH` or use an absolute path in `command`. Inspect the client log (`~/Library/Logs/Claude/mcp-server-ilold.log` for Claude Desktop on macOS). | +| Tool call returns `Error: ...` | The backend rejected the `SolanaCommand`. The error text is the same as the REPL would print; check the `--contract` and the instruction arguments. | diff --git a/docs/guide/src/roadmap/cross-cutting.md b/docs/guide/src/roadmap/cross-cutting.md index 8e571c2..2cae727 100644 --- a/docs/guide/src/roadmap/cross-cutting.md +++ b/docs/guide/src/roadmap/cross-cutting.md @@ -1,9 +1,5 @@ # Cross-cutting Roadmap -## MCP server for LLM agent integration - -Expose the REPL surface as an MCP (Model Context Protocol) server so an LLM agent can drive an audit programmatically. Each REPL command maps to a typed MCP tool reusing the existing HTTP API. With the server in place, an agent maps a project, proposes call sequences, inspects state, and records findings without a human translating between the model and the REPL. - ## Elozer integration Elozer is our in-house static analyzer. It produces a typed AST for smart-contract source today; a CFG layer needs to be built on top before slicing, taint analysis, and detectors can run on the Solana side. Wiring Elozer into ilold provides that AST foundation and unblocks the items listed in the [Solana roadmap](./solana.md). From f87c7c378df2733a4f4d8c325db6d007f8c2357c Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Mon, 11 May 2026 15:18:05 +0200 Subject: [PATCH 108/115] feat(solana): instruction source viewer with open-in-ide parity - Add GET /api/program/:name/:ix/source returning Anchor handler source - Parse handler via syn::parse_file, extract span and absolute path - Reuse FunctionSourcePanel component on the Solana canvas - getInstructionSource client mirrors getFunctionSource shape - Open-in-IDE button reuses existing openInIde util - Tests cover extractor, endpoint, 404 for unknown instruction --- Cargo.lock | 2 + crates/ilold-web/Cargo.toml | 2 + crates/ilold-web/frontend/src/lib/api/rest.ts | 13 ++ .../contract/FunctionSourcePanel.svelte | 15 +- crates/ilold-web/src/api/project.rs | 155 ++++++++++++++++++ crates/ilold-web/src/lib.rs | 1 + .../tests/instruction_source_test.rs | 111 +++++++++++++ 7 files changed, 293 insertions(+), 6 deletions(-) create mode 100644 crates/ilold-web/tests/instruction_source_test.rs diff --git a/Cargo.lock b/Cargo.lock index 857f601..771758a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3009,11 +3009,13 @@ dependencies = [ "ilold-session-core", "ilold-solana-core", "portable-pty", + "proc-macro2", "reqwest", "serde", "serde_json", "solana-address 2.6.0", "solana-keypair", + "syn 2.0.117", "tokio", "tokio-tungstenite 0.24.0", "tower-http", diff --git a/crates/ilold-web/Cargo.toml b/crates/ilold-web/Cargo.toml index 4650386..bc01213 100644 --- a/crates/ilold-web/Cargo.toml +++ b/crates/ilold-web/Cargo.toml @@ -17,6 +17,8 @@ serde = { workspace = true } serde_json = { workspace = true } anyhow = { workspace = true } portable-pty = "0.8" +syn = { version = "2", features = ["full"] } +proc-macro2 = { version = "1", features = ["span-locations"] } [dev-dependencies] tokio-tungstenite = "0.24" diff --git a/crates/ilold-web/frontend/src/lib/api/rest.ts b/crates/ilold-web/frontend/src/lib/api/rest.ts index 191040e..02739d5 100644 --- a/crates/ilold-web/frontend/src/lib/api/rest.ts +++ b/crates/ilold-web/frontend/src/lib/api/rest.ts @@ -294,6 +294,19 @@ export async function getFunctionSource( return res.json(); } +// Solana counterpart — same response shape as `getFunctionSource` so the +// `FunctionSourcePanel` component is agnostic to backend. +export async function getInstructionSource( + programName: string, + ixName: string, +): Promise<FunctionSourceResponse> { + const res = await fetch( + `${BASE}/api/program/${encodeURIComponent(programName)}/${encodeURIComponent(ixName)}/source`, + ); + if (!res.ok) throw new Error(`Source for ${ixName} not found`); + return res.json(); +} + export interface SequenceAnalysis { functions: { name: string; diff --git a/crates/ilold-web/frontend/src/lib/components/contract/FunctionSourcePanel.svelte b/crates/ilold-web/frontend/src/lib/components/contract/FunctionSourcePanel.svelte index a7dde95..dbcf7eb 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/FunctionSourcePanel.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/FunctionSourcePanel.svelte @@ -4,14 +4,16 @@ import { oneDark } from '@codemirror/theme-one-dark'; import { EditorView } from '@codemirror/view'; import DraggablePanel from '$lib/DraggablePanel.svelte'; - import { getFunctionSource, type FunctionSourceResponse } from '$lib/api/rest'; + import { getFunctionSource, getInstructionSource, type FunctionSourceResponse } from '$lib/api/rest'; - // Modern Solidity source viewer. Styled after VSCode's peek view + GitHub - // blob view. Uses the CodeMirror `oneDark` theme and the JS grammar as a - // Solidity fallback. - let { contract, func, onclose }: { + // Modern source viewer. Styled after VSCode's peek view + GitHub blob view. + // Uses the CodeMirror `oneDark` theme and the JS grammar as a Solidity/Rust + // fallback so it stays close enough for highlighting without pulling extra + // language bundles. + let { contract, func, kind = 'solidity', onclose }: { contract: string; func: string; + kind?: 'solidity' | 'solana'; onclose: () => void; } = $props(); @@ -55,7 +57,8 @@ loading = true; error = null; source = ''; - getFunctionSource(currentContract, currentFunc) + const fetcher = kind === 'solana' ? getInstructionSource : getFunctionSource; + fetcher(currentContract, currentFunc) .then((res: FunctionSourceResponse) => { if (currentFunc !== func || currentContract !== contract) return; source = res.source; diff --git a/crates/ilold-web/src/api/project.rs b/crates/ilold-web/src/api/project.rs index 91ad8d5..edcb0ff 100644 --- a/crates/ilold-web/src/api/project.rs +++ b/crates/ilold-web/src/api/project.rs @@ -4,11 +4,13 @@ use std::sync::Arc; use axum::extract::{Path, Query, State}; use axum::http::StatusCode; use axum::Json; +use ilold_core::model::common::SourceSpan; use ilold_solana_core::model::ProgramDef; use ilold_solana_core::overlay::RuntimeOverlay; use ilold_solana_core::view::ProgramView; use serde::{Deserialize, Serialize}; use solana_keypair::Signer; +use syn::spanned::Spanned; use crate::state::{require_solidity_msg, AppState, Backend}; @@ -308,3 +310,156 @@ fn build_solidity_map(state: &Arc<AppState>) -> ProjectMap { relationships, } } + +// ============================================================================ +// Instruction source — Anchor handler body for the canvas "View source" panel +// and the IDE deep link. Mirrors `get_function_source` for Solidity. +// ============================================================================ + +/// Shape mirrors `FunctionSourceResponse` so the frontend can reuse the +/// `FunctionSourcePanel` component without a parallel type. +#[derive(Serialize)] +pub struct InstructionSourceResponse { + pub file_path: String, + pub source: String, + pub span: SourceSpan, +} + +pub async fn get_instruction_source( + State(state): State<Arc<AppState>>, + Path((name, ix)): Path<(String, String)>, +) -> Result<Json<InstructionSourceResponse>, (StatusCode, String)> { + let program = find_solana_program(&state, &name)?; + // Reject early: 404 if the IDL has no such instruction. Avoids reading + // the file just to return the same error after parsing. + if !program.instructions.iter().any(|i| i.name == ix) { + return Err(( + StatusCode::NOT_FOUND, + format!("instruction '{ix}' not found in program '{name}'"), + )); + } + + let file_path = state + .project_root + .join("programs") + .join(&program.name) + .join("src") + .join("lib.rs"); + let content = std::fs::read_to_string(&file_path).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("read {}: {e}", file_path.display()), + ) + })?; + + let span = extract_handler_span(&content, &ix).ok_or(( + StatusCode::NOT_FOUND, + format!("handler '{ix}' not found in {}", file_path.display()), + ))?; + + let source = slice_lines(&content, span.start_line as usize, span.end_line as usize); + + Ok(Json(InstructionSourceResponse { + file_path: file_path.to_string_lossy().into_owned(), + source, + span, + })) +} + +/// Parse `lib.rs` with syn and locate the `pub fn <ix>` inside the +/// `#[program] pub mod ...` module. Falls back to top-level fns and to a +/// recursive walk so handlers nested in unusual layouts still resolve. +pub(crate) fn extract_handler_span(source: &str, ix: &str) -> Option<SourceSpan> { + let file = syn::parse_file(source).ok()?; + find_fn_in_items(&file.items, ix) +} + +fn find_fn_in_items(items: &[syn::Item], ix: &str) -> Option<SourceSpan> { + // Prefer the `#[program]`-attributed module — that is where Anchor places + // the IDL-visible handlers, so we avoid colliding with helper fns named + // the same elsewhere in the file. + for item in items { + if let syn::Item::Mod(m) = item { + if has_program_attr(&m.attrs) { + if let Some((_, inner)) = &m.content { + if let Some(span) = find_fn_in_items(inner, ix) { + return Some(span); + } + } + } + } + } + for item in items { + match item { + syn::Item::Fn(f) if f.sig.ident == ix => return Some(span_of(f)), + syn::Item::Mod(m) => { + if let Some((_, inner)) = &m.content { + if let Some(span) = find_fn_in_items(inner, ix) { + return Some(span); + } + } + } + _ => {} + } + } + None +} + +fn has_program_attr(attrs: &[syn::Attribute]) -> bool { + attrs.iter().any(|a| a.path().is_ident("program")) +} + +fn span_of(item: &syn::ItemFn) -> SourceSpan { + let span = item.span(); + let start = span.start(); + let end = span.end(); + SourceSpan { + file_index: 0, + start_line: start.line as u32, + start_col: start.column as u32, + end_line: end.line as u32, + end_col: end.column as u32, + } +} + +fn slice_lines(src: &str, start_1based: usize, end_1based: usize) -> String { + if start_1based == 0 || end_1based < start_1based { + return String::new(); + } + src.lines() + .skip(start_1based - 1) + .take(end_1based - start_1based + 1) + .collect::<Vec<_>>() + .join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + const STAKING_LIB: &str = include_str!( + "../../../../tests/fixtures/solana/staking/programs/staking/src/lib.rs" + ); + + #[test] + fn extract_handler_span_finds_stake() { + let span = extract_handler_span(STAKING_LIB, "stake").expect("stake handler"); + // `pub fn stake(...)` is on line 19 in the fixture (1-based). + assert_eq!(span.start_line, 19, "start line off: {span:?}"); + assert!( + span.end_line > span.start_line, + "stake body must span >1 lines: {span:?}", + ); + let body = slice_lines(STAKING_LIB, span.start_line as usize, span.end_line as usize); + assert!(body.contains("pub fn stake"), "body missing signature:\n{body}"); + assert!( + body.contains("StakingError::ZeroAmount"), + "body missing require!:\n{body}", + ); + } + + #[test] + fn extract_handler_span_missing_returns_none() { + assert!(extract_handler_span(STAKING_LIB, "does_not_exist").is_none()); + } +} diff --git a/crates/ilold-web/src/lib.rs b/crates/ilold-web/src/lib.rs index 3ace9e2..ed23e9b 100644 --- a/crates/ilold-web/src/lib.rs +++ b/crates/ilold-web/src/lib.rs @@ -19,6 +19,7 @@ fn build_router(state: Arc<AppState>) -> Router { .route("/api/project/map", get(api::project::get_project_map)) .route("/api/program/{name}/view", get(api::project::get_program_view)) .route("/api/program/{name}/overlay", get(api::project::get_program_overlay)) + .route("/api/program/{name}/{ix}/source", get(api::project::get_instruction_source)) .route("/api/users/{scenario}/labels", get(api::project::get_user_labels)) .route("/api/contract/{name}", get(api::contract::get_contract)) .route("/api/contract/{name}/callgraph", get(api::contract::get_callgraph)) diff --git a/crates/ilold-web/tests/instruction_source_test.rs b/crates/ilold-web/tests/instruction_source_test.rs new file mode 100644 index 0000000..319e1c0 --- /dev/null +++ b/crates/ilold-web/tests/instruction_source_test.rs @@ -0,0 +1,111 @@ +//! Integration tests for `GET /api/program/:name/:ix/source`. +//! +//! Mirrors `contract_source_test.rs` (Solidity) but boots a Solana server +//! against the staking Anchor fixture. + +use std::path::PathBuf; + +use ilold_solana_core::ingest::detect; + +fn staking_fixture() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("tests/fixtures/solana/staking") +} + +async fn start_staking() -> (reqwest::Client, u16) { + let detected = detect(&staking_fixture()).expect("detect staking fixture"); + let (_state, port) = ilold_web::start_solana_server(detected, 0) + .await + .expect("start solana server"); + (reqwest::Client::new(), port) +} + +#[tokio::test] +async fn get_instruction_source_returns_source_for_stake() { + let (client, port) = start_staking().await; + + let res = client + .get(format!( + "http://127.0.0.1:{port}/api/program/staking/stake/source" + )) + .send() + .await + .unwrap(); + assert!(res.status().is_success(), "status: {}", res.status()); + + let body: serde_json::Value = res.json().await.unwrap(); + let source = body.get("source").and_then(|v| v.as_str()).unwrap(); + assert!( + source.contains("pub fn stake"), + "expected handler signature, got:\n{source}" + ); + assert!( + source.contains("StakingError::ZeroAmount"), + "expected handler body, got:\n{source}" + ); + + let start_line = body + .pointer("/span/start_line") + .and_then(|v| v.as_u64()) + .unwrap(); + let end_line = body + .pointer("/span/end_line") + .and_then(|v| v.as_u64()) + .unwrap(); + assert!( + end_line > start_line, + "handler span must cover multiple lines, got {start_line}..{end_line}" + ); + + let file_path = body.get("file_path").and_then(|v| v.as_str()).unwrap(); + assert!( + std::path::Path::new(file_path).is_absolute(), + "file_path must be absolute, got: {file_path}" + ); + assert!( + file_path.ends_with("programs/staking/src/lib.rs"), + "unexpected file_path: {file_path}" + ); +} + +#[tokio::test] +async fn get_instruction_source_returns_404_for_missing_ix() { + let (client, port) = start_staking().await; + + let res = client + .get(format!( + "http://127.0.0.1:{port}/api/program/staking/does_not_exist/source" + )) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + reqwest::StatusCode::NOT_FOUND, + "unknown instruction must 404, got: {}", + res.status(), + ); +} + +#[tokio::test] +async fn get_instruction_source_returns_404_for_unknown_program() { + let (client, port) = start_staking().await; + + let res = client + .get(format!( + "http://127.0.0.1:{port}/api/program/nope/stake/source" + )) + .send() + .await + .unwrap(); + assert_eq!( + res.status(), + reqwest::StatusCode::NOT_FOUND, + "unknown program must 404, got: {}", + res.status(), + ); +} From d1407a9be64910f3d4c82ed41c71ad5795c10e18 Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Mon, 11 May 2026 15:36:43 +0200 Subject: [PATCH 109/115] fix(canvas): wire source viewer for solana instruction nodes - ContextMenu canViewSource now matches nodeType 'instruction' - FunctionSourcePanel mount accepts solana program name and forwards kind - handleOpenInIde routes to getInstructionSource when kind is solana - Right-click on a Solana instruction shows View source and Open in code --- .../src/lib/components/contract/ContextMenu.svelte | 2 +- .../src/routes/contract/[name]/+page.svelte | 14 +++++++++----- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/ilold-web/frontend/src/lib/components/contract/ContextMenu.svelte b/crates/ilold-web/frontend/src/lib/components/contract/ContextMenu.svelte index 2b6b41e..65dc9b7 100644 --- a/crates/ilold-web/frontend/src/lib/components/contract/ContextMenu.svelte +++ b/crates/ilold-web/frontend/src/lib/components/contract/ContextMenu.svelte @@ -36,7 +36,7 @@ // View source / Open in code are read-only operations meaningful for // any canvas node that identifies a function. Shown in every mode. const canViewSource = $derived( - menu != null && (menu.nodeType === 'function' || menu.nodeType === 'seq-next') + menu != null && (menu.nodeType === 'function' || menu.nodeType === 'seq-next' || menu.nodeType === 'instruction') ); // Canvas-action buttons (Expand CFG, Remove from canvas, Collapse, Remove diff --git a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte index 1770f1e..4e9520f 100644 --- a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte +++ b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte @@ -1,7 +1,7 @@ <script lang="ts"> import { page } from '$app/state'; import { onMount, onDestroy, tick, untrack } from 'svelte'; - import { getContract, getCallGraph, getCfg, getPaths, getSequences, getSequenceAnalysis, getFunctionSource, getProjectMap, getProgramView, type ContractDetail, type CytoscapeGraph, type SequenceAnalysis, type MapContract, type MapProgram, type ProgramView, type IxView, type AccountView, type IxAccountView } from '$lib/api/rest'; + import { getContract, getCallGraph, getCfg, getPaths, getSequences, getSequenceAnalysis, getFunctionSource, getInstructionSource, getProjectMap, getProgramView, type ContractDetail, type CytoscapeGraph, type SequenceAnalysis, type MapContract, type MapProgram, type ProgramView, type IxView, type AccountView, type IxAccountView } from '$lib/api/rest'; import { applyOverlayUpdate as applyRuntimeOverlayUpdate, clearOverlay as clearRuntimeOverlay, @@ -1450,9 +1450,12 @@ // is registered the browser silently drops the request — no UI nag. async function handleOpenInIde(funcName: string) { contextMenu = null; - if (!contract) return; + const projectName = kind === 'solana' ? solanaProgram?.name : contract?.name; + if (!projectName) return; try { - const res = await getFunctionSource(contract.name, funcName); + const res = kind === 'solana' + ? await getInstructionSource(projectName, funcName) + : await getFunctionSource(projectName, funcName); openInIde(res.file_path, res.span.start_line, res.span.start_col); } catch (e) { notifyFailure('open in IDE', e); @@ -2109,10 +2112,11 @@ onclose={() => contextMenu = null} /> - {#if sourcePanel && contract} + {#if sourcePanel && (contract || (kind === 'solana' && solanaProgram))} <FunctionSourcePanel - contract={contract.name} + contract={kind === 'solana' ? solanaProgram!.name : contract!.name} func={sourcePanel.func} + kind={kind} onclose={() => sourcePanel = null} /> {/if} From dfe05ab524c9e9afc2e9209e40dcb364c1fddaad Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Mon, 11 May 2026 15:38:24 +0200 Subject: [PATCH 110/115] feat(mcp): agnostic contract with ilold_use to switch programs - Make --contract optional on ilold mcp subcommand - Re-include ilold_use in tool registry (now 30 tools) - Handler keeps current_contract behind tokio Mutex - ilold_use validates program against /api/project/map then sets active - Other tools error with clear message when no contract active - Backward compatible: --contract still seeds the initial value - Update guide reference/mcp.md with switching-programs section - Tests cover registry size, use flow, error path --- crates/ilold-cli/src/main.rs | 8 +- crates/ilold-mcp/src/client.rs | 32 +++-- crates/ilold-mcp/src/lib.rs | 6 +- crates/ilold-mcp/src/schema.rs | 4 + crates/ilold-mcp/src/server.rs | 125 ++++++++++++++---- crates/ilold-mcp/src/tools.rs | 29 ++-- crates/ilold-mcp/tests/client_httpmock.rs | 12 +- crates/ilold-mcp/tests/handler_use_flow.rs | 125 ++++++++++++++++++ crates/ilold-mcp/tests/mcp_stdio_full_flow.rs | 2 +- .../ilold-mcp/tests/mcp_stdio_lists_tools.rs | 6 +- crates/ilold-mcp/tests/schema_consistency.rs | 10 +- docs/guide/src/reference/mcp.md | 47 ++++--- 12 files changed, 325 insertions(+), 81 deletions(-) create mode 100644 crates/ilold-mcp/tests/handler_use_flow.rs diff --git a/crates/ilold-cli/src/main.rs b/crates/ilold-cli/src/main.rs index 7a840b4..1d2cc91 100644 --- a/crates/ilold-cli/src/main.rs +++ b/crates/ilold-cli/src/main.rs @@ -67,11 +67,11 @@ enum Commands { /// Base URL of a running `ilold serve` instance (defaults to http://127.0.0.1:8080) #[arg(long, env = "ILOLD_SERVER_URL", default_value = "http://127.0.0.1:8080")] server_url: String, - /// Target program name. All tool calls route to this program via the - /// `contract` field of `/api/cmd`. Required so the MCP client never - /// has to think about workspace topology. + /// Optional initial active program. When set, every tool call routes to + /// this program until the LLM (or the user) invokes `ilold_use` to + /// switch. Leave unset to let the LLM pick the program at runtime. #[arg(long, env = "ILOLD_CONTRACT")] - contract: String, + contract: Option<String>, /// Emit a `notifications/progress` MCP message before each tool call /// describing intent. Useful when the client UI renders progress next /// to tool calls so the human sees what the LLM is about to run. diff --git a/crates/ilold-mcp/src/client.rs b/crates/ilold-mcp/src/client.rs index f681780..1726d7e 100644 --- a/crates/ilold-mcp/src/client.rs +++ b/crates/ilold-mcp/src/client.rs @@ -5,15 +5,13 @@ use crate::error::McpClientError; pub struct IloldClient { base_url: String, - contract: String, http: reqwest::Client, } impl IloldClient { - pub fn new(base_url: String, contract: String) -> Self { + pub fn new(base_url: String) -> Self { Self { base_url: base_url.trim_end_matches('/').to_string(), - contract, http: reqwest::Client::new(), } } @@ -22,10 +20,6 @@ impl IloldClient { &self.base_url } - pub fn contract(&self) -> &str { - &self.contract - } - pub async fn health_check(&self) -> Result<(), McpClientError> { let url = format!("{}/api/project/map", self.base_url); let resp = self @@ -59,13 +53,35 @@ impl IloldClient { Ok(()) } + pub async fn project_map(&self) -> Result<Value, McpClientError> { + let url = format!("{}/api/project/map", self.base_url); + let resp = self + .http + .get(&url) + .send() + .await + .map_err(|e| McpClientError::Unreachable { + url: url.clone(), + reason: e.to_string(), + })?; + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(McpClientError::HttpError { status, body }); + } + resp.json::<Value>() + .await + .map_err(|e| McpClientError::InvalidResponse(e.to_string())) + } + pub async fn send_command( &self, + contract: &str, command: Value, ) -> Result<SolanaCommandResult, McpClientError> { let url = format!("{}/api/cmd", self.base_url); let body = serde_json::json!({ - "contract": self.contract, + "contract": contract, "command": command, }); let resp = self diff --git a/crates/ilold-mcp/src/lib.rs b/crates/ilold-mcp/src/lib.rs index 538aeab..acabb30 100644 --- a/crates/ilold-mcp/src/lib.rs +++ b/crates/ilold-mcp/src/lib.rs @@ -17,7 +17,7 @@ pub use error::McpClientError; #[derive(Debug, Clone)] pub struct Config { pub server_url: String, - pub contract: String, + pub contract: Option<String>, pub narration: bool, } @@ -31,13 +31,13 @@ pub async fn run(cfg: Config) -> Result<()> { .try_init() .ok(); - let client = Arc::new(IloldClient::new(cfg.server_url, cfg.contract)); + let client = Arc::new(IloldClient::new(cfg.server_url)); client .health_check() .await .context("Ilold backend health-check failed")?; - let handler = server::IloldMcpServer::new(client, cfg.narration); + let handler = server::IloldMcpServer::new(client, cfg.contract, cfg.narration); let service = handler.serve(stdio()).await?; service.waiting().await?; Ok(()) diff --git a/crates/ilold-mcp/src/schema.rs b/crates/ilold-mcp/src/schema.rs index 97154ad..045f726 100644 --- a/crates/ilold-mcp/src/schema.rs +++ b/crates/ilold-mcp/src/schema.rs @@ -11,6 +11,10 @@ use serde_json::{Map, Value, json}; pub fn schema_for_tool(name: &str) -> Value { match name { "ilold_call" => schema_for_call(), + "ilold_use" => string_only( + "program", + "Program name to make active (must match an entry returned by ilold_programs)", + ), "ilold_info" => string_only("ix", "Instruction name to inspect"), "ilold_pda" => string_only("instruction", "Instruction whose PDAs to list"), "ilold_inspect" => string_only("pubkey", "Account pubkey (or named keypair)"), diff --git a/crates/ilold-mcp/src/server.rs b/crates/ilold-mcp/src/server.rs index 3238423..70256cd 100644 --- a/crates/ilold-mcp/src/server.rs +++ b/crates/ilold-mcp/src/server.rs @@ -11,6 +11,7 @@ use rmcp::model::{ }; use rmcp::service::{Peer, RequestContext, RoleServer}; use serde_json::Value; +use tokio::sync::Mutex; use crate::client::IloldClient; use crate::narration::intent_for_tool; @@ -18,12 +19,21 @@ use crate::tools; pub struct IloldMcpServer { client: Arc<IloldClient>, + current_contract: Arc<Mutex<Option<String>>>, narration: bool, } impl IloldMcpServer { - pub fn new(client: Arc<IloldClient>, narration: bool) -> Self { - Self { client, narration } + pub fn new( + client: Arc<IloldClient>, + initial_contract: Option<String>, + narration: bool, + ) -> Self { + Self { + client, + current_contract: Arc::new(Mutex::new(initial_contract)), + narration, + } } } @@ -34,9 +44,10 @@ impl ServerHandler for IloldMcpServer { info.server_info = Implementation::new("ilold-mcp", env!("CARGO_PKG_VERSION")); info.instructions = Some(format!( "Ilold MCP server. Exposes Solana REPL commands as MCP tools \ - backed by the Ilold backend at {}. Target program: {}.", + backed by the Ilold backend at {}. Call ilold_programs to list \ + available programs and ilold_use <program> to fix the active one \ + before issuing other tool calls.", self.client.base_url(), - self.client.contract(), )); info } @@ -61,7 +72,13 @@ impl ServerHandler for IloldMcpServer { let token = context.meta.get_progress_token(); emit_intent_progress(&context.peer, token, &tool_name, args_value.as_ref()).await; } - let res = dispatch(&self.client, &tool_name, args_value.as_ref()).await; + let res = dispatch( + &self.client, + &self.current_contract, + &tool_name, + args_value.as_ref(), + ) + .await; Ok(res) } } @@ -85,51 +102,105 @@ async fn emit_intent_progress( } } -async fn dispatch( +pub async fn dispatch( client: &IloldClient, + current_contract: &Arc<Mutex<Option<String>>>, tool_name: &str, arguments: Option<&Value>, ) -> CallToolResult { + if tool_name == "ilold_use" { + return handle_use(client, current_contract, arguments).await; + } if tool_name == "ilold_programs" { - return handle_programs(client).await; + let active = current_contract.lock().await.clone(); + return handle_programs(client, active.as_deref()).await; } + let active = match current_contract.lock().await.clone() { + Some(c) => c, + None => { + return error_result( + "No active contract. Call ilold_use <program> first or list available programs with ilold_programs." + .to_string(), + ); + } + }; let command = match tools::build_command(tool_name, arguments) { Ok(cmd) => cmd, Err(message) => return error_result(format!("Invalid arguments: {message}")), }; - match client.send_command(command).await { + match client.send_command(&active, command).await { Ok(result) => build_tool_response(&result), Err(err) => error_result(err.to_string()), } } -async fn handle_programs(client: &IloldClient) -> CallToolResult { - let url = format!("{}/api/project/map", client.base_url()); - let resp = match reqwest::get(&url).await { - Ok(r) => r, - Err(e) => return error_result(format!("cannot reach {url}: {e}")), +async fn handle_use( + client: &IloldClient, + current_contract: &Arc<Mutex<Option<String>>>, + arguments: Option<&Value>, +) -> CallToolResult { + let program = match arguments + .and_then(|v| v.as_object()) + .and_then(|o| o.get("program")) + .and_then(|v| v.as_str()) + { + Some(p) if !p.is_empty() => p.to_string(), + _ => return error_result("missing or empty field: program".to_string()), + }; + let map = match client.project_map().await { + Ok(v) => v, + Err(err) => return error_result(err.to_string()), }; - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return error_result(format!("HTTP {status}: {body}")); + let kind = map.get("kind").and_then(|v| v.as_str()).unwrap_or(""); + if kind != "solana" { + return error_result(format!( + "backend reports kind={kind}; ilold_use only supports Solana workspaces" + )); + } + let known: Vec<String> = map + .get("programs") + .and_then(|p| p.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|p| p.get("name").and_then(|n| n.as_str()).map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + if !known.iter().any(|n| n == &program) { + return error_result(format!( + "unknown program `{program}`. Available: {}", + if known.is_empty() { + "(none)".to_string() + } else { + known.join(", ") + } + )); } - let value: Value = match resp.json().await { + *current_contract.lock().await = Some(program.clone()); + let text = format!("Active contract set to `{program}`."); + let mut out = CallToolResult::structured(serde_json::json!({ + "active_contract": program, + })); + out.content = vec![Content::text(text)]; + out +} + +async fn handle_programs(client: &IloldClient, active_contract: Option<&str>) -> CallToolResult { + let value = match client.project_map().await { Ok(v) => v, - Err(e) => return error_result(format!("invalid response: {e}")), + Err(err) => return error_result(err.to_string()), }; - let text = render_programs(&value, client.contract()); + let text = render_programs(&value, active_contract); let structured = value.clone(); let mut out = CallToolResult::structured(serde_json::json!({ "project_map": structured })); out.content = vec![Content::text(text)]; out } -fn render_programs(map: &Value, active_contract: &str) -> String { +fn render_programs(map: &Value, active_contract: Option<&str>) -> String { let mut out = String::new(); - out.push_str(&format!( - " workspace programs (active: {active_contract})\n" - )); + let label = active_contract.unwrap_or("(none — call ilold_use to set one)"); + out.push_str(&format!(" workspace programs (active: {label})\n")); if let Some(programs) = map.get("programs").and_then(|p| p.as_array()) { for p in programs { let name = p.get("name").and_then(|n| n.as_str()).unwrap_or("?"); @@ -139,7 +210,11 @@ fn render_programs(map: &Value, active_contract: &str) -> String { .and_then(|i| i.as_array()) .map(|a| a.len()) .unwrap_or(0); - let marker = if name == active_contract { " ← active" } else { "" }; + let marker = if Some(name) == active_contract { + " ← active" + } else { + "" + }; out.push_str(&format!( " · {name} (program_id={pid}, instructions={ix_count}){marker}\n" )); diff --git a/crates/ilold-mcp/src/tools.rs b/crates/ilold-mcp/src/tools.rs index e0cfa69..42f2451 100644 --- a/crates/ilold-mcp/src/tools.rs +++ b/crates/ilold-mcp/src/tools.rs @@ -6,17 +6,17 @@ use serde_json::{Map, Value, json}; use crate::schema::schema_for_tool; -// `use` is a REPL-only meta command that switches the active program in the -// CLI prompt; it has no SolanaCommand variant and the MCP transport selects -// the program via the `--contract` flag instead, so the tool is excluded -// from the registry. +// `ilold_use` is kept in the registry but handled client-side by the MCP +// server: it sets the active contract on the handler instead of issuing a +// SolanaCommand. Every other tool reads that state and routes its call to +// the active program. // // `seq | sequence` is excluded because the dedicated `/api/session/sequence` // endpoint is Solidity-only (gated by `require_solidity_msg`). For Solana the // REPL falls back to the Session view, so exposing both `ilold_session` and // `ilold_sequence` would be two tool names for the exact same backend call. const EXCLUDED_ALIASES: &[&str] = &[ - "?", "help", "h", "quit", "q", "exit", "browser", "use", "seq", "sequence", + "?", "help", "h", "quit", "q", "exit", "browser", "seq", "sequence", ]; const TOOL_NAME_PREFIX: &str = "ilold_"; @@ -81,6 +81,7 @@ pub fn build_command(name: &str, arguments: Option<&Value>) -> Result<Value, Str let args = arguments.cloned().unwrap_or_else(|| json!({})); let args_obj = args.as_object().cloned().unwrap_or_default(); match name { + "ilold_use" => Err("ilold_use is handled client-side and has no SolanaCommand mapping".to_string()), "ilold_call" => { let ix = require_str(&args_obj, "ix")?; let call_args = args_obj.get("args").cloned().unwrap_or_else(|| json!({})); @@ -207,12 +208,12 @@ mod tests { use ilold_solana_core::exploration::SolanaCommand; #[test] - fn tool_registry_has_29_entries() { - // 33 help blocks - 4 meta (?, quit, browser, use) - 2 sequence aliases + fn tool_registry_has_30_entries() { + // 33 help blocks - 3 meta (?, quit, browser) - 2 sequence aliases // (seq, sequence excluded because the /api/session/sequence endpoint - // is Solidity-only) + 1 dedicated users-new block = 29. + // is Solidity-only) + 1 dedicated users-new block + 1 ilold_use = 30. let tools = build_tool_registry(); - assert_eq!(tools.len(), 29); + assert_eq!(tools.len(), 30); } #[test] @@ -224,6 +225,15 @@ mod tests { ); } + #[test] + fn registry_includes_use() { + let tools = build_tool_registry(); + assert!( + tools.iter().any(|t| t.name == "ilold_use"), + "ilold_use must be exposed so the LLM can switch the active program" + ); + } + #[test] fn registry_excludes_sequence() { let tools = build_tool_registry(); @@ -253,7 +263,6 @@ mod tests { "ilold_q", "ilold_exit", "ilold_browser", - "ilold_use", ]; for f in forbidden { assert!( diff --git a/crates/ilold-mcp/tests/client_httpmock.rs b/crates/ilold-mcp/tests/client_httpmock.rs index ef4ecc8..8b78c5a 100644 --- a/crates/ilold-mcp/tests/client_httpmock.rs +++ b/crates/ilold-mcp/tests/client_httpmock.rs @@ -40,7 +40,7 @@ async fn client_send_command_serializes_body_correctly() { })); }); - let client = IloldClient::new(server.base_url(), "staking".into()); + let client = IloldClient::new(server.base_url()); let command = json!({ "Call": { "ix": "stake", @@ -50,7 +50,7 @@ async fn client_send_command_serializes_body_correctly() { } }); let result = client - .send_command(command) + .send_command("staking", command) .await .expect("send_command succeeds"); match result { @@ -73,7 +73,7 @@ async fn client_health_check_rejects_solidity() { })); }); - let client = IloldClient::new(server.base_url(), "Foo".into()); + let client = IloldClient::new(server.base_url()); let err = client .health_check() .await @@ -95,9 +95,9 @@ async fn client_passes_contract_field() { then.status(200).json_body(json!("NoteAdded")); }); - let client = IloldClient::new(server.base_url(), "lever".into()); + let client = IloldClient::new(server.base_url()); let _ = client - .send_command(json!("Funcs")) + .send_command("lever", json!("Funcs")) .await .expect("send_command serializes contract"); mock.assert(); @@ -114,7 +114,7 @@ async fn client_health_check_accepts_solana() { })); }); - let client = IloldClient::new(server.base_url(), "staking".into()); + let client = IloldClient::new(server.base_url()); client .health_check() .await diff --git a/crates/ilold-mcp/tests/handler_use_flow.rs b/crates/ilold-mcp/tests/handler_use_flow.rs new file mode 100644 index 0000000..93a0260 --- /dev/null +++ b/crates/ilold-mcp/tests/handler_use_flow.rs @@ -0,0 +1,125 @@ +//! Integration tests for the agnostic-contract handler flow: +//! `ilold_use` switches the active program, other tools error when no contract +//! is active, and the active contract is forwarded verbatim to `/api/cmd`. + +use std::sync::Arc; + +use httpmock::Method::{GET, POST}; +use httpmock::MockServer; +use ilold_mcp::IloldClient; +use ilold_mcp::server::dispatch; +use serde_json::json; +use tokio::sync::Mutex; + +fn project_map_two_programs(server: &MockServer) { + server.mock(|when, then| { + when.method(GET).path("/api/project/map"); + then.status(200).json_body(json!({ + "kind": "solana", + "programs": [ + { "name": "hand", "program_id": "Hand1111", "instructions": [] }, + { "name": "lever", "program_id": "Lev11111", "instructions": [] } + ] + })); + }); +} + +#[tokio::test] +async fn tool_call_without_active_contract_returns_error() { + let server = MockServer::start_async().await; + let client = Arc::new(IloldClient::new(server.base_url())); + let state = Arc::new(Mutex::new(None)); + + let res = dispatch(&client, &state, "ilold_funcs", None).await; + assert_eq!(res.is_error, Some(true)); + let dumped = serde_json::to_string(&res).expect("serialize CallToolResult"); + assert!( + dumped.contains("No active contract"), + "expected guidance to call ilold_use, got: {dumped}" + ); +} + +#[tokio::test] +async fn ilold_use_sets_active_contract() { + let server = MockServer::start_async().await; + project_map_two_programs(&server); + let client = Arc::new(IloldClient::new(server.base_url())); + let state = Arc::new(Mutex::new(None)); + + let res = dispatch( + &client, + &state, + "ilold_use", + Some(&json!({ "program": "lever" })), + ) + .await; + assert_ne!(res.is_error, Some(true)); + let active = state.lock().await.clone(); + assert_eq!(active.as_deref(), Some("lever")); +} + +#[tokio::test] +async fn ilold_use_rejects_unknown_program() { + let server = MockServer::start_async().await; + project_map_two_programs(&server); + let client = Arc::new(IloldClient::new(server.base_url())); + let state = Arc::new(Mutex::new(None)); + + let res = dispatch( + &client, + &state, + "ilold_use", + Some(&json!({ "program": "ghost" })), + ) + .await; + assert_eq!(res.is_error, Some(true)); + assert!(state.lock().await.is_none()); +} + +#[tokio::test] +async fn tool_call_uses_active_contract_in_post_body() { + let server = MockServer::start_async().await; + project_map_two_programs(&server); + let cmd_mock = server.mock(|when, then| { + when.method(POST) + .path("/api/cmd") + .json_body_partial(r#"{ "contract": "hand" }"#); + then.status(200).json_body(json!({ + "InstructionList": { "items": [] } + })); + }); + + let client = Arc::new(IloldClient::new(server.base_url())); + let state = Arc::new(Mutex::new(None)); + + let set = dispatch( + &client, + &state, + "ilold_use", + Some(&json!({ "program": "hand" })), + ) + .await; + assert_ne!(set.is_error, Some(true)); + + let call = dispatch(&client, &state, "ilold_funcs", None).await; + assert_ne!(call.is_error, Some(true)); + cmd_mock.assert(); +} + +#[tokio::test] +async fn ilold_use_can_switch_between_programs() { + let server = MockServer::start_async().await; + project_map_two_programs(&server); + let client = Arc::new(IloldClient::new(server.base_url())); + let state = Arc::new(Mutex::new(Some("hand".to_string()))); + + let switch = dispatch( + &client, + &state, + "ilold_use", + Some(&json!({ "program": "lever" })), + ) + .await; + assert_ne!(switch.is_error, Some(true)); + assert_eq!(state.lock().await.as_deref(), Some("lever")); +} diff --git a/crates/ilold-mcp/tests/mcp_stdio_full_flow.rs b/crates/ilold-mcp/tests/mcp_stdio_full_flow.rs index 80ac4e1..20a5fe9 100644 --- a/crates/ilold-mcp/tests/mcp_stdio_full_flow.rs +++ b/crates/ilold-mcp/tests/mcp_stdio_full_flow.rs @@ -153,7 +153,7 @@ fn mcp_stdio_full_flow_initialize_list_call() { .and_then(|v| v.as_array()) .cloned() .expect("tools array"); - assert_eq!(tools.len(), 29, "expected 29 tools, got {}", tools.len()); + assert_eq!(tools.len(), 30, "expected 30 tools, got {}", tools.len()); let names: Vec<&str> = tools .iter() .filter_map(|t| t.get("name").and_then(|n| n.as_str())) diff --git a/crates/ilold-mcp/tests/mcp_stdio_lists_tools.rs b/crates/ilold-mcp/tests/mcp_stdio_lists_tools.rs index 6dc4256..d009abb 100644 --- a/crates/ilold-mcp/tests/mcp_stdio_lists_tools.rs +++ b/crates/ilold-mcp/tests/mcp_stdio_lists_tools.rs @@ -1,15 +1,15 @@ use ilold_mcp::tools::build_tool_registry; #[test] -fn registry_lists_29_solana_tools() { +fn registry_lists_30_solana_tools() { let tools = build_tool_registry(); - assert_eq!(tools.len(), 29); + assert_eq!(tools.len(), 30); let names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); assert!(names.contains(&"ilold_call")); assert!(names.contains(&"ilold_funcs")); assert!(names.contains(&"ilold_coverage")); assert!(names.contains(&"ilold_users_new")); - assert!(!names.contains(&"ilold_use")); + assert!(names.contains(&"ilold_use")); assert!(!names.contains(&"ilold_help")); assert!(!names.contains(&"ilold_quit")); assert!(!names.contains(&"ilold_sequence")); diff --git a/crates/ilold-mcp/tests/schema_consistency.rs b/crates/ilold-mcp/tests/schema_consistency.rs index d84c162..9d75dd9 100644 --- a/crates/ilold-mcp/tests/schema_consistency.rs +++ b/crates/ilold-mcp/tests/schema_consistency.rs @@ -15,6 +15,7 @@ use serde_json::{Value, json}; fn sample_arguments(name: &str) -> Option<Value> { match name { "ilold_call" => Some(json!({ "ix": "stake" })), + "ilold_use" => Some(json!({ "program": "staking" })), "ilold_info" => Some(json!({ "ix": "stake" })), "ilold_pda" => Some(json!({ "instruction": "stake" })), "ilold_inspect" => Some(json!({ "pubkey": "alice" })), @@ -39,11 +40,12 @@ fn sample_arguments(name: &str) -> Option<Value> { } } -/// `ilold_programs` is a special-case in the dispatcher (REST GET to -/// /api/project/map). It has no SolanaCommand variant so we only validate the -/// schema, not the build_command path. +/// `ilold_programs` and `ilold_use` are special-cases in the dispatcher: +/// `ilold_programs` calls REST GET /api/project/map and `ilold_use` updates +/// the handler's active-contract state. Neither has a SolanaCommand variant, +/// so we only validate the schema, not the build_command path. fn skips_build_command(name: &str) -> bool { - name == "ilold_programs" + matches!(name, "ilold_programs" | "ilold_use") } #[test] diff --git a/docs/guide/src/reference/mcp.md b/docs/guide/src/reference/mcp.md index e9aad28..52be72a 100644 --- a/docs/guide/src/reference/mcp.md +++ b/docs/guide/src/reference/mcp.md @@ -33,20 +33,22 @@ The `ilold` binary must be on the client's `PATH`. If it is not, use the absolut ## CLI reference ``` -ilold mcp [OPTIONS] --contract <CONTRACT> +ilold mcp [OPTIONS] ``` | Flag | Required | Default | Description | | --- | --- | --- | --- | | `--server-url <URL>` | no | `http://127.0.0.1:8080` | Base URL of the `ilold serve` instance. Environment variable: `ILOLD_SERVER_URL`. | -| `--contract <NAME>` | yes | : | Target program name. Every tool call routes to this program through the `contract` field of `/api/cmd`. Environment variable: `ILOLD_CONTRACT`. | +| `--contract <NAME>` | no | unset | Optional initial active program. When unset the LLM (or the user) must call `ilold_use <program>` before any other tool. Pre-setting it is handy when the workspace has a single program. Environment variable: `ILOLD_CONTRACT`. | | `--narration` | no | off | Emit a `notifications/progress` MCP message before each tool call describing intent (for example `Calling \`stake\` with amount=1000`). Environment variable: `ILOLD_NARRATION`. | +The MCP server is agnostic to the active contract. A single registration in the client works against multi-program workspaces: the LLM lists programs with `ilold_programs` and then fixes the active one with `ilold_use`. + The MCP transport reserves stdout for JSON-RPC; logs and panics go to stderr. ## Client configuration -Every snippet below assumes the backend is running on `http://127.0.0.1:8080` and the target program is `staking`. Adjust both values to your workspace. +Every snippet below assumes the backend is running on `http://127.0.0.1:8080`. The MCP server is registered once and stays agnostic to the active program — the LLM calls `ilold_use <program>` to switch contract during the session. Pre-setting `--contract <name>` is optional and only seeds the initial value. ### Claude Code @@ -61,18 +63,19 @@ Two options. The first is project-scoped (`.mcp.json` at the repository root, ch "command": "ilold", "args": [ "mcp", - "--server-url", "http://127.0.0.1:8080", - "--contract", "staking" + "--server-url", "http://127.0.0.1:8080" ] } } } ``` +Add `"--contract", "<name>"` to the `args` list to pre-set the initial active program. + Equivalent CLI form: ``` -claude mcp add --transport stdio ilold -- ilold mcp --server-url http://127.0.0.1:8080 --contract staking +claude mcp add --transport stdio ilold -- ilold mcp --server-url http://127.0.0.1:8080 ``` ### Claude Desktop @@ -89,15 +92,14 @@ Edit `claude_desktop_config.json` (Developer → Edit Config in the desktop sett "command": "ilold", "args": [ "mcp", - "--server-url", "http://127.0.0.1:8080", - "--contract", "staking" + "--server-url", "http://127.0.0.1:8080" ] } } } ``` -Restart Claude Desktop after saving. The MCP indicator in the input box lists `ilold` and its tools when the connection is healthy. +Restart Claude Desktop after saving. The MCP indicator in the input box lists `ilold` and its tools when the connection is healthy. Append `"--contract", "<name>"` to `args` to pre-set an initial program. ### Cursor @@ -110,15 +112,14 @@ Place the file at `.cursor/mcp.json` (project) or `~/.cursor/mcp.json` (global): "command": "ilold", "args": [ "mcp", - "--server-url", "http://127.0.0.1:8080", - "--contract", "staking" + "--server-url", "http://127.0.0.1:8080" ] } } } ``` -Optional `env` and `envFile` keys are supported by Cursor for passing environment variables. +Optional `env` and `envFile` keys are supported by Cursor for passing environment variables. Append `"--contract", "<name>"` to `args` to pre-set an initial program. ### Continue @@ -133,10 +134,10 @@ mcpServers: - mcp - --server-url - http://127.0.0.1:8080 - - --contract - - staking ``` +Append `- --contract` and `- <name>` to `args` to pre-set an initial program. + ## Tools The registry is derived at startup from `crates/ilold-help/src/lib.rs::SOLANA_HELP_BLOCKS`. The table below lists every exposed tool with a one-line summary. Each tool returns the matching `SolanaCommandResult` variant as structured JSON plus a pretty-printed text block identical to the REPL output. @@ -202,10 +203,21 @@ The registry is derived at startup from `crates/ilold-help/src/lib.rs::SOLANA_HE | Tool | Purpose | | --- | --- | +| `ilold_use` | Set the active program for the rest of the MCP session. Every other tool call routes to this program. | | `ilold_save` | Serialise the active scenario to `~/.ilold/sessions/<name>.json`. | | `ilold_load` | Restore a scenario JSON from disk and replay it into the VM. | -Total: 29 tools. The REPL meta commands (`?`, `help`, `quit`, `browser`, `use`, `seq`) are intentionally excluded: the MCP client discovers tools via `tools/list`, the subprocess exits on stdin EOF, the canvas URL is already on the human side, and the active program is fixed by `--contract`. +Total: 30 tools. The REPL meta commands (`?`, `help`, `quit`, `browser`, `seq`) are intentionally excluded: the MCP client discovers tools via `tools/list`, the subprocess exits on stdin EOF, and the canvas URL is already on the human side. + +## Switching programs + +Multi-program workspaces are handled at runtime, not at registration time: + +1. `ilold_programs` lists every program detected by the backend. The active one is marked. +2. `ilold_use <program>` sets the active program. The handler validates the name against `/api/project/map` and rejects unknown names. +3. Subsequent tool calls (`ilold_funcs`, `ilold_call`, etc.) route to the active program automatically. + +If no contract is active (no `--contract` flag and no prior `ilold_use` call), every tool other than `ilold_programs` and `ilold_use` returns a clear error asking the LLM to set one. `ilold_use` can be called any number of times in the same session to switch back and forth between programs. ## Example session @@ -227,7 +239,7 @@ Every step also fires a WebSocket patch from `ilold serve`, so a browser tab poi ## Limitations - **Solana only.** The MCP server refuses to start when the backend is a Solidity project. Solidity support is in the [cross-cutting roadmap](../roadmap/cross-cutting.md). -- **Single program per session.** `--contract` is fixed at startup; switching programs requires restarting the subprocess. Multi-program workspaces are still discoverable through `ilold_programs`. +- **Single active program at a time.** The handler tracks one active program. Call `ilold_use <program>` to switch — the MCP subprocess does not need to be restarted to point at a different program in the same workspace. - **Static tool registry.** Tools are derived from `SOLANA_HELP_BLOCKS` once at startup. Reloading the backend project does not change the tool set; only the data behind the tools. - **No sandbox over the LLM.** Every tool that mutates the VM (`ilold_call`, `ilold_clear`, `ilold_back`, `ilold_scenario`) is invocable without confirmation from the server. Sandboxing is delegated to the MCP client: mature clients prompt the human before destructive tools (those whose names contain `clear`, `delete`, `reset`). - **Narration is best-effort.** `--narration` emits a `notifications/progress` message keyed by the request `progressToken`. Clients that do not declare a progress token in the request silently drop the notification. @@ -240,4 +252,5 @@ Every step also fires a WebSocket patch from `ilold serve`, so a browser tab poi | `Cannot reach Ilold server at <url>` on startup | `ilold serve` is not running, or `--server-url` points to the wrong port. | | `Server at <url> is not a Solana project (kind=solidity)` | The backend was started against a Solidity workspace. Point `ilold serve` at a Solana project. | | Tools do not appear in the client | The client could not spawn `ilold`. Check that the binary is on `PATH` or use an absolute path in `command`. Inspect the client log (`~/Library/Logs/Claude/mcp-server-ilold.log` for Claude Desktop on macOS). | -| Tool call returns `Error: ...` | The backend rejected the `SolanaCommand`. The error text is the same as the REPL would print; check the `--contract` and the instruction arguments. | +| `No active contract` from every tool but `ilold_programs` | The session has no active program. Call `ilold_use <program>` (or restart the subprocess with `--contract <name>`). | +| Tool call returns `Error: ...` | The backend rejected the `SolanaCommand`. The error text is the same as the REPL would print; check the active program (`ilold_programs`) and the instruction arguments. | From aab9ef9a41c85ceb7294d5a1ef527fc03a0c5d3c Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Mon, 11 May 2026 15:47:15 +0200 Subject: [PATCH 111/115] fix(solana): canonicalize instruction source path for open-in-ide - Matches the Solidity solar_frontend pattern (line 37: canonicalize) - Without this VSCode opens '/tests/fixtures/...' relative to root and fails - Returns absolute file_path so deep-link vscode://file/<abs> works --- crates/ilold-web/src/api/project.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/ilold-web/src/api/project.rs b/crates/ilold-web/src/api/project.rs index edcb0ff..ddce106 100644 --- a/crates/ilold-web/src/api/project.rs +++ b/crates/ilold-web/src/api/project.rs @@ -345,6 +345,12 @@ pub async fn get_instruction_source( .join(&program.name) .join("src") .join("lib.rs"); + let file_path = std::fs::canonicalize(&file_path).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("canonicalize {}: {e}", file_path.display()), + ) + })?; let content = std::fs::read_to_string(&file_path).map_err(|e| { ( StatusCode::INTERNAL_SERVER_ERROR, From 251dcdbedb00fc68a7e136f3f6e59bfed677f5fa Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Mon, 11 May 2026 16:52:24 +0200 Subject: [PATCH 112/115] docs(guide): add llm-driven audit demo recipe - New recipes/ section with one page covering Solana MCP and Solidity REPL paths - Documents three Solana prompt styles (one-liner, senior auditor, adversarial) - Captures the typed REPL command sequence for Solidity audits - SUMMARY adds Recipes section above Reference --- docs/guide/src/SUMMARY.md | 4 + docs/guide/src/recipes/llm-demo.md | 135 +++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 docs/guide/src/recipes/llm-demo.md diff --git a/docs/guide/src/SUMMARY.md b/docs/guide/src/SUMMARY.md index 0fc65bb..0d1cc48 100644 --- a/docs/guide/src/SUMMARY.md +++ b/docs/guide/src/SUMMARY.md @@ -38,6 +38,10 @@ - [Workflow: Scenarios and forks](./solana/workflows/scenarios.md) - [Limitations](./solana/limitations.md) +# Recipes + +- [LLM-driven audit demo](./recipes/llm-demo.md) + # Reference - [HTTP API](./reference/api-endpoints.md) diff --git a/docs/guide/src/recipes/llm-demo.md b/docs/guide/src/recipes/llm-demo.md new file mode 100644 index 0000000..7749461 --- /dev/null +++ b/docs/guide/src/recipes/llm-demo.md @@ -0,0 +1,135 @@ +# LLM-driven audit demo + +Two walkthroughs for driving ilold with an LLM: + +- **Solana via MCP** — natural language prompts handed to Claude Code / Desktop / Cursor / Continue. The LLM picks the tools. +- **Solidity via REPL** — manual command sequence in `ilold explore`. Solidity does not have an MCP server in v1. + +Setup is documented in [Reference: MCP server](../reference/mcp.md) and [Getting Started](../getting-started.md). The recipes below assume the backend and the canvas are already running. + +## Solana via MCP + +Prerequisites: + +- `ilold serve tests/fixtures/solana/staking --port 8080` running in one terminal. +- (Optional) `npm run dev` running so the canvas at `http://localhost:5173` reflects each tool call. +- `claude mcp list` shows `ilold ✓ Connected`. + +Paste any of the prompts below into the LLM client. The LLM picks the right MCP tools by itself. + +### One-liner audit + +``` +Audit the active Solana program in ilold and hand me the deliverable when done. +``` + +The LLM will typically explore the program, set up actors, run a happy path, probe admin paths, and export a Markdown audit. Total time: ~30 seconds and ~15 tool calls. + +### Senior auditor framing + +``` +You have ilold available, a workbench that executes smart contracts in a real VM. +Act as a senior security auditor for Solana. A program just landed on your desk +for review. Find anything that could break it in production. + +Work as you would in a real audit: map the surface, set up realistic scenarios, +probe whatever attack vectors come to mind, and hand me the deliverable at the end. + +Narrate briefly as you go. +``` + +Best version for video recordings: the LLM stays explanatory, picks tools on its own, and the narration matches what the audience sees in the chat. + +### Adversarial framing + +``` +You are a hacker looking at a freshly deployed Solana program with 50M TVL. +Your only tool is ilold. Find out if you can drain funds without permission, +steal rewards from other users, or break the accounting. Test everything against +the real VM. Walk me through what you find and how you would exploit it. +``` + +More dramatic tone. Generates findings with attacker phrasing, useful for talks but not for production deliverables. + +### Switching programs mid-session + +After any of the prompts above, the LLM stays in the same MCP session. To audit a different program in the same backend: + +``` +Now list all programs available with ilold_programs, switch to a different one, +and repeat the same audit pattern. +``` + +If you swap the backend (stop `ilold serve` and start it again pointing at a new +project), the MCP keeps working: the next `ilold_programs` call sees the new +workspace. + +### Closing the session + +``` +Summarise everything you did. How many tool calls? Which paths did you cover? +Which paths are still untested? Save the scenario so I can replay it later. +``` + +The LLM will print the cumulative coverage and call `ilold_save` so the session lives in `~/.ilold/sessions/`. + +## Solidity via REPL + +Solidity is exposed through the interactive REPL (`ilold explore`), not through MCP. The REPL takes typed commands directly. + +Prerequisites: + +- `ilold explore tests/fixtures/staking.sol --port 8080` running. +- (Optional) `npm run dev` for the canvas. + +### Discovery and analysis + +``` +f List functions +i deposit Detail one function +v List state variables +who totalStaked Writers and readers of a state variable +seq Sequence narrative +``` + +### Building a session + +``` +c deposit Add deposit to the session +c withdraw Add withdraw +s View accumulated state +tr deposit Trace deposit execution +sl deposit totalStaked Dataflow slice for totalStaked through deposit +tl totalStaked Cross-step timeline of totalStaked +``` + +### Scenarios + +``` +scenario new attacker +scenario fork attacker 1 Fork active scenario at step 1 +scenario list +scenario switch main +``` + +### Findings and export + +``` +finding High "reentrancy in withdraw" +note "external call before state update" +status withdraw finding +export Generate Markdown deliverable +``` + +### Inline help + +``` +? Full command reference +sl? Quick help for a single command +``` + +## Recording tips + +The Solana MCP path is the one to record on video: the LLM picks tools live and the canvas paints alongside. Pair the chat (50% of the screen) with the canvas (the other 50%) and let the prompt run end to end without pauses. + +The Solidity REPL is better for narrated walkthroughs where you want to demonstrate each primitive deliberately. From eec9a2338be7fbd9ac86e6560c83feefe57271c2 Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Mon, 11 May 2026 17:40:32 +0200 Subject: [PATCH 113/115] chore: drop AI-style comments across the codebase - Strip multi-line prose docstrings from internal helpers - Drop section-header comments and obsolete task identifiers - Keep short WHY lines and one-line public API docs - Tests workspace stays green --- crates/ilold-cli/src/analyze.rs | 2 - crates/ilold-cli/src/context.rs | 5 +- crates/ilold-cli/src/explore.rs | 54 ----- crates/ilold-cli/src/fmt.rs | 26 --- crates/ilold-cli/src/interactive.rs | 36 +--- crates/ilold-cli/src/main.rs | 8 +- crates/ilold-help/src/lib.rs | 6 +- crates/ilold-mcp/src/schema.rs | 13 -- crates/ilold-mcp/src/tools.rs | 17 -- crates/ilold-render/src/solana.rs | 3 - .../src/exploration/canvas.rs | 4 - .../src/exploration/session.rs | 13 -- .../ilold-session-core/src/journal/export.rs | 11 +- .../ilold-session-core/src/journal/types.rs | 7 - crates/ilold-solana-core/src/execute/fork.rs | 8 - .../src/exploration/add_step.rs | 26 +-- .../src/exploration/commands.rs | 35 --- .../src/exploration/execute.rs | 26 --- crates/ilold-solana-core/src/overlay.rs | 10 - .../frontend/src/lib/stores/graph.svelte.ts | 30 +-- .../frontend/src/lib/stores/palette.svelte.ts | 8 - .../src/lib/stores/runtimeOverlay.svelte.ts | 8 - .../frontend/src/lib/stores/search.svelte.ts | 6 - .../frontend/src/lib/stores/session.svelte.ts | 40 +--- .../src/routes/contract/[name]/+page.svelte | 199 +----------------- crates/ilold-web/src/api/contract.rs | 49 ----- crates/ilold-web/src/api/project.rs | 24 --- crates/ilold-web/src/api/session.rs | 110 ---------- crates/ilold-web/src/state.rs | 36 ---- crates/ilold-web/src/ws/pty.rs | 3 - crates/ilold-web/src/ws/search.rs | 2 - 31 files changed, 21 insertions(+), 804 deletions(-) diff --git a/crates/ilold-cli/src/analyze.rs b/crates/ilold-cli/src/analyze.rs index 62dba7a..361b910 100644 --- a/crates/ilold-cli/src/analyze.rs +++ b/crates/ilold-cli/src/analyze.rs @@ -41,8 +41,6 @@ pub fn run( println!("Parsed {} file(s), {} contract(s)\n", project.source_files.len(), project.contracts.len()); - // Precompute all per-contract sequence analyses, then run the - // inheritance-aware transitive effect pass. let config = PruningConfig::default(); let mut all_analyses: HashMap<String, SequenceAnalysis> = HashMap::new(); for contract in &project.contracts { diff --git a/crates/ilold-cli/src/context.rs b/crates/ilold-cli/src/context.rs index 12fa3df..d7b4a03 100644 --- a/crates/ilold-cli/src/context.rs +++ b/crates/ilold-cli/src/context.rs @@ -54,8 +54,6 @@ pub fn run( pt_map.insert(key, pt); } } - // Build per-contract analyses for every contract in the project so that - // transitive effects can span the full inheritance chain. let mut all_sequence_analyses: HashMap<String, ilold_core::sequence::analysis::SequenceAnalysis> = HashMap::new(); for c in &project.contracts { let combined = project.inherited_state_vars(c); @@ -213,8 +211,7 @@ fn print_sequence(n: &SequenceNarrative) { c_bright(&format!("Step {}: {}", i + 1, step.function)), c_muted(&format!("({})", step.access))); - // Collect all lines for this step to know which is last - let mut lines: Vec<(String, String)> = Vec::new(); // (label, value) + let mut lines: Vec<(String, String)> = Vec::new(); for req in &step.requires { lines.push(("require".into(), c_warn(req).to_string())); diff --git a/crates/ilold-cli/src/explore.rs b/crates/ilold-cli/src/explore.rs index 043c9b0..b1e5fb7 100644 --- a/crates/ilold-cli/src/explore.rs +++ b/crates/ilold-cli/src/explore.rs @@ -64,8 +64,6 @@ pub async fn run(paths: Vec<PathBuf>, port: u16, max_seq_depth: usize, attach: O let project_info: serde_json::Value = resp.json().await?; let contracts_arr = project_info["contracts"].as_array(); - // Pick the LAST contract (not interface/library) — in Solidity the main - // contract is always at the end of the file, after imports and dependencies. let contract_name = contracts_arr .and_then(|arr| arr.iter().rev().find(|c| c["kind"].as_str() == Some("Contract"))) .or_else(|| contracts_arr.and_then(|arr| arr.last())) @@ -73,8 +71,6 @@ pub async fn run(paths: Vec<PathBuf>, port: u16, max_seq_depth: usize, attach: O .unwrap_or("unknown") .to_string(); - // /api/project only has function counts, not names. - // Fetch /api/contract/{name} per contract to get function names for the completer. let mut functions_by_contract = HashMap::<String, Vec<String>>::new(); let contract_names_raw: Vec<String> = contracts_arr .map(|arr| arr.iter().filter_map(|c| c["name"].as_str().map(String::from)).collect()) @@ -322,7 +318,6 @@ fn repl_loop( scenario: scenario_name.clone(), }; - // Initial prompt sync in --attach mode: pick up steps from other terminals if state.is_none() { if let Some(server_steps) = sync_steps(&handle, &client, &base_url, &contract, backend) { steps = server_steps; @@ -342,7 +337,6 @@ fn repl_loop( } loop { - // Sync prompt from server in --attach mode (catches changes from other terminals) if state.is_none() { if let Some(server_steps) = sync_steps(&handle, &client, &base_url, &contract, backend) { if server_steps != steps { @@ -422,7 +416,6 @@ fn repl_loop( } } } else if let Some(fbc) = functions_by_contract.as_ref() { - // --attach mode: use cached per-contract function map if let Some(funcs) = fbc.get(&new_name) { functions = funcs.clone(); if let Ok(mut comp) = completer.lock() { @@ -488,13 +481,11 @@ fn handle_input( completer, ); } - // Allow shortcuts like `st0`, `st1`, `step2` without requiring a space. let normalized = split_numeric_suffix(line); let parts: Vec<&str> = normalized.splitn(2, ' ').collect(); let cmd = parts[0].to_lowercase(); let arg = parts.get(1).map(|s| s.trim()).unwrap_or(""); - // Inline help: appending ? to any command prints a one-line usage. if cmd.ends_with('?') && cmd.len() > 1 { let base = &cmd[..cmd.len() - 1]; print_inline_help(base); @@ -516,8 +507,6 @@ fn handle_input( use ilold_core::exploration::commands::ScenarioAction; - // Parse `fork <name>` or `fork <name> at <N>`. Returns Err with a - // user-facing message on parse failure. let parse_fork = |raw: &str| -> Result<ScenarioAction, String> { let parts: Vec<&str> = raw.split_whitespace().collect(); match parts.as_slice() { @@ -564,7 +553,6 @@ fn handle_input( }); match send_command(handle, client, base_url, &body) { Ok(result) => { - // Update local trackers before printing. let mut did_update_scenario = false; match &result { CommandResult::ScenarioCreated { name } => { @@ -614,7 +602,6 @@ fn handle_input( _ => print_contracts(state, contract), } } else { - // --attach mode: fetch contract list from server match handle.block_on(async { let resp = client.get(format!("{base_url}/api/project")).send().await?; resp.json::<serde_json::Value>().await @@ -698,7 +685,6 @@ fn handle_input( } } } else { - // --attach mode: switch directly, let the server validate commands let name = arg.to_string(); if name == contract { println!(" Already using {}", c_accent(&name)); @@ -819,7 +805,6 @@ fn handle_input( if arg.is_empty() { handle_finding_interactive(handle, client, base_url, contract, steps); } else { - // Parse: fi <severity> <title> [description] let finding_parts: Vec<&str> = arg.splitn(2, ' ').collect(); if finding_parts.len() < 2 { println!(" Usage: fi <severity> <title>"); @@ -929,9 +914,6 @@ fn handle_input( TraceTarget::Function(func_name) => { let mut url = format!("{base_url}/api/session/trace/{contract}/{func_name}"); let mut sep = '?'; - // Interactive mode needs more context to be useful, so - // bump the default depth to 4 when `-i` is set and the - // user didn't pass an explicit `--depth`. let effective_depth = parsed.depth .or(if parsed.interactive { Some(4) } else { None }); if let Some(d) = effective_depth { @@ -952,7 +934,6 @@ fn handle_input( url } TraceTarget::SessionStep(idx) => { - // Persisted tree — depth/reverts/expand flags ignored. format!("{base_url}/api/session/step/{idx}/trace") } }; @@ -996,9 +977,6 @@ fn handle_input( } "sl" | "slice" => { let parts: Vec<&str> = arg.split_whitespace().collect(); - // Separate flags from positional args so order doesn't matter: - // `sl deposit totalStaked --backward` and `sl --backward deposit totalStaked` - // both parse correctly. let mut positionals: Vec<&str> = Vec::new(); let mut direction: Option<&str> = None; for part in &parts { @@ -1438,8 +1416,6 @@ fn handle_solana_input( println!(" Severity: critical | high | medium | low | info"); return InputResult::Continue; } - // Strip a trailing --rec="..." (or unquoted) before splitting - // severity + title so the title can contain spaces. let (rest, rec): (&str, Option<String>) = match arg.find("--rec=") { Some(idx) => { let head = arg[..idx].trim_end(); @@ -1474,8 +1450,6 @@ fn handle_solana_input( dispatch_solana(handle, client, base_url, contract, body, steps) } "seq" | "sequence" => { - // Solana lacks the dedicated sequence-narrative endpoint; fall back - // to the Session view which renders the step list with CU/diffs. dispatch_solana( handle, client, @@ -1509,9 +1483,6 @@ fn handle_solana_input( println!(" Usage: save <name> [--with-keypairs]"); return InputResult::Continue; } - // SDD-03: parse the optional --with-keypairs flag. The flag may - // come before or after <name>; everything else is rejected so a - // typo never silently saves without secrets. let mut with_keypairs = false; let mut name: Option<&str> = None; for tok in arg.split_whitespace() { @@ -1590,9 +1561,6 @@ fn handle_solana_input( return InputResult::Continue; } }; - // SDD-03: warn the auditor at load time when the bundle carries - // plaintext keypairs, so accidental git commits get a visible - // reminder. Cheap detection — we already have the JSON in memory. if json.contains("\"keypairs_present\": true") || json.contains("\"keypairs_present\":true") { @@ -1625,9 +1593,6 @@ fn handle_solana_input( steps, ), "export" | "ex" => { - // Parse optional --auditor=, --version=, --date= flags. Anything - // else is an error so a typo never produces a half-empty - // deliverable. let mut auditor: Option<String> = None; let mut version: Option<String> = None; let mut date: Option<String> = None; @@ -2006,9 +1971,6 @@ fn sync_scenarios( } } -/// Fetch current session steps from the server (for --attach prompt sync). -/// Dispatches by backend kind: Solidity uses `CommandResult::SessionView`, -/// Solana uses `SolanaCommandResult::SessionView` (different shape, same name). fn sync_steps( handle: &tokio::runtime::Handle, client: &reqwest::Client, @@ -2148,7 +2110,6 @@ fn parse_trace_args(arg: &str) -> TraceArgs { interactive = true; i += 1; } else if let Some(rest) = t.strip_prefix('+') { - // `+N` — force-inline the call at canonical step_id N. if let Ok(id) = rest.parse::<usize>() { expand.push(id); } @@ -2157,9 +2118,6 @@ fn parse_trace_args(arg: &str) -> TraceArgs { && target.is_none() && tokens.get(i + 1).and_then(|s| s.parse::<usize>().ok()).is_some() { - // `tr step <N>` — re-render a persisted session step. - // Only treated as a keyword when the next token parses as usize; - // otherwise `step` falls through to be treated as a function name. let idx = tokens[i + 1].parse::<usize>().unwrap(); target = Some(TraceTarget::SessionStep(idx)); i += 2; @@ -2173,8 +2131,6 @@ fn parse_trace_args(arg: &str) -> TraceArgs { TraceArgs { target, depth, reverts, expand, interactive } } -/// Strip a single surrounding pair of double or single quotes from a CLI flag -/// value (`--rec="hello world"` → `hello world`). No-op when not quoted. fn strip_quotes(s: &str) -> &str { let s = s.trim(); if (s.starts_with('"') && s.ends_with('"') && s.len() >= 2) @@ -2390,8 +2346,6 @@ fn print_findings_list( } } -// ─── Output formatting ───────────────────────────────────────────────────── - fn print_result(result: &CommandResult, steps: &[String]) { match result { CommandResult::StepAdded { step_index, function, access, state_changed } => { @@ -2674,8 +2628,6 @@ fn print_narrative(val: &serde_json::Value) { println!(" {} [{}]{}", c_bright(name), c_accent(access), mod_str); } - // Build the list of sections that will be shown so we know which is last - // (for picking the trailing branch character). #[derive(Default)] struct TransitiveGroup { writes: Vec<String>, @@ -2737,7 +2689,6 @@ fn print_narrative(val: &serde_json::Value) { sections.push(Section::StringList { label: "Events", label_color: SectionColor::Accent, items: events }); } - // Transitive effects (grouped by chain) let collect_transitive = |key: &str| -> Vec<(Vec<String>, String)> { val.get(key) .and_then(|v| v.as_array()) @@ -3055,7 +3006,6 @@ fn print_inline_help(cmd: &str) { (&["save"], "save <name>", "Save session to disk. Example: save my-audit"), (&["load"], "load <name>", "Load session from disk. Example: load my-audit"), (&["browser"], "browser", "Open the web UI in a browser."), - // Solana-specific commands: (&["users"], "users [new <name> [<lamports>]]", "List or create keypairs in active scenario."), (&["airdrop", "air"], "airdrop <name> <lamports>", "Top up a user's SOL balance."), (&["tw", "time-warp"],"time-warp <delta_seconds>", "Warp the Clock sysvar (positive forward, negative back)."), @@ -3073,8 +3023,6 @@ fn print_inline_help(cmd: &str) { println!(" {} unknown command: {}", c_danger("✗"), cmd); } -// ─── Reedline: Prompt ────────────────────────────────────────────────────── - struct IloldPrompt { contract: String, steps: Vec<String>, @@ -3122,8 +3070,6 @@ impl Prompt for IloldPrompt { } } -// ─── Reedline: Completer ─────────────────────────────────────────────────── - struct IloldCompleter { functions: Vec<String>, contracts: Vec<String>, diff --git a/crates/ilold-cli/src/fmt.rs b/crates/ilold-cli/src/fmt.rs index cab0b37..fcb4e51 100644 --- a/crates/ilold-cli/src/fmt.rs +++ b/crates/ilold-cli/src/fmt.rs @@ -28,7 +28,6 @@ pub fn separator(label: &str) -> String { ) } -/// Pass PLAIN text lines (no ANSI colors). The box handles its own coloring. pub fn header_box(lines: &[&str]) -> String { let max_content = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0); let inner_width = max_content + 2; @@ -69,8 +68,6 @@ pub fn pad_right(s: &str, width: usize) -> String { } } -// Flow tree renderer — trace command output. - pub fn render_flow_tree(tree: &FlowTree) -> String { let mut out = String::new(); @@ -91,8 +88,6 @@ pub fn render_flow_tree(tree: &FlowTree) -> String { append_expand_hint(tree, &mut out); - // Collect state-written variables for cross-ref hints. We extract the - // base name (before any `[`) because the slicer works on base identifiers. let mut raw_vars: Vec<&str> = Vec::new(); collect_written_vars(&tree.root, &mut raw_vars); let mut base_vars: Vec<String> = raw_vars.iter() @@ -129,9 +124,6 @@ fn collect_written_vars<'a>(node: &'a FlowNode, out: &mut Vec<&'a str>) { } } -/// If the rendered tree contains depth-limited InternalCalls, append a -/// footer listing their canonical step_ids so the auditor knows what to -/// pass to `tr <func> +N`. Caps at 10 candidates. fn append_expand_hint(tree: &FlowTree, out: &mut String) { let mut candidates: Vec<usize> = Vec::new(); collect_depth_limited(&tree.root, &mut candidates); @@ -311,10 +303,6 @@ fn color_for_kind_text(kind: &FlowKind, text: &str) -> colored::ColoredString { } } -// ───────────────────────────────────────────────────────────────────────────── -// Variable timeline renderer -// ───────────────────────────────────────────────────────────────────────────── - pub fn render_variable_timeline(tl: &VariableTimeline) -> String { let mut out = String::new(); out.push('\n'); @@ -341,7 +329,6 @@ pub fn render_variable_timeline(tl: &VariableTimeline) -> String { render_entries(&tl.local_entries, &mut out); } - // Collect unique function names from all entries for slice hints. let all_entries = tl.state_entries.iter().chain(tl.local_entries.iter()); let mut seen = std::collections::HashSet::new(); let hints: Vec<String> = all_entries @@ -359,7 +346,6 @@ pub fn render_variable_timeline(tl: &VariableTimeline) -> String { fn render_entries(entries: &[TimelineEntry], out: &mut String) { let mut prev_step: Option<usize> = None; for entry in entries { - // Group header per session step. if Some(entry.session_step_index) != prev_step { out.push_str(&format!( " {} {}\n", @@ -401,10 +387,6 @@ fn render_entries(entries: &[TimelineEntry], out: &mut String) { } } -// ───────────────────────────────────────────────────────────────────────────── -// Dataflow slice renderer -// ───────────────────────────────────────────────────────────────────────────── - pub fn render_slice_result(res: &SliceResult) -> String { let mut out = String::new(); out.push('\n'); @@ -472,10 +454,6 @@ fn render_slice_side(label: &str, entries: &[SliceEntry], var: &str, out: &mut S } } -// ───────────────────────────────────────────────────────────────────────────── -// Scenario renderers -// ───────────────────────────────────────────────────────────────────────────── - pub fn render_scenario_list(items: &[ScenarioInfo]) -> String { if items.is_empty() { return format!(" {}\n", c_muted("(no scenarios)")); @@ -488,7 +466,6 @@ pub fn render_scenario_list(items: &[ScenarioInfo]) -> String { .unwrap_or(0) .max(4); - // Build header-box lines (plain text; header_box colors the frame). let title = format!( "scenarios — {} total, active: {}", items.len(), @@ -602,9 +579,6 @@ mod tests { #[test] fn render_require_message_not_double_escaped() { - // Regression: `{:?}` on the message used to produce `"\"LOCKED\""`. - // After the fix with `{}`, the output should contain the unescaped - // form `"LOCKED"`. let tree = make_require_tree(Some("\"LOCKED\"")); let rendered = strip_ansi(&render_flow_tree(&tree)); diff --git a/crates/ilold-cli/src/interactive.rs b/crates/ilold-cli/src/interactive.rs index 189e0e5..826f35b 100644 --- a/crates/ilold-cli/src/interactive.rs +++ b/crates/ilold-cli/src/interactive.rs @@ -1,5 +1,3 @@ -// Interactive FlowTree viewer. Entered via `tr <func> -i` in the REPL. - use std::collections::HashSet; use std::io; use std::time::Duration; @@ -18,7 +16,6 @@ use ratatui::{Frame, Terminal}; use ilold_core::narrative::trace::{FlowKind, FlowNode, FlowTree}; -/// Restores the terminal on drop, including the panic path. struct TerminalGuard; impl TerminalGuard { @@ -40,8 +37,6 @@ impl Drop for TerminalGuard { struct ViewerState { tree: FlowTree, collapsed: HashSet<usize>, - /// Tracked by id (not list index) so expand/collapse keeps selection - /// on the same logical node. selected_step_id: usize, show_help: bool, } @@ -63,8 +58,6 @@ impl ViewerState { out } - /// Owned snapshot with no borrows into `self` — safe to hold across - /// `&mut self` mutations inside the event loop. fn snapshot(&self) -> Vec<RowSnapshot> { self.flatten() .iter() @@ -103,8 +96,6 @@ struct FlatRow<'a> { is_collapsed: bool, } -/// Pre-order flatten, skipping children of collapsed nodes. Tree-drawing -/// chars are baked into each row's prefix. fn flatten_node<'a>( node: &'a FlowNode, parent_prefix: &str, @@ -148,7 +139,6 @@ fn flatten_node<'a>( } } -/// Block on the viewer until the user presses `q` or `Esc`. pub fn run_trace_viewer(tree: FlowTree) -> io::Result<()> { let _guard = TerminalGuard::new()?; @@ -162,19 +152,15 @@ pub fn run_trace_viewer(tree: FlowTree) -> io::Result<()> { Ok(()) } -// ───────────────────────────────────────────────────────────────────────────── -// Rendering -// ───────────────────────────────────────────────────────────────────────────── - fn draw_ui(frame: &mut Frame, state: &ViewerState, flat: &[FlatRow<'_>]) { let area = frame.area(); let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // header - Constraint::Min(1), // list - Constraint::Length(1), // footer + Constraint::Length(3), + Constraint::Min(1), + Constraint::Length(1), ]) .split(area); @@ -194,7 +180,6 @@ fn draw_help_overlay(frame: &mut Frame, area: Rect) { let y = area.y + (area.height.saturating_sub(height)) / 2; let popup = Rect { x, y, width, height }; - // Clear the popup area so the underlying list doesn't show through. frame.render_widget(Clear, popup); let cyan = Style::default().fg(Color::Cyan); @@ -412,17 +397,11 @@ fn kind_text_color(kind: &FlowKind) -> Color { } } -// ───────────────────────────────────────────────────────────────────────────── -// Event loop -// ───────────────────────────────────────────────────────────────────────────── - fn run_loop( terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, state: &mut ViewerState, ) -> io::Result<()> { loop { - // If a prior collapse hid the selected node, snap the cursor to - // the first visible row so it never lives off-screen. let snap = state.snapshot(); if !snap.iter().any(|r| r.step_id == state.selected_step_id) { if let Some(first) = snap.first() { @@ -440,7 +419,6 @@ fn run_loop( } let ev = event::read()?; let Event::Key(key) = ev else { continue }; - // Ignore Release events so holding a key doesn't double-trigger. if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat { continue; } @@ -451,9 +429,7 @@ fn run_loop( } } -/// Returns `false` to exit the loop. fn handle_key(state: &mut ViewerState, snap: &[RowSnapshot], code: KeyCode) -> bool { - // Help overlay swallows most keys; only ?/Esc/F1 close it, q still quits. if state.show_help { match code { KeyCode::Char('q') => return false, @@ -485,7 +461,6 @@ fn handle_key(state: &mut ViewerState, snap: &[RowSnapshot], code: KeyCode) -> b } KeyCode::Right | KeyCode::Enter | KeyCode::Char('l') => { - // Expand current node — remove from collapsed set. let idx = state.cursor_in_snapshot(snap); if let Some(row) = snap.get(idx) { if row.has_children && row.is_collapsed { @@ -494,9 +469,6 @@ fn handle_key(state: &mut ViewerState, snap: &[RowSnapshot], code: KeyCode) -> b } } KeyCode::Left | KeyCode::Char('h') => { - // Collapse current node — add to collapsed set. If it's already - // collapsed (or is a leaf), jump to parent so repeated ← walks - // up the tree. let idx = state.cursor_in_snapshot(snap); if let Some(row) = snap.get(idx) { if row.has_children && !row.is_collapsed { @@ -523,8 +495,6 @@ fn handle_key(state: &mut ViewerState, snap: &[RowSnapshot], code: KeyCode) -> b true } -/// Find the step_id of the node that has `child_id` as a direct child. -/// Returns `None` if `child_id` is the root or not found. fn find_parent_id(node: &FlowNode, child_id: usize) -> Option<usize> { for child in &node.children { if child.step_id == child_id { diff --git a/crates/ilold-cli/src/main.rs b/crates/ilold-cli/src/main.rs index 1d2cc91..987e361 100644 --- a/crates/ilold-cli/src/main.rs +++ b/crates/ilold-cli/src/main.rs @@ -67,14 +67,10 @@ enum Commands { /// Base URL of a running `ilold serve` instance (defaults to http://127.0.0.1:8080) #[arg(long, env = "ILOLD_SERVER_URL", default_value = "http://127.0.0.1:8080")] server_url: String, - /// Optional initial active program. When set, every tool call routes to - /// this program until the LLM (or the user) invokes `ilold_use` to - /// switch. Leave unset to let the LLM pick the program at runtime. + /// Optional initial active program (LLM can switch later via ilold_use). #[arg(long, env = "ILOLD_CONTRACT")] contract: Option<String>, - /// Emit a `notifications/progress` MCP message before each tool call - /// describing intent. Useful when the client UI renders progress next - /// to tool calls so the human sees what the LLM is about to run. + /// Emit MCP progress notifications describing each tool-call intent. #[arg(long, env = "ILOLD_NARRATION")] narration: bool, }, diff --git a/crates/ilold-help/src/lib.rs b/crates/ilold-help/src/lib.rs index f22a7c2..7463988 100644 --- a/crates/ilold-help/src/lib.rs +++ b/crates/ilold-help/src/lib.rs @@ -103,7 +103,7 @@ pub const SOLANA_HELP_BLOCKS: &[HelpBlock] = &[ HelpBlock { title: "fa | funcs-all", aliases: &["fa", "funcs-all"], - purpose: "List instructions with full counts plus admin-gating and coupling hints (T-R50 ProgramView).", + purpose: "List instructions with full counts plus admin-gating and coupling hints.", syntax: &[("fa", "")], flags: &[], examples: &[("fa", "Spot admin-only entry points and shared-writable couplings at a glance")], @@ -252,7 +252,7 @@ pub const SOLANA_HELP_BLOCKS: &[HelpBlock] = &[ HelpBlock { title: "coupling | cp", aliases: &["coupling", "cp"], - purpose: "List instruction pairs that share a writable account (coupling heuristic from T-R50).", + purpose: "List instruction pairs that share a writable account.", syntax: &[("coupling", "")], flags: &[], examples: &[("coupling", "Surface ix that may interfere via shared writable state")], @@ -262,7 +262,7 @@ pub const SOLANA_HELP_BLOCKS: &[HelpBlock] = &[ HelpBlock { title: "coverage | cov", aliases: &["coverage", "cov"], - purpose: "Aggregated runtime metrics over the active scenario: calls, failures, CU stats, CPI edges (T-R52 RuntimeOverlay).", + purpose: "Aggregated runtime metrics over the active scenario: calls, failures, CU stats, CPI edges.", syntax: &[("coverage", "")], flags: &[], examples: &[("cov", "Spot ix never called, ix that always fail, programs reached via CPI")], diff --git a/crates/ilold-mcp/src/schema.rs b/crates/ilold-mcp/src/schema.rs index 045f726..dd685d0 100644 --- a/crates/ilold-mcp/src/schema.rs +++ b/crates/ilold-mcp/src/schema.rs @@ -1,13 +1,6 @@ use schemars::Schema; use serde_json::{Map, Value, json}; -/// JSON Schema for the input arguments of a tool. The MCP spec requires -/// `inputSchema` to be a JSON object describing the `arguments` payload. -/// We hand-roll the schemas per tool (rather than slicing the big -/// SolanaCommand schema) because some variants need MCP-specific shapes: -/// for tools without arguments we return the canonical empty-object schema, -/// for tools with arguments we lift the variant fields to the top level so -/// the LLM sees `{ ix, args, accounts }` instead of `{ Call: { ix, ... } }`. pub fn schema_for_tool(name: &str) -> Value { match name { "ilold_call" => schema_for_call(), @@ -34,10 +27,6 @@ pub fn schema_for_tool(name: &str) -> Value { "ilold_load" => string_only("json", "Saved scenario JSON to restore"), "ilold_scenario" => schema_scenario(), "ilold_export" => schema_export(), - // Tools without arguments (Funcs, Back, Clear, State, Session, - // Users, Vars, Findings, Coupling, Coverage, FuncsAll, Programs, - // Sequence). Programs is a synthesised name; the registry uses - // `ilold_programs` for the workspace listing handler. _ => empty_object_schema(), } } @@ -294,8 +283,6 @@ fn schema_export() -> Value { }) } -/// Returns the schemars-generated schema for the full SolanaCommand enum. -/// Useful for diagnostics and for the schema_consistency cross-check test. pub fn full_solana_command_schema() -> Schema { schemars::schema_for!(ilold_solana_core::exploration::SolanaCommand) } diff --git a/crates/ilold-mcp/src/tools.rs b/crates/ilold-mcp/src/tools.rs index 42f2451..9bd0714 100644 --- a/crates/ilold-mcp/src/tools.rs +++ b/crates/ilold-mcp/src/tools.rs @@ -6,15 +6,6 @@ use serde_json::{Map, Value, json}; use crate::schema::schema_for_tool; -// `ilold_use` is kept in the registry but handled client-side by the MCP -// server: it sets the active contract on the handler instead of issuing a -// SolanaCommand. Every other tool reads that state and routes its call to -// the active program. -// -// `seq | sequence` is excluded because the dedicated `/api/session/sequence` -// endpoint is Solidity-only (gated by `require_solidity_msg`). For Solana the -// REPL falls back to the Session view, so exposing both `ilold_session` and -// `ilold_sequence` would be two tool names for the exact same backend call. const EXCLUDED_ALIASES: &[&str] = &[ "?", "help", "h", "quit", "q", "exit", "browser", "seq", "sequence", ]; @@ -42,7 +33,6 @@ pub fn is_excluded(block: &HelpBlock) -> bool { } pub fn canonical_alias(block: &HelpBlock) -> &'static str { - // alias must have at least 4 chars to be canonical, so `seq` falls back to `sequence` block .aliases .iter() @@ -74,9 +64,6 @@ fn value_to_json_object(v: Value) -> JsonObject { } } -/// Translate MCP tool `name` + `arguments` into the SolanaCommand JSON value -/// that the backend `/api/cmd` endpoint expects. Returns the JSON `command` -/// payload only — the `IloldClient` wraps it with the `contract` field. pub fn build_command(name: &str, arguments: Option<&Value>) -> Result<Value, String> { let args = arguments.cloned().unwrap_or_else(|| json!({})); let args_obj = args.as_object().cloned().unwrap_or_default(); @@ -209,9 +196,6 @@ mod tests { #[test] fn tool_registry_has_30_entries() { - // 33 help blocks - 3 meta (?, quit, browser) - 2 sequence aliases - // (seq, sequence excluded because the /api/session/sequence endpoint - // is Solidity-only) + 1 dedicated users-new block + 1 ilold_use = 30. let tools = build_tool_registry(); assert_eq!(tools.len(), 30); } @@ -371,7 +355,6 @@ mod tests { #[test] fn build_command_users_new_includes_default_lamports() { - // Lamports omitted on the wire — backend applies its own default. let v = build_command("ilold_users_new", Some(&json!({ "name": "alice" }))).unwrap(); let cmd: SolanaCommand = serde_json::from_value(v).expect("deserialize"); match cmd { diff --git a/crates/ilold-render/src/solana.rs b/crates/ilold-render/src/solana.rs index fe09265..5d325d7 100644 --- a/crates/ilold-render/src/solana.rs +++ b/crates/ilold-render/src/solana.rs @@ -11,9 +11,6 @@ use ilold_solana_core::view::{ use crate::colors::{c_accent, c_danger, c_muted, c_ok, c_warn}; use crate::fmt::pad_right; -/// Render a SolanaCommandResult to text. Output is byte-identical to the -/// legacy `print_solana_result` body (without the leading/trailing blank -/// line); callers add framing whitespace where appropriate. pub fn render_solana_result(result: &SolanaCommandResult) -> String { let mut out = String::new(); match result { diff --git a/crates/ilold-session-core/src/exploration/canvas.rs b/crates/ilold-session-core/src/exploration/canvas.rs index a963dac..9e10698 100644 --- a/crates/ilold-session-core/src/exploration/canvas.rs +++ b/crates/ilold-session-core/src/exploration/canvas.rs @@ -19,10 +19,6 @@ pub enum CanvasPatch { Highlight { scenario: String, function: String }, ScenarioEvent(ScenarioEvent), SolanaUsersChanged { scenario: String }, - /// Incremental runtime overlay delta emitted right after a Call resolves - /// (StepAdded or CallFailed). The frontend store merges these against the - /// snapshot fetched from `/api/program/{name}/overlay` so badges stay in - /// sync without re-fetching the full overlay on every tick. OverlayUpdate { scenario: String, ix_name: String, diff --git a/crates/ilold-session-core/src/exploration/session.rs b/crates/ilold-session-core/src/exploration/session.rs index 992a423..f69e35f 100644 --- a/crates/ilold-session-core/src/exploration/session.rs +++ b/crates/ilold-session-core/src/exploration/session.rs @@ -12,10 +12,6 @@ pub struct ExplorationSession { pub journal: AuditJournal, #[serde(default)] pub forked_from: Option<ForkOrigin>, - /// Counts CallFailed outcomes per instruction. Lives off `steps` because - /// failed Calls deliberately do not push a step (Solidity-aligned model) - /// — without this counter the runtime overlay could never surface - /// "rejected Nx" badges. #[serde(default)] pub failed_calls_per_ix: BTreeMap<String, u32>, } @@ -36,10 +32,6 @@ pub struct ExplorationStep { pub trace_config: TraceConfig, #[serde(default)] pub runtime_trace: Option<serde_json::Value>, - /// Solana-only: the original Call inputs (args, accounts, signer names) so - /// LoadSession can replay the step against a fresh VM and reconstruct the - /// post-step accounts state. None for Solidity (no replay) or for steps - /// that came from an older save without this field. #[serde(default)] pub call_payload: Option<serde_json::Value>, } @@ -114,10 +106,6 @@ impl ExplorationSession { .or_insert(0) += 1; } - /// Reset scenario-local observations that must not survive a fork. - /// Currently only `failed_calls_per_ix`, but kept as a single helper so - /// future scenario-local counters land in one place instead of fanning - /// out across every fork site. pub fn reset_scenario_local_observations(&mut self) { self.failed_calls_per_ix.clear(); } @@ -206,7 +194,6 @@ mod tests { assert_eq!(s.failed_calls_per_ix.get("stake").copied(), Some(2)); s.reset_scenario_local_observations(); assert!(s.failed_calls_per_ix.is_empty()); - // Steps stay; the reset is scoped to scenario-local observations. assert_eq!(s.steps.len(), 1); } diff --git a/crates/ilold-session-core/src/journal/export.rs b/crates/ilold-session-core/src/journal/export.rs index f5a6c92..abd1988 100644 --- a/crates/ilold-session-core/src/journal/export.rs +++ b/crates/ilold-session-core/src/journal/export.rs @@ -4,9 +4,6 @@ use serde::{Deserialize, Serialize}; use super::types::*; -/// Optional auditor metadata threaded through the export. Pass-through, not -/// stored in the journal — see SDD 02-audit-deliverable-export/design.md -/// rationale (avoids JSON-format bumps and keeps PII out of saved sessions). #[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)] pub struct AuditMetadata { #[serde(default)] @@ -17,9 +14,6 @@ pub struct AuditMetadata { pub audit_date: Option<String>, } -/// Solana-specific program facts the renderer prints next to the metadata. -/// Solidity callers pass `None` to `export_markdown_multi`; the body simply -/// omits the program block. #[derive(Debug, Clone)] pub struct ProgramSection { pub name: String, @@ -53,8 +47,6 @@ pub fn export_markdown(journal: &AuditJournal, total_functions: usize) -> String md } -// ── Shared private helpers ────────────────────────────────────────────────── - pub(crate) fn render_findings_block(md: &mut String, findings: &[Finding]) { if findings.is_empty() { writeln!(md, "## Findings\n\nNo findings recorded.").unwrap(); @@ -193,8 +185,7 @@ pub fn export_markdown_multi( let total_findings: usize = scenarios.iter().map(|(_, j)| j.findings.len()).sum(); writeln!(md, "**Scenarios**: {} · **Total findings**: {}\n", scenarios.len(), total_findings).unwrap(); - let _ = total_steps; // step listing is rendered by the Solana caller via ExplorationStep, - // which the journal layer does not own — kept here for future use. + let _ = total_steps; if let Some(p) = program { writeln!(md, "## Program\n").unwrap(); diff --git a/crates/ilold-session-core/src/journal/types.rs b/crates/ilold-session-core/src/journal/types.rs index b51b083..0ec02d7 100644 --- a/crates/ilold-session-core/src/journal/types.rs +++ b/crates/ilold-session-core/src/journal/types.rs @@ -34,14 +34,8 @@ pub struct Finding { pub description: String, pub notes: Vec<String>, pub created_at: String, - /// Step index that motivated the finding. Captured automatically at - /// recording time from `session.steps.len() - 1`. None when the finding - /// is recorded before any step has been executed. #[serde(default)] pub affected_step_index: Option<usize>, - /// Optional remediation suggestion (Solana auditor flow opts in via - /// `fi <sev> <title> --rec="…"`). When present the export markdown - /// renders a separate "Recommendation" block per finding. #[serde(default)] pub recommendation: Option<String>, } @@ -187,7 +181,6 @@ mod tests { assert_eq!(j.findings.len(), 1); assert_eq!(j.findings[0].id, "F-01"); assert_eq!(j.findings[0].created_at, "2026-03-31T10:00:00Z"); - // add_finding also records a FindingRecorded entry assert_eq!(j.entries.len(), 1); } diff --git a/crates/ilold-solana-core/src/execute/fork.rs b/crates/ilold-solana-core/src/execute/fork.rs index 4fba91f..250ccdf 100644 --- a/crates/ilold-solana-core/src/execute/fork.rs +++ b/crates/ilold-solana-core/src/execute/fork.rs @@ -78,9 +78,6 @@ impl VmSnapshot { } } -/// Lightweight snapshot used between steps within the same VM. Skips the -/// `programs` blob (kept loaded in the VmHost) so each entry costs ~accounts -/// bytes instead of MBs. Restoring is done in-place via `restore_state`. #[derive(Clone)] pub struct StateSnapshot { pub accounts: Vec<(Address, AccountSharedData)>, @@ -102,17 +99,12 @@ impl VmHost { } } - /// Replace the in-memory account/clock state with `snap` while keeping - /// the loaded programs intact. Used by Back to rewind a single step - /// without paying the cost of re-adding programs. pub fn restore_state(&mut self, snap: StateSnapshot) -> Result<(), SolanaError> { let live: std::collections::HashSet<Address> = self.svm().accounts_db().inner.keys().copied().collect(); let snap_keys: std::collections::HashSet<Address> = snap.accounts.iter().map(|(k, _)| *k).collect(); - // Drop accounts created after the snapshot — set_account with an - // empty Account zeroes the slot which LiteSVM treats as absent. let to_drop: Vec<Address> = live.difference(&snap_keys).copied().collect(); for pk in to_drop { let empty: solana_account::Account = solana_account::Account::default(); diff --git a/crates/ilold-solana-core/src/exploration/add_step.rs b/crates/ilold-solana-core/src/exploration/add_step.rs index 9eec1be..85699dc 100644 --- a/crates/ilold-solana-core/src/exploration/add_step.rs +++ b/crates/ilold-solana-core/src/exploration/add_step.rs @@ -18,15 +18,6 @@ use crate::error::SolanaError; use crate::execute::{build_instruction, build_transaction, VmHost}; use crate::model::{InstructionDef, ProgramDef}; -/// Outcome of executing a Call against the VM. -/// -/// `step_index = Some(N)` means the call succeeded and was appended to -/// `session.steps[N]`. `step_index = None` means the VM rejected the call -/// (Anchor constraint, custom `require!`, runtime panic) — we deliberately -/// do NOT push a step in that case so the scenario timeline only contains -/// runs that actually mutated state. Mirrors how Solidity's `c <fn>` only -/// records valid entry points; the auditor still gets the full trace via -/// the `trace` field for inspection. pub struct StepOutcome { pub step_index: Option<usize>, pub trace: RuntimeTrace, @@ -60,10 +51,7 @@ pub fn add_solana_step( let tx = build_transaction(instruction.clone(), vm.payer(), extra_signers, blockhash)?; let account_keys: Vec<Address> = tx.message.static_account_keys().to_vec(); let result = vm.svm_mut().send_transaction(tx); - // LiteSVM does NOT rotate the blockhash automatically. Two Calls in the same - // session would collide (BlockhashNotFound) and the second silently fails - // with cu=0 / no state mutation. Expire after every send so the next Call - // gets a fresh blockhash. + // LiteSVM does not rotate the blockhash automatically; force expiry. vm.svm_mut().expire_blockhash(); let (runtime_trace, mutations) = match result { @@ -109,10 +97,6 @@ pub fn add_solana_step( } }; - // Failed Calls never reach the timeline: the auditor wanted to try the - // attack, the VM blocked it, and the canonical model is "session steps - // are real successful transactions". The full trace is still returned - // so the CLI can print logs / CU / error. if runtime_trace.error.is_some() { return Ok(StepOutcome { step_index: None, trace: runtime_trace }); } @@ -142,10 +126,6 @@ pub fn add_solana_step( } -// Maps the LiteSVM `inner_instructions` (Vec<Vec<InnerInstruction>>, one outer -// entry per top-level ix) into the trace shape consumed by the overlay. The -// `program_id_index` is into `tx.message.static_account_keys`, captured before -// `send_transaction` consumed the tx. fn project_inner_instructions( meta: &TransactionMetadata, account_keys: &[Address], @@ -157,10 +137,6 @@ fn project_inner_instructions( .get(ii.instruction.program_id_index as usize) .map(|k| k.to_string()) .unwrap_or_else(|| format!("idx:{}", ii.instruction.program_id_index)); - // Anchor instruction discriminators are the first 8 bytes; the - // first byte (or its bs58 head) is enough as a stable, legible - // disambiguator for the overlay aggregation key. Empty payload - // gets a placeholder so cpi_edges still aggregates by program. let instruction = if ii.instruction.data.is_empty() { "ix".to_string() } else { diff --git a/crates/ilold-solana-core/src/exploration/commands.rs b/crates/ilold-solana-core/src/exploration/commands.rs index 3d8a2a4..65994c2 100644 --- a/crates/ilold-solana-core/src/exploration/commands.rs +++ b/crates/ilold-solana-core/src/exploration/commands.rs @@ -69,14 +69,8 @@ pub enum SolanaCommand { ix: String, status: ReviewStatus, }, - /// Persist the active scenario store. Backwards compatible with the legacy - /// unit form `"SaveSession"` (no embedded keypairs). #[serde(alias = "SaveSession")] SaveSession { - /// SDD-03: when true, the resulting JSON also embeds the per-scenario - /// user keypairs in plaintext so a future LoadSession reproduces the - /// same pubkeys (and any PDAs derived from them). Default false keeps - /// the original "save the timeline shape" behaviour. #[serde(default)] with_keypairs: bool, }, @@ -100,19 +94,11 @@ pub enum SolanaCommand { Timeline { pubkey: String, }, - /// Detail of a single instruction — args (typed), accounts with badges, - /// PDAs with seeds, discriminator hex, admin-gated bool. Info { ix: String, }, - /// Pairs of instructions that share at least one writable account. Coupling, - /// Account-type catalogue (`vars` in the REPL): name + discriminator + - /// fields. Slice of `ProgramView::accounts`. Vars, - /// Aggregated runtime metrics (calls, failures, CU, CPI edges) over the - /// active scenario. Backend-only computation; clients consume the typed - /// `RuntimeOverlay` payload. Coverage, } @@ -160,18 +146,9 @@ pub enum SolanaCommandResult { logs_excerpt: Vec<String>, account_diffs_count: usize, compute_units: u64, - /// Always None for StepAdded — kept for backwards-compat with clients - /// that read this field. Failed Calls now produce `CallFailed` so the - /// scenario timeline only contains transactions that actually mutated - /// state (Solidity-aligned model). #[serde(default)] error: Option<String>, }, - /// The VM rejected the Call (Anchor constraint, custom `require!`, etc.). - /// No step is appended to the session and no canvas broadcast is emitted - /// — the scenario timeline stays clean. The CLI prints the error + logs - /// inline so the auditor sees exactly what happened, and they can record - /// the attempt manually with `note` or `finding` if it is worth keeping. CallFailed { instruction: String, logs_excerpt: Vec<String>, @@ -288,7 +265,6 @@ pub enum SolanaCommandResult { label: Option<String>, entries: Vec<TimelineEntry>, }, - /// Detail for a single instruction, sliced from `ProgramView`. IxInfo { ix: IxView, admin_gated: bool, @@ -313,11 +289,8 @@ pub struct StepDiffSummary { pub name: Option<String>, pub lamports_delta: i128, pub data_changed: bool, - /// Anchor-decoded snapshot of the account before the Call ran. None when - /// the discriminator did not match a known type (e.g. system accounts). #[serde(default)] pub decoded_before: Option<Value>, - /// Anchor-decoded snapshot after the Call. Same caveat as `decoded_before`. #[serde(default)] pub decoded_after: Option<Value>, } @@ -337,13 +310,8 @@ pub struct WhoEntry { pub account_field: String, pub writable: bool, pub signer: bool, - /// Resolved Anchor account type (e.g. "Pool"). None for system / sysvar / - /// program / unknown accounts. Lets the renderer show "(as pool: Pool)". #[serde(default, skip_serializing_if = "Option::is_none")] pub account_type: Option<String>, - /// Args of the instruction this entry references. Useful when the auditor - /// landed on this entry via an AccountType or Field query and wants to see - /// what knobs each ix exposes. #[serde(default, skip_serializing_if = "Option::is_none")] pub ix_args: Option<Vec<ArgView>>, } @@ -394,9 +362,6 @@ pub fn canvas_patches_from_solana( compute_units, error, } => vec![ - // AddNode first, OverlayUpdate after — clients that paint badges - // off the canvas node need the node to exist before the delta - // arrives. CanvasPatch::AddNode { scenario: active_scenario.to_string(), function: instruction.clone(), diff --git a/crates/ilold-solana-core/src/exploration/execute.rs b/crates/ilold-solana-core/src/exploration/execute.rs index 7f2ebd1..208d3d1 100644 --- a/crates/ilold-solana-core/src/exploration/execute.rs +++ b/crates/ilold-solana-core/src/exploration/execute.rs @@ -256,9 +256,6 @@ pub fn execute_call( } }; - // Capture original inputs so LoadSession can replay this Call against a - // fresh VM. We serialize the user-name strings (not the resolved pubkeys) - // because user keypairs are recreated on Load — same name, same pubkey. let call_payload = serde_json::json!({ "ix": ix_name, "args": args.clone(), @@ -475,8 +472,6 @@ pub fn execute_finding( .collect(), ) }; - // Capture the index of the most recent step so the export can render - // "Step #N" alongside the affected function. None when no steps yet. let affected_step_index = if session.steps.is_empty() { None } else { @@ -652,11 +647,6 @@ where account_types: program.account_types.len(), }; - // Reuse the shared markdown renderer (header + metadata + program + - // methodology + severity matrix + findings detail). Only the per-scenario - // step listing stays here because step records belong to ExplorationStep, - // which is owned by ilold-session-core but printed with Solana semantics - // (compute units, error from runtime_trace). let journal_pairs: Vec<(&str, &ilold_session_core::journal::types::AuditJournal)> = scenarios.iter().map(|(n, s)| (*n, &s.journal)).collect(); let mut md = export_markdown_multi( @@ -666,7 +656,6 @@ where program.instructions.len(), ); - // Per-scenario step listing — Solana-specific (no Solidity counterpart). use std::fmt::Write; writeln!(md, "## Scenarios\n").unwrap(); writeln!(md, "**Active**: `{active}`\n").unwrap(); @@ -824,8 +813,6 @@ fn who_for_field( owner: &str, field: &crate::view::FieldView, ) -> SolanaCommandResult { - // Heuristic: without source-level analysis we list every ix that touches - // the owner account-type as writable. The renderer must surface this. let mut hits: Vec<WhoEntry> = Vec::new(); for ix in &view.instructions { for acc in &ix.accounts { @@ -866,8 +853,6 @@ fn find_field_owner( view: &crate::view::ProgramView, field_name: &str, ) -> Option<(String, crate::view::FieldView)> { - // Stable iteration order: AccountView vector preserves IDL order, which is - // the order ProgramDef::from_idl emitted. That is deterministic per-IDL. for acc in &view.accounts { if let Some(f) = acc.fields.iter().find(|f| f.name == field_name) { return Some((acc.name.clone(), f.clone())); @@ -897,8 +882,6 @@ pub fn execute_timeline( active_scenario: &str, users: &HashMap<String, Keypair>, ) -> SolanaCommandResult { - // The auditor types `tl alice` or `tl <pubkey>` interchangeably; normalise - // to the on-wire pubkey before walking the diffs. let resolved_label = users.get(raw_target).map(|_| raw_target.to_string()); let pubkey = match users.get(raw_target) { Some(kp) => kp.pubkey().to_string(), @@ -935,7 +918,6 @@ pub fn execute_timeline( .zip(d.get("after").and_then(|v| v.as_array())) .map(|(b, a)| b != a) .unwrap_or(false); - // Try to decode before/after using IDL discriminators. let decode = |bytes_v: Option<&Value>| -> Option<Value> { let arr = bytes_v.and_then(|v| v.as_array())?; let bytes: Vec<u8> = arr.iter().filter_map(|b| b.as_u64().map(|n| n as u8)).collect(); @@ -1026,7 +1008,6 @@ mod tests { names, vec!["add_rewards", "claim_rewards", "initialize_pool", "stake", "unstake"] ); - // Sorted alphabetically for snapshot stability. assert!(hits.iter().all(|w| w.account_type.as_deref() == Some("Pool"))); assert!(hits.iter().all(|w| w.ix_args.is_some())); let pool_fields = fields.expect("Pool fields populated"); @@ -1056,7 +1037,6 @@ mod tests { assert!(accs .iter() .any(|a| a.name == "user_stake" && a.account_type.as_deref() == Some("UserStake"))); - // The 'user' signer maps to no account type — must not crash, must be None. assert!(accs .iter() .any(|a| a.name == "user" && a.account_type.is_none() && a.signer)); @@ -1073,7 +1053,6 @@ mod tests { assert!(accounts.is_none()); let pool_fields = fields.expect("owner_fields present"); assert!(pool_fields.iter().any(|f| f.name == "total_staked")); - // All 5 ix that touch Pool as writable. let names: Vec<_> = hits.iter().map(|w| w.instruction.as_str()).collect(); assert_eq!( names, @@ -1093,11 +1072,6 @@ mod tests { #[test] fn who_field_returns_no_writers_when_account_name_does_not_map() { - // Edge case: lever declares the IDL account as `power` (snake) but the - // type is `PowerStatus` — snake_to_pascal("power") = "Power" ≠ - // "PowerStatus". Without source-level analysis we can't bridge that gap, - // so the heuristic must surface zero writers for `is_on` rather than - // guessing. This is a known limitation we surface honestly. let (target, hits, kind, owner, fields, ..) = unwrap_who(execute_who(&lever(), "is_on")); assert_eq!(target, "is_on"); diff --git a/crates/ilold-solana-core/src/overlay.rs b/crates/ilold-solana-core/src/overlay.rs index a919abd..e7dc78d 100644 --- a/crates/ilold-solana-core/src/overlay.rs +++ b/crates/ilold-solana-core/src/overlay.rs @@ -4,11 +4,6 @@ use ilold_session_core::exploration::session::ExplorationSession; use ilold_session_core::runtime_trace::RuntimeTrace; use serde::{Deserialize, Serialize}; -/// Extract the deduplicated, insertion-ordered list of CPI program IDs -/// invoked by a RuntimeTrace. Lifted out so both the overlay aggregator -/// and the WS broadcast site share one decoder for `inner_instructions`. -/// Order mirrors `inner_instructions` (first hit wins) so the resulting -/// list reflects the CPI call sequence the program actually emitted. pub fn extract_cpi_programs(trace: &RuntimeTrace) -> Vec<String> { let mut seen = std::collections::HashSet::new(); let mut out = Vec::new(); @@ -58,8 +53,6 @@ impl RuntimeOverlay { }; let mut cu_samples: BTreeMap<String, Vec<u64>> = BTreeMap::new(); - // Aggregation key for CPI edges: (from_ix, to_program, depth). Tracked - // separately so the final Vec<CpiEdge> can be deterministic-sorted. let mut cpi_counts: BTreeMap<(String, String, u32), u32> = BTreeMap::new(); for step in &session.steps { @@ -194,8 +187,6 @@ mod tests { fn from_session_reads_failed_calls_counter() { let mut session = empty_session(); session.steps.push(step_with_trace("stake", ok_trace(11_000))); - // Failed Calls never push a step (they go through record_failed_call - // in execute_call::CallFailed). Mirror that real flow here. session.record_failed_call("stake"); session.record_failed_call("unstake"); @@ -263,7 +254,6 @@ mod tests { let overlay = RuntimeOverlay::from_session(&session); assert_eq!(overlay.cpi_edges.len(), 3); - // Sorted by (from_ix, to_program, depth). let stake_sys = &overlay.cpi_edges[0]; assert_eq!(stake_sys.from_ix, "stake"); assert_eq!(stake_sys.to_program, "11111111111111111111111111111111"); diff --git a/crates/ilold-web/frontend/src/lib/stores/graph.svelte.ts b/crates/ilold-web/frontend/src/lib/stores/graph.svelte.ts index 5fca2be..75a2fcb 100644 --- a/crates/ilold-web/frontend/src/lib/stores/graph.svelte.ts +++ b/crates/ilold-web/frontend/src/lib/stores/graph.svelte.ts @@ -1,8 +1,6 @@ import type { Node, Edge } from '@xyflow/svelte'; import type { AccountKind, ArgView, FieldView } from '$lib/api/rest'; -// ── Node data types ───────────────────────────────────────── - export interface FunctionNodeData { [key: string]: unknown; _type: 'function'; @@ -10,24 +8,22 @@ export interface FunctionNodeData { is_external: boolean; contractName?: string; _dimmed?: boolean; - // Enrichment fields (from ContractDetail.functions) visibility?: string; mutability?: string; path_count?: number; modifiers?: string[]; - // Scenarios: composed session-step metadata _sessionStep?: true; - _scenario?: string; // the scenario that owns this rendered node - _scenariosPassingThrough?: string[]; // full set of scenarios whose path includes this node (inherited + own) - _activeScenario?: string; // current active scenario (for highlight/mute classes) - stepIndex?: number; // session-step index, used by right-click "Fork scenario here" + _scenario?: string; + _scenariosPassingThrough?: string[]; + _activeScenario?: string; + stepIndex?: number; } export interface BlockNodeData { [key: string]: unknown; _type: 'block'; label: string; - node_type: string; // "Entry" | "Return" | "Revert" | "Block" | "LoopCondition" + node_type: string; _parentFunc: string; statements?: string[]; _dimmed?: boolean; @@ -41,11 +37,7 @@ export interface SequenceNodeData { _seqParent: string; pathCount?: number; readOnly?: boolean; - /** Solidity visibility (Public/External/Internal/Private) — drives the - * ext/int/pub/priv badge on the seq-next card. */ visibility?: string; - /** Function modifier names — presence of any drives the 🔒 access-control - * badge. Same semantics as FunctionNode. */ modifiers?: string[]; _transition?: any; _chainTransitions?: any[]; @@ -105,17 +97,9 @@ export type GraphNodeData = | AccountNodeData | TraceNodeData; -// ── Reactive state ────────────────────────────────────────── -// SvelteFlow uses $bindable nodes/edges — the wrapper component -// binds directly to these arrays via getNodes()/getEdges() and -// setNodes()/setEdges(). SvelteFlow mutates them internally for -// drag, selection, etc. - let nodes = $state<Node<GraphNodeData>[]>([]); let edges = $state<Edge[]>([]); -// ── Getters ───────────────────────────────────────────────── - export function getNodes(): Node<GraphNodeData>[] { return nodes; } @@ -124,8 +108,6 @@ export function getEdges(): Edge[] { return edges; } -// ── Mutations ─────────────────────────────────────────────── - export function setNodes(newNodes: Node<GraphNodeData>[]) { nodes = newNodes; } @@ -178,8 +160,6 @@ export function clearGraph() { edges = []; } -// ── Queries ───────────────────────────────────────────────── - export function findNode(id: string): Node<GraphNodeData> | undefined { return nodes.find((n) => n.id === id); } diff --git a/crates/ilold-web/frontend/src/lib/stores/palette.svelte.ts b/crates/ilold-web/frontend/src/lib/stores/palette.svelte.ts index 0390da5..10ccdc1 100644 --- a/crates/ilold-web/frontend/src/lib/stores/palette.svelte.ts +++ b/crates/ilold-web/frontend/src/lib/stores/palette.svelte.ts @@ -1,7 +1,3 @@ -// Command-palette state. Kept in a tiny store so `+layout.svelte` can own -// the <CommandPalette /> instance (single mount, always-live Cmd+K binding) -// while each route publishes its context-specific commands here. - import type { Command } from '$lib/commands/registry'; let _open = $state(false); @@ -14,10 +10,6 @@ export function togglePalette() { _open = !_open; } export function getPaletteCommands(): Command[] { return _commands; } -/** Replace the full command list. Routes typically call this inside an - * `$effect` so the list stays in sync with the reactive state it derives - * from (active scenario, loaded contract, etc.). */ export function setPaletteCommands(cmds: Command[]) { _commands = cmds; } -/** Convenience for unmounting routes: clear everything. */ export function clearPaletteCommands() { _commands = []; } diff --git a/crates/ilold-web/frontend/src/lib/stores/runtimeOverlay.svelte.ts b/crates/ilold-web/frontend/src/lib/stores/runtimeOverlay.svelte.ts index e775c5c..9fb4e9f 100644 --- a/crates/ilold-web/frontend/src/lib/stores/runtimeOverlay.svelte.ts +++ b/crates/ilold-web/frontend/src/lib/stores/runtimeOverlay.svelte.ts @@ -7,8 +7,6 @@ let callsPerIx = $state<Record<string, number>>({}); let failedPerIx = $state<Record<string, number>>({}); let cuStatsPerIx = $state<Record<string, CuStats>>({}); let cpiEdges = $state<CpiEdge[]>([]); -// Tracks whether the initial REST snapshot has landed; protects against WS -// patches arriving before scenario context is known and corrupting state. let initialized = $state<boolean>(false); export function getCallsPerIx(): Record<string, number> { @@ -83,9 +81,6 @@ function recomputeStats(prev: CuStats | undefined, sample: number): CuStats { } export function applyOverlayUpdate(patch: SessionOverlayUpdate): void { - // Drop patches that arrive before the initial REST snapshot. Without this - // guard, a WS event landing during page load would seed the store under - // scenario === '' and the next snapshot would silently overwrite it. if (!initialized) return; if (scenario && patch.scenario !== scenario) return; @@ -118,9 +113,6 @@ export function applyOverlayUpdate(patch: SessionOverlayUpdate): void { map.set(`${e.from_ix}|${e.to_program}|${e.depth}`, { ...e }); } for (const to of patch.cpi_targets_added) { - // Depth = 1 by default for incremental adds; the REST snapshot preserves - // the precise depth captured in the trace. Avoiding depth here keeps the - // wire-format minimal — accurate aggregation lands on the next reload. const key = `${ix}|${to}|1`; const prev = map.get(key); if (prev) { diff --git a/crates/ilold-web/frontend/src/lib/stores/search.svelte.ts b/crates/ilold-web/frontend/src/lib/stores/search.svelte.ts index 6149bf0..6e81be1 100644 --- a/crates/ilold-web/frontend/src/lib/stores/search.svelte.ts +++ b/crates/ilold-web/frontend/src/lib/stores/search.svelte.ts @@ -1,9 +1,3 @@ -// Navigation channel used by the Cmd+K palette. When the palette runs a -// path-search result, it writes to _searchNavigate; the contract page's -// $effect picks it up and focuses the target function + path. The -// "search open" / toggle state moved to `$lib/stores/palette.svelte` with -// the rest of the command-palette plumbing. - import type { SearchNavigatePayload } from '$lib/api/types'; let _searchContext = $state<string | null>(null); diff --git a/crates/ilold-web/frontend/src/lib/stores/session.svelte.ts b/crates/ilold-web/frontend/src/lib/stores/session.svelte.ts index fc41451..e7ad719 100644 --- a/crates/ilold-web/frontend/src/lib/stores/session.svelte.ts +++ b/crates/ilold-web/frontend/src/lib/stores/session.svelte.ts @@ -15,18 +15,9 @@ import type { ForkOrigin, } from '$lib/api/types'; -// ── Reactive state (Svelte 5 $state runes) ────────────────────────────────── -// -// Svelte 5 Maps: mutations (set/delete) do NOT trigger reactivity. We reassign -// with `new Map(scenarios)` after each mutation so downstream $derived/$effect -// recomputes. `activeScenario` and `highlightedFunction` are plain scalars. - let scenarios = $state<Map<string, SessionStep[]>>(new Map([['main', []]])); let activeScenario = $state<string>('main'); let highlightedFunction = $state<string | null>(null); -// Fork origin per scenario (backend-authoritative, populated by resync). -// Main has no entry — only forked scenarios appear here. Used by the canvas -// to render forks as branches emerging from their origin node. let forkOrigins = $state<Map<string, ForkOrigin>>(new Map()); function resetState() { @@ -36,12 +27,6 @@ function resetState() { forkOrigins = new Map(); } -// ── WebSocket subscriptions (created once on module import) ───────────────── - -// session_* events carry a `scenario` field (design §4.1). We route each -// mutation to the matching map entry; if the scenario is unknown we create -// it on the fly — resync() will reconcile on reconnect. - subscribe('session_add_node', (msg: SessionAddNode) => { const next = new Map(scenarios); const existing = next.get(msg.scenario) ?? []; @@ -61,19 +46,15 @@ subscribe('session_remove_node', (msg: SessionRemoveNode) => { }); subscribe('session_clear', (msg: SessionClear) => { - // Scoped clear (design §10.2) — only the target scenario is emptied. const next = new Map(scenarios); next.set(msg.scenario, []); scenarios = next; }); subscribe('session_highlight', (msg: SessionHighlight) => { - // highlightedFunction is global in frontend v1; scenario field is ignored. highlightedFunction = msg.function; }); -// ── Scenario lifecycle events ─────────────────────────────────────────────── - subscribe('scenario_created', (msg: ScenarioCreated) => { const next = new Map(scenarios); next.set(msg.name, []); @@ -88,9 +69,6 @@ subscribe('scenario_deleted', (msg: ScenarioDeleted) => { const next = new Map(scenarios); next.delete(msg.name); scenarios = next; - // Drop the fork-origin entry for the deleted scenario. Forks that pointed - // TO this scenario keep their origin; the canvas renders them as standalone - // when the referenced origin no longer exists. if (forkOrigins.has(msg.name)) { const nextOrigins = new Map(forkOrigins); nextOrigins.delete(msg.name); @@ -99,15 +77,10 @@ subscribe('scenario_deleted', (msg: ScenarioDeleted) => { }); subscribe('scenario_forked', (_msg: ScenarioForked) => { - // Computing the fork locally would require deep-cloning the source steps - // at at_step; cheaper and safer to resync from backend. resync(); }); subscribe('scenario_store_reloaded', (_msg: ScenarioStoreReloaded) => { - // LoadSession replaced the entire backend store. Pull the new snapshot - // (scenarios + forkOrigins + active) instead of reconstructing from the - // event payload — same rationale as scenario_forked. resync(); }); @@ -117,21 +90,17 @@ subscribe('connection', (event: ConnectionEvent) => { } }); -// ── REST re-sync ──────────────────────────────────────────────────────────── - let resyncGen = 0; async function resync(): Promise<void> { const gen = ++resyncGen; try { const response = await getAllScenarios(); - if (gen !== resyncGen) return; // stale — a newer resync superseded this + if (gen !== resyncGen) return; const newMap = new Map<string, SessionStep[]>(); const newOrigins = new Map<string, ForkOrigin>(); for (const snapshot of response.scenarios) { - // SessionStepView has identical shape to SessionStep — copy fields - // explicitly so TS infers the narrowed type and future drift breaks here. newMap.set( snapshot.name, snapshot.steps.map((s) => ({ @@ -147,9 +116,6 @@ async function resync(): Promise<void> { }); } } - // Guarantee 'main' is always present — the store invariant on backend - // (ScenarioStore::DEFAULT) is "main exists"; if the response is somehow - // empty, reseed so UI never renders a blank scenario list. if (newMap.size === 0) newMap.set('main', []); scenarios = newMap; @@ -161,8 +127,6 @@ async function resync(): Promise<void> { } } -// ── Public API ────────────────────────────────────────────────────────────── - export function getScenarios(): Map<string, SessionStep[]> { return scenarios; } @@ -179,8 +143,6 @@ export function getScenarioSteps(name: string): SessionStep[] { return scenarios.get(name) ?? []; } -// Back-compat: returns active scenario's steps. Existing consumers -// (SessionTimeline, StatePanel, contract page) keep working unchanged. export function getSteps(): SessionStep[] { return getScenarioSteps(activeScenario); } diff --git a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte index 4e9520f..75d8f89 100644 --- a/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte +++ b/crates/ilold-web/frontend/src/routes/contract/[name]/+page.svelte @@ -66,20 +66,13 @@ let selectedPath: any = $state(null); let funcPaths: Record<string, any> = $state({}); let expandedFuncs: Set<string> = $state(new Set()); - // Driven by SvelteFlow's onselectionchange — used only for the status bar - // selection chip. Kept as a plain count (not the full node list) since no - // other consumer needs per-node selection data at this level. let selectionCount: number = $state(0); - // Default: Seq mode is the auditor-friendly view; Session mode flips the - // sidebar click into "add step" and hides exploration nodes on the canvas - // so only the scenarios tree is visible. let mode: 'cfg' | 'sequences' | 'session' = $state('sequences'); let seqTree: any = $state(null); let seqAnalysis: SequenceAnalysis | null = $state(null); let seqExpanded: Map<string, boolean> = $state(new Map()); let seqDirection: 'TB' | 'LR' = $state('TB'); - // Context menu: right-click on nodes let contextMenu: { x: number; y: number; @@ -89,18 +82,12 @@ sessionStep?: { stepIndex: number }; } | null = $state(null); - let canvasFuncs: Set<string> = $state(new Set()); // functions currently on canvas + let canvasFuncs: Set<string> = $state(new Set()); - // Project map (all contracts in the workspace) — fetched once on mount so - // the Cmd+K palette can offer cross-contract navigation without every - // keystroke hitting the REST endpoint. let projectMap: MapContract[] = $state([]); - // Inline source-viewer panel state. Null when closed. Set by the - // ContextMenu "View source" entry. let sourcePanel: { func: string } | null = $state(null); - // Session → canvas auto-paint state let sessionVisCount = $state(0); const sessionHighlight = $derived(getHighlightedFunction()); @@ -131,28 +118,16 @@ return null; } - // BFS tree layout constants for seq subtrees (shared by relayoutSeqTree) const SEQ_NODE_W = 220; const SEQ_NODE_H = 80; - const SEQ_SIBLING_GAP = 30; // gap between siblings at the same level - const SEQ_LEVEL_GAP = 120; // gap between parent and children rank - - /** - * Re-layout a single seq subtree rooted at `rootId` (a function node). - * - Anchors the root to its live (drag-aware) position so user drags are preserved. - * - BFS from root via seq-edges; siblings are distributed perpendicular to seqDirection. - * - Updates positions of all seq-next nodes in the subtree. - * - Updates sourceHandle/targetHandle on all seq-edges in the subtree to match seqDirection. - * - * Callers MUST add any new nodes/edges to the store BEFORE invoking this helper, - * so the BFS walk includes them. Placeholder positions on new nodes are fine. - */ + const SEQ_SIBLING_GAP = 30; + const SEQ_LEVEL_GAP = 120; + function relayoutSeqTree(rootId: string) { const root = findNode(rootId); if (!root) return; const rootPos = liveNodePosition(rootId) ?? root.position; - // 1. Collect the full seq subtree (root + all transitively-linked seq-next nodes) const subtreeIds = new Set<string>([rootId]); let added = true; while (added) { @@ -168,7 +143,6 @@ } } - // 2. Build children index from seq-edges restricted to the subtree const childrenMap = new Map<string, string[]>(); const subtreeEdgeIds = new Set<string>(); for (const e of getEdges()) { @@ -180,7 +154,6 @@ } } - // 3. BFS from root, assigning levels const levels = new Map<string, number>(); levels.set(rootId, 0); const queue = [rootId]; @@ -197,7 +170,6 @@ } } - // 4. Group nodes by level and compute positions anchored at rootPos const byLevel: string[][] = Array.from({ length: maxLevel + 1 }, () => []); for (const [id, lvl] of levels) byLevel[lvl].push(id); @@ -207,7 +179,6 @@ const ids = byLevel[lvl]; const count = ids.length; if (isLR) { - // Children to the right, stacked vertically at same X const totalH = count * SEQ_NODE_H + (count - 1) * SEQ_SIBLING_GAP; const startY = rootPos.y + SEQ_NODE_H / 2 - totalH / 2; const x = rootPos.x + lvl * (SEQ_NODE_W + SEQ_LEVEL_GAP); @@ -215,7 +186,6 @@ posMap.set(id, { x, y: startY + i * (SEQ_NODE_H + SEQ_SIBLING_GAP) }); }); } else { - // Children below, in a horizontal row at same Y const totalW = count * SEQ_NODE_W + (count - 1) * SEQ_SIBLING_GAP; const startX = rootPos.x + SEQ_NODE_W / 2 - totalW / 2; const y = rootPos.y + lvl * (SEQ_NODE_H + SEQ_LEVEL_GAP); @@ -225,7 +195,6 @@ } } - // 5. Apply positions to seq-next nodes in this subtree setNodes(getNodes().map(n => { if (n.data._type === 'seq-next' && posMap.has(n.id)) { return { ...n, position: posMap.get(n.id)! }; @@ -233,7 +202,6 @@ return n; })); - // 6. Update handle orientation on seq-edges in this subtree to match seqDirection const sh = isLR ? 'r' : 'b'; const th = isLR ? 'l' : 't'; setEdges(getEdges().map(e => { @@ -244,9 +212,7 @@ })); } - /** Re-layout and re-orient all expanded seq subtrees (used when seqDirection changes) */ function reorientAllSeqSubtrees() { - // Find all root functions that have seq-next children const roots = new Set<string>(); for (const n of getNodes()) { if (n.data._type === 'seq-next') { @@ -259,15 +225,12 @@ } } - /** Merge an opacity value into an edge's style string */ function edgeStyle(base: string | undefined, opacity: number): string { - // Remove existing opacity from base style, then append new one const cleaned = (base ?? '').replace(/opacity:\s*[\d.]+;?/g, '').trim(); const sep = cleaned && !cleaned.endsWith(';') ? '; ' : ' '; return `${cleaned}${cleaned ? sep : ''}opacity: ${opacity}`.trim(); } - /** Reset all _dimmed state on nodes and edges */ function resetAllDimmed() { setNodes(getNodes().map(n => { if ('_dimmed' in n.data && n.data._dimmed) { @@ -283,7 +246,6 @@ })); } - /** Remove every seq-next descendant of a seq node. */ function collapseAllDescendants(nodeId: string) { const allDesc = findDescendants(nodeId); const toRemove = new Set<string>(); @@ -402,7 +364,6 @@ targetHandle: 't', }; } - // Unconditional / fallback return { color: 'var(--color-text-dim)', animated: false, @@ -411,18 +372,7 @@ }; } - // ── Session → canvas auto-paint (Phase S5) ────────────────── - // The session store owns an `activeScenario` + Map<name, steps[]>. This - // effect composes ALL scenarios into a unified tree (shared prefix + - // divergent tails) via `composeScenarioTree`, then syncs the canvas. - // - // Strategy: on every run, remove all `session:*` step nodes/edges and - // re-emit from the composed tree. Cheap (nodes are tiny) and avoids a - // fragile per-id diff. `activeScenario` is read so restyling (pill colors, - // active glow vs muted) re-runs when the user switches scenarios even if - // the tree shape is identical. $effect(() => { - // Reactive reads — trigger re-run on scenario changes + active-scenario flip. const scenarios = getScenarios(); const forkOrigins = getForkOrigins(); const active = getActiveScenario(); @@ -435,10 +385,6 @@ const tree = composeScenarioTree(scenarios, forkOrigins); - // Graph-store reads/writes are wrapped in untrack() to prevent a reactive - // cycle: reading getNodes() would subscribe this effect to `nodes`, and - // the subsequent removeNodesById/addNodes/addEdges would re-trigger it - // (infinite loop that froze the canvas on every c <func>). untrack(() => { const toRemove = new Set<string>(); for (const n of getNodes()) { @@ -453,11 +399,6 @@ const allFuncs = [...(contract?.functions ?? []), ...(contract?.inherited_functions ?? [])]; - // Lane-per-scenario tree. Each scenario renders only its divergent - // tail (`steps[at_step..end]`) on its own horizontal lane; the - // inherited prefix is reused from the origin's lane. A fork edge - // connects origin:step:{at_step-1} → self:step:{at_step} so branches - // visibly emerge from their fork point. const SESSION_BASE_X = 200; const SESSION_BASE_Y = 300; const SESSION_STEP_WIDTH = 280; @@ -527,9 +468,6 @@ }); }); - // Mode visibility filter: in session mode show only session nodes/edges; in - // any other mode hide them. Nodes are kept in the graph so switching back - // restores them without re-painting. $effect(() => { const currentMode = mode; untrack(() => { @@ -546,14 +484,6 @@ }); }); - // Invalidate stale NodeInspector selection. Any flow that removes - // nodes (CFG collapse, removeFuncFromCanvas, removeSeqNode, DEL key, - // Clear from the sidebar) converges here — callers don't need to - // remember to null out selectedNode themselves. The read inside - // findNode creates a reactive dependency so the guard re-runs whenever - // the graph store mutates. Safe against loops: when we set - // selectedNode = null the effect re-runs, short-circuits on the null - // check, and exits without further writes. $effect(() => { if (!selectedNode) return; if (!findNode(selectedNode.id)) { @@ -562,7 +492,6 @@ } }); - // Highlight the function node when the session broadcasts session_highlight $effect(() => { const funcName = sessionHighlight; if (!funcName) return; @@ -617,8 +546,6 @@ }, }); solanaCanvasIxs = new Set([...solanaCanvasIxs, ixName]); - // Paint CPI edges that the overlay already knows about for this ix — - // happens when the auditor added an ix node after Calls had already run. paintCpiEdges(); if (flowApi) flowApi.fitView({ nodes: [{ id: `ix:${ixName}` }], padding: 0.5, duration: 400 }); } @@ -633,8 +560,6 @@ if (data?._type === 'account' && data.parentInstruction === ixName) ids.add(n.id); } removeNodesById(ids); - // Drop CPI edges that started at this ix; placeholder externals that lose - // their last incoming cpi edge are pruned by orphanCleanup below. const edgePrefix = `cpi:${ixName}->`; const filtered = getEdges().filter((e) => !e.id.startsWith(edgePrefix)); if (filtered.length !== getEdges().length) setEdges(filtered); @@ -741,9 +666,6 @@ solanaTraceCount = tree.nodes.length; - // Drop runtime entries whose trace node no longer exists (after Clear, - // Back, scenario delete) so a re-execute of the same step doesn't show - // stale CU/logs from a previous run. const liveKeys = new Set(tree.nodes.map((n) => `${n._scenario}:${n.stepIndex}`)); let mutated = false; const next = new Map(solanaRuntimeByStep); @@ -951,10 +873,6 @@ const unsub = subscribeWs('solana_users_changed', () => { if (solanaProgram) refreshSolanaUsers(); }); - // Fill solanaRuntimeByStep from the broadcast so calls executed in another - // terminal (CLI, second browser) show CU/diffs/logs in this canvas instead - // of "0 CU 0 diffs". Locally-issued calls also flow through here, harmless - // because handleSolanaSubmit already wrote the same data. const unsubAdd = subscribeWs('session_add_node', (msg) => { const runtime = (msg as any).runtime; if (!runtime) return; @@ -1028,10 +946,6 @@ const scenario = getActiveScenario() ?? 'main'; const runtimeKey = `${scenario}:${sa.step_index}`; const next = new Map(solanaRuntimeByStep); - // SDD T-R47: StepAdded now carries the structured error from the VM - // (None when the Call succeeded). Falls back to scanning the logs for - // the historical AnchorError / failed: / panicked markers when the - // field is absent — covers older saves replayed in this session. const explicitError: string | null = (sa.error as string | null | undefined) ?? null; const logs: string[] = sa.logs_excerpt ?? []; const inferredError = logs.find((l) => @@ -1048,11 +962,6 @@ await refreshSolanaUsers(); } - // Guard against navigation race: an async onMount that awaits multiple - // network calls can complete AFTER the user navigates to a different - // contract, overwriting the new contract's state with the old one's - // data. mountToken is captured before the first await; if the user - // navigates we bump cancel via onDestroy and the guard short-circuits. let mountCancelled = $state(false); onDestroy(() => { mountCancelled = true; @@ -1065,9 +974,6 @@ if (!contractName) return; const expected = contractName; const stillFresh = () => !mountCancelled && page.params.name === expected; - // Graph store is global — stale nodes from a previous contract must be - // wiped or they'd pollute this contract's canvas (and leave the sidebar - // out of sync because local Sets re-init empty on re-mount). clearGraph(); canvasFuncs = new Set(); expandedFuncs = new Set(); @@ -1105,7 +1011,6 @@ const callgraphData = await getCallGraph(contractName); if (!stillFresh()) return; callgraphRaw = callgraphData; - // Sequences/analysis are Solidity-only; expected to 400 for Solana. try { const tree = await getSequences(contractName); if (stillFresh()) seqTree = tree; @@ -1123,13 +1028,6 @@ } }); - // Listen for search result navigation. Only `getSearchNavigate()` is - // tracked — everything else is accessed via untrack so mutations inside - // the IIFE (canvasFuncs, funcPaths, expandedFuncs, edges via - // highlightPath) don't re-enter this effect and trigger an - // effect_update_depth_exceeded loop. The effect re-runs exactly when - // the palette publishes a new navigation target; subsequent state - // writes are handled inside the async task. $effect(() => { const nav = getSearchNavigate(); if (!nav) return; @@ -1177,10 +1075,6 @@ }); }); - // Sidebar click dispatcher. In Session mode, the sidebar is the entry - // point for building a scenario — clicking a function fires a Call - // command and the WS session_add_node event repaints the scenario tree. - // In CFG/Seq modes, clicking adds the function as an exploration node. function notifyFailure(label: string, e: unknown) { const reason = e instanceof Error ? e.message : String(e); console.warn(`${label} failed:`, e); @@ -1208,7 +1102,6 @@ const nodeData = callgraphRaw.nodes.find(n => n.data.label === funcName); if (!nodeData) return; - // Look up enrichment data from ContractDetail const allFuncs = [...(contract?.functions ?? []), ...(contract?.inherited_functions ?? [])]; const funcDetail = allFuncs.find((f: any) => f.name === funcName); @@ -1234,7 +1127,6 @@ }, } as Node<GraphNodeData>); - // Add call edges where BOTH source and target are on canvas for (const e of callgraphRaw.edges) { const srcOnCanvas = canvasFuncs.has( callgraphRaw.nodes.find(n => n.data.id === e.data.source)?.data.label ?? '' @@ -1291,18 +1183,15 @@ const toRemove = new Set<string>([nodeId]); - // CFG children (blocks with _parentFunc === funcName) for (const n of getNodes()) { if ('_parentFunc' in n.data && n.data._parentFunc === funcName) { toRemove.add(n.id); } } - // Seq descendants (recursive via _seqParent) const seqDesc = findDescendants(nodeId); for (const id of seqDesc) toRemove.add(id); - // Also find seq nodes whose _seqParent starts with nodeId→ for (const n of getNodes()) { if ('_seqParent' in n.data) { const sp = n.data._seqParent as string; @@ -1326,10 +1215,6 @@ seqExpanded = new Map(seqExpanded); } - // Keyboard-driven delete from the canvas (Figma/Excalidraw pattern). - // Dispatches each selected node to the right existing helper so all the - // store bookkeeping (canvasFuncs, expandedFuncs, seqExpanded, dim state) - // stays in one place. Session mode is guarded at the canvas prop level. function handleNodesDelete(nodes: Node<GraphNodeData>[]) { if (mode === 'session') return; const funcsToRemove = new Set<string>(); @@ -1350,14 +1235,12 @@ selectedNode = null; } - // --- Event handlers --- async function handleNodeTap(node: Node<GraphNodeData>) { const data = node.data; if (!selectedNode || selectedNode.id !== node.id) { selectedPath = null; - // Reset CFG block highlighting when clicking a different node setNodes(getNodes().map(n => { if (n.data._type === 'block' && '_dimmed' in n.data && n.data._dimmed) { return { ...n, data: { ...n.data, _dimmed: false } as GraphNodeData }; @@ -1389,9 +1272,6 @@ resetAllDimmed(); } - // Right-click "⎇ Fork scenario here": forks the active scenario, keeping - // steps [0..=stepIndex] (i.e. truncate at stepIndex + 1). Surfaces backend - // errors via console.warn — ScenarioStore enforces uniqueness of names. async function handleForkScenario(stepIndex: number) { contextMenu = null; const name = promptScenarioName(); @@ -1439,15 +1319,11 @@ } } - // Right-click "{} View source": open the inline CodeMirror panel. function handleViewSource(funcName: string) { contextMenu = null; sourcePanel = { func: funcName }; } - // Right-click "↗ Open in code": fetch the function's absolute path + line - // from the source endpoint and fire the `vscode://` deep link. If no IDE - // is registered the browser silently drops the request — no UI nag. async function handleOpenInIde(funcName: string) { contextMenu = null; const projectName = kind === 'solana' ? solanaProgram?.name : contract?.name; @@ -1462,10 +1338,6 @@ } } - // Right-click "✕ Remove from here": truncate the active scenario at - // `stepIndex` by firing N Back commands. N = current length - stepIndex. - // Using Back (which the backend already supports) avoids needing a new - // truncate command on the server. async function handleRemoveFromHere(stepIndex: number) { contextMenu = null; const active = getActiveScenario(); @@ -1484,10 +1356,6 @@ function handleContextMenu(event: MouseEvent, node: Node<GraphNodeData>) { const data = node.data; - // "Fork scenario here" is only meaningful when the active scenario's - // path passes through this node — either it owns the node or inherits - // it from an ancestor. `_scenariosPassingThrough` already encodes both - // cases so the check is a single Array.includes. let sessionStep: { stepIndex: number } | undefined; if ((data._type === 'function' || data._type === 'trace') && data._sessionStep === true) { const { _scenariosPassingThrough: scns, _activeScenario: active, stepIndex: idx } = data; @@ -1506,11 +1374,7 @@ } async function handleNodeClick(node: Node<GraphNodeData>, event?: MouseEvent) { - // Selection first (sync), then expand/collapse (async) handleNodeTap(node); - // In Session mode the canvas is read-only for exploration — clicks only - // select. Expansion would add CFG blocks / seq-next children that we - // deliberately hide in this mode. if (mode === 'session') return; const d = node.data; if (d._type === 'instruction' && mode === 'cfg') { @@ -1533,10 +1397,6 @@ } async function handleSeqNodeTap(funcName: string, nodeId: string, seqParent: string, event?: MouseEvent) { - // Plain click commits to one sibling path: collapse the auto-expanded - // sub-trees of all siblings at this level. Shift+click skips the - // collapse so the user can keep multiple branches open in parallel — - // matches the "Shift+click → add branch" hint shown in Legend. const keepSiblings = event?.shiftKey === true; if (seqParent && !keepSiblings) { const siblings = getNodes().filter( @@ -1562,7 +1422,6 @@ const parentId = anchorNodeId || `${contract.name}::${funcName}`; if (expandedFuncs.has(funcName)) { - // --- COLLAPSE --- const toRemove = new Set<string>(); for (const n of getNodes()) { if ('_parentFunc' in n.data && n.data._parentFunc === funcName) { @@ -1577,14 +1436,12 @@ return; } - // --- EXPAND --- if (!cfgCache[funcName]) { cfgCache[funcName] = await getCfg(contract.name, funcName); } const cfg = cfgCache[funcName]; const parentPos = liveNodePosition(parentId) ?? { x: 300, y: 200 }; - // 1. Build Svelte Flow nodes (initially at parent position for animation) const cfgNodes: Node<GraphNodeData>[] = cfg.nodes.map(n => ({ id: `cfg:${funcName}:${n.data.id}`, type: 'block', @@ -1598,7 +1455,6 @@ }, })); - // 2. Build edges with color-coded styles, arrows, and explicit handles const cfgEdges: Edge[] = cfg.edges.map((e, i) => { const es = cfgEdgeStyle(e.data.kind); return { @@ -1622,7 +1478,6 @@ }; }); - // 3. Link edge: function node → CFG entry block const entryNode = cfg.nodes.find(n => n.data.node_type === 'Entry'); if (entryNode) { cfgEdges.push({ @@ -1638,12 +1493,10 @@ }); } - // 4. Run dagre on CFG subset to get positions const layoutNodes = runDagreLayout(cfgNodes, cfgEdges, { rankDir: 'TB', nodeSep: 40, rankSep: 60, nodeWidth: 180, }); - // 5. Offset all positions below the parent function node let minX = Infinity, minY = Infinity, maxX = -Infinity; for (const n of layoutNodes) { if (n.position.x < minX) minX = n.position.x; @@ -1659,7 +1512,6 @@ finalPositions.set(n.id, { x: n.position.x + offsetX, y: n.position.y + offsetY }); } - // Add nodes at their final dagre-computed positions (no animation, predictable) for (const n of cfgNodes) { const final = finalPositions.get(n.id); if (final) n.position = final; @@ -1667,7 +1519,6 @@ addNodes(cfgNodes); addEdges(cfgEdges); - // Dim function nodes + call edges dimFunctionLayer(parentId); expandedFuncs.add(funcName); @@ -1675,40 +1526,27 @@ } async function toggleSeqExpand(funcName: string, parentNodeId: string) { - // ── COLLAPSE ── if (seqExpanded.has(parentNodeId)) { collapseAllDescendants(parentNodeId); seqExpanded.delete(parentNodeId); seqExpanded = new Map(seqExpanded); - // If no seq-next nodes remain, un-dim everything const anySeq = getNodes().some(n => n.data._type === 'seq-next'); if (!anySeq) resetAllDimmed(); return; } - // ── EXPAND ── if (!seqTree || !seqTree.functions) return; - // Find the root function node for this seq subtree (walk up _seqParent chain) const rootFunc = findSeqRootFunction(parentNodeId); if (!rootFunc) return; const seqFunctions: Array<{ name: string; visibility: string; read_only: boolean; path_count: number }> = seqTree.functions; - // Show every contract function as a candidate next-step, matching the - // CLI `f` listing. The "interesting transition" signal (⚠ conditions - // badge + dashed border) is preserved automatically via the per-child - // `_transition` lookup below — no filtering needed here. const targets = seqFunctions; - // Reuse the same lookup the scenarios canvas uses to pull modifier/ - // mutability info that isn't on `SequenceFunction`. `contract` already - // holds the full function detail. const allFuncs = [...(contract?.functions ?? []), ...(contract?.inherited_functions ?? [])]; - // Build new seq-next children with placeholder positions — relayoutSeqTree - // will assign final positions from the shared BFS walk. const newNodes: Node<GraphNodeData>[] = []; const newEdges: Edge[] = []; for (const func of targets) { @@ -1750,8 +1588,6 @@ }); } - // Commit new nodes/edges to the store first, then let the shared helper - // re-run BFS over the whole subtree (root + existing + new) coherently. addNodes(newNodes); addEdges(newEdges); relayoutSeqTree(rootFunc.id); @@ -1795,19 +1631,16 @@ function highlightPath(funcName: string, path: any) { selectedPath = path; - // Build set of highlighted block IDs const highlightedIds = new Set<string>( path.nodes.map((n: any) => `cfg:${funcName}:b${n.block_id}`) ); - // Build set of highlighted edge pairs (consecutive path nodes) const highlightedEdgePairs = new Set<string>(); const blockIds = [...highlightedIds]; for (let i = 0; i < blockIds.length - 1; i++) { highlightedEdgePairs.add(`${blockIds[i]}→${blockIds[i + 1]}`); } - // Update nodes: dim all CFG blocks except highlighted ones setNodes(getNodes().map(n => { if (n.data._type === 'block' && n.data._parentFunc === funcName) { const dimmed = !highlightedIds.has(n.id); @@ -1816,7 +1649,6 @@ return n; })); - // Update edges: dim all CFG edges except path edges setEdges(getEdges().map(e => { if (e.data?._parentFunc === funcName && e.data?._type === 'cfg-edge') { const key = `${e.source}→${e.target}`; @@ -1827,11 +1659,6 @@ })); } - // ── Cmd+K palette: publish context commands ────────────────────────── - // Rebuilds whenever any input state changes (contract, scenarios, - // active scenario, mode, canvas state, project map). The palette store - // is global, so on route unmount we clear the list to avoid leaking - // stale handlers back to the next page. $effect(() => { if (kind === 'solana' && solanaProgram) { const prog = solanaProgram; @@ -1893,14 +1720,12 @@ const ctr = contract; const cmds: Command[] = []; - // Modes — always present. Icon hints the layout. cmds.push( { id: 'mode:cfg', label: 'Mode: CFG', category: 'Mode', icon: '⊟', keywords: ['cfg', 'control flow'], run: () => switchMode('cfg') }, { id: 'mode:sequences', label: 'Mode: Sequences', category: 'Mode', icon: '⇵', keywords: ['seq', 'calls'], run: () => switchMode('sequences') }, { id: 'mode:session', label: 'Mode: Session', category: 'Mode', icon: '⎇', keywords: ['scenario', 'session'], run: () => switchMode('session') }, ); - // Canvas / terminal actions. cmds.push( { id: 'canvas:center', label: 'Center canvas', category: 'Action', icon: '⊙', keywords: ['fit', 'zoom', 'reset view'], run: () => { flowApi?.fitView({ padding: 0.1 }); } }, { id: 'canvas:clear', label: 'Clear canvas', category: 'Action', icon: '✕', keywords: ['reset', 'wipe'], run: () => { @@ -1910,16 +1735,11 @@ { id: 'terminal:toggle', label: 'Toggle terminal', category: 'Action', icon: '>_', keywords: ['console', 'repl', 'pty'], run: () => toggleTerminal() }, ); - // Session controls — only meaningful while there is an active scenario - // with at least one step. We still expose them otherwise so users can - // discover the shortcut; the handlers already guard empty scenarios. cmds.push( { id: 'session:back', label: 'Back — remove last step', category: 'Action', icon: '↶', keywords: ['undo', 'step'], run: () => handleSessionBack() }, { id: 'session:clear', label: 'Clear scenario', category: 'Action', icon: '🗑', keywords: ['reset scenario'], run: () => handleSessionClear() }, ); - // Scenario lifecycle. "Switch to X" and "Delete X" per existing - // scenario; "New scenario" always available. cmds.push({ id: 'scenario:new', label: 'New scenario', @@ -1952,11 +1772,6 @@ } } - // Functions — own + inherited. Jump = add to canvas (Session mode - // turns it into an add-step, which matches the sidebar click). - // Solidity allows overloading by signature so names may repeat; we - // include the index in the id to keep every row uniquely keyed even - // when two rows end up with the same label. const allFuncs = [ ...(ctr.functions ?? []).map((f) => ({ name: f.name, source: 'own' as const })), ...(ctr.inherited_functions ?? []).map((f) => ({ name: f.name, source: 'inherited' as const })), @@ -1973,9 +1788,6 @@ }); }); - // Cross-contract navigation. Skip the current one and dedupe by name - // — ProjectMap may list the same interface twice (keyed each would - // throw on duplicate ids). const seenContracts = new Set<string>([ctr.name]); for (const c of projectMap) { if (seenContracts.has(c.name)) continue; @@ -1994,9 +1806,6 @@ setPaletteCommands(cmds); }); - // Clear published commands on unmount so the palette doesn't render - // stale handlers if the user lands on a page that doesn't publish its - // own list. onDestroy(() => { clearPaletteCommands(); }); diff --git a/crates/ilold-web/src/api/contract.rs b/crates/ilold-web/src/api/contract.rs index 0f14ee2..0aa2f2d 100644 --- a/crates/ilold-web/src/api/contract.rs +++ b/crates/ilold-web/src/api/contract.rs @@ -11,10 +11,6 @@ use ilold_core::sequence::types::SequenceTree; use crate::state::{require_solidity, AppState}; -// ============================================================================ -// Contract detail -// ============================================================================ - #[derive(Serialize)] pub struct ContractDetail { pub name: String, @@ -153,10 +149,6 @@ pub async fn get_contract( })) } -// ============================================================================ -// Call graph (Cytoscape-compatible JSON) -// ============================================================================ - #[derive(Serialize)] pub struct CytoscapeGraph { pub nodes: Vec<CytoscapeNode>, @@ -241,10 +233,6 @@ pub async fn get_callgraph( Ok(Json(CytoscapeGraph { nodes, edges })) } -// ============================================================================ -// CFG (Cytoscape-compatible JSON) -// ============================================================================ - pub async fn get_cfg( State(state): State<Arc<AppState>>, Path((contract_name, func_name)): Path<(String, String)>, @@ -300,19 +288,10 @@ pub async fn get_cfg( Ok(Json(CytoscapeGraph { nodes, edges })) } -// ============================================================================ -// Function source (for the canvas "View source" panel + IDE deep link) -// ============================================================================ - #[derive(Serialize)] pub struct FunctionSourceResponse { - /// Absolute path to the Solidity file — used as-is for the `vscode://` - /// deep link on the frontend. pub file_path: String, - /// Source text sliced to the function's line range (inclusive, 1-based). pub source: String, - /// Original span; frontend uses `start_line` / `start_col` for the IDE - /// jump, and nothing else today. pub span: SourceSpan, } @@ -325,9 +304,6 @@ pub async fn get_function_source( .find(|c| c.name == contract_name) .ok_or(StatusCode::NOT_FOUND)?; - // `resolve_function` walks the inheritance chain, so a function declared - // in a parent contract resolves to the parent's FunctionDef (with its - // own span + file_index). let (_owning, func) = s.project.resolve_function(contract, &func_name) .ok_or(StatusCode::NOT_FOUND)?; @@ -347,9 +323,6 @@ pub async fn get_function_source( })) } -/// Return the text between `start_1based` and `end_1based` lines (inclusive). -/// Returns an empty string for malformed ranges (`start > end`) so a bad -/// span from the parser never panics the handler. fn slice_lines(src: &str, start_1based: usize, end_1based: usize) -> String { if start_1based == 0 || end_1based < start_1based { return String::new(); @@ -361,10 +334,6 @@ fn slice_lines(src: &str, start_1based: usize, end_1based: usize) -> String { .join("\n") } -// ============================================================================ -// Path tree -// ============================================================================ - pub async fn get_paths( State(state): State<Arc<AppState>>, Path((contract_name, func_name)): Path<(String, String)>, @@ -388,10 +357,6 @@ pub async fn get_paths( .ok_or(StatusCode::NOT_FOUND) } -// ============================================================================ -// Sequence tree -// ============================================================================ - #[derive(Deserialize)] pub struct SequenceQuery { pub depth: Option<usize>, @@ -410,10 +375,6 @@ pub async fn get_sequences( .ok_or(StatusCode::NOT_FOUND) } -// ============================================================================ -// Sequence analysis — conditions between function transitions -// ============================================================================ - use ilold_core::sequence::analysis::{analyze_sequences, SequenceAnalysis}; pub async fn get_sequence_analysis( @@ -425,10 +386,6 @@ pub async fn get_sequence_analysis( Ok(Json(analysis)) } -// ============================================================================ -// Search suggestions — what's searchable in this contract -// ============================================================================ - #[derive(Serialize)] pub struct SearchSuggestions { pub functions: Vec<String>, @@ -475,7 +432,6 @@ pub async fn get_search_suggestions( .map(|e| e.name.clone()) .collect(); - // Collect unique origins (current contract + any ancestor with accessible functions) let mut origins: std::collections::HashSet<String> = std::collections::HashSet::new(); origins.insert(name.clone()); for af in &accessible { @@ -484,7 +440,6 @@ pub async fn get_search_suggestions( } } - // Collect unique external calls from all paths keyed by any origin let mut ext_calls = std::collections::HashSet::new(); for ((c, _), pt) in &s.path_trees { if !origins.contains(c) { continue; } @@ -528,10 +483,6 @@ pub async fn get_search_suggestions( })) } -// ============================================================================ -// Helpers -// ============================================================================ - use ilold_core::cfg::types::{BasicBlock, BlockKind, CfgStatement}; fn summarize_stmt(stmt: &CfgStatement) -> String { diff --git a/crates/ilold-web/src/api/project.rs b/crates/ilold-web/src/api/project.rs index ddce106..3673da0 100644 --- a/crates/ilold-web/src/api/project.rs +++ b/crates/ilold-web/src/api/project.rs @@ -66,10 +66,6 @@ pub async fn get_project( })) } -// ============================================================================ -// Project Map — full contract details with cross-contract relationships -// ============================================================================ - #[derive(Serialize)] pub struct ProjectMap { pub kind: &'static str, @@ -143,10 +139,6 @@ pub async fn get_program_view( Ok(Json(program.compute_view())) } -/// Resolve the per-scenario `pubkey -> user name` map. Lets the canvas show -/// "alice" instead of "Bxk7…" when a runtime field matches a known user. -/// Lives in its own endpoint (not on `RuntimeOverlay`) because users mutate -/// independently of the overlay; see design.md §"authority_resolutions". pub async fn get_user_labels( State(state): State<Arc<AppState>>, Path(scenario): Path<String>, @@ -311,13 +303,6 @@ fn build_solidity_map(state: &Arc<AppState>) -> ProjectMap { } } -// ============================================================================ -// Instruction source — Anchor handler body for the canvas "View source" panel -// and the IDE deep link. Mirrors `get_function_source` for Solidity. -// ============================================================================ - -/// Shape mirrors `FunctionSourceResponse` so the frontend can reuse the -/// `FunctionSourcePanel` component without a parallel type. #[derive(Serialize)] pub struct InstructionSourceResponse { pub file_path: String, @@ -330,8 +315,6 @@ pub async fn get_instruction_source( Path((name, ix)): Path<(String, String)>, ) -> Result<Json<InstructionSourceResponse>, (StatusCode, String)> { let program = find_solana_program(&state, &name)?; - // Reject early: 404 if the IDL has no such instruction. Avoids reading - // the file just to return the same error after parsing. if !program.instructions.iter().any(|i| i.name == ix) { return Err(( StatusCode::NOT_FOUND, @@ -372,18 +355,12 @@ pub async fn get_instruction_source( })) } -/// Parse `lib.rs` with syn and locate the `pub fn <ix>` inside the -/// `#[program] pub mod ...` module. Falls back to top-level fns and to a -/// recursive walk so handlers nested in unusual layouts still resolve. pub(crate) fn extract_handler_span(source: &str, ix: &str) -> Option<SourceSpan> { let file = syn::parse_file(source).ok()?; find_fn_in_items(&file.items, ix) } fn find_fn_in_items(items: &[syn::Item], ix: &str) -> Option<SourceSpan> { - // Prefer the `#[program]`-attributed module — that is where Anchor places - // the IDL-visible handlers, so we avoid colliding with helper fns named - // the same elsewhere in the file. for item in items { if let syn::Item::Mod(m) = item { if has_program_attr(&m.attrs) { @@ -450,7 +427,6 @@ mod tests { #[test] fn extract_handler_span_finds_stake() { let span = extract_handler_span(STAKING_LIB, "stake").expect("stake handler"); - // `pub fn stake(...)` is on line 19 in the fixture (1-based). assert_eq!(span.start_line, 19, "start line off: {span:?}"); assert!( span.end_line > span.start_line, diff --git a/crates/ilold-web/src/api/session.rs b/crates/ilold-web/src/api/session.rs index 367e9b2..289bdd0 100644 --- a/crates/ilold-web/src/api/session.rs +++ b/crates/ilold-web/src/api/session.rs @@ -90,9 +90,6 @@ fn timestamp_now() -> String { .unwrap_or_default() } -/// Validate a scenario name and check it is not already taken in the store. -/// Shared guard for both `ScenarioAction::New` and `ScenarioAction::Fork`. -/// Returns `Err(CommandResult::Error)` ready to be propagated. fn reserve_name(store: &ScenarioStore, name: &str) -> Result<(), CommandResult> { if let Err(e) = validate_scenario_name(name) { return Err(CommandResult::Error { message: e }); @@ -105,9 +102,6 @@ fn reserve_name(store: &ScenarioStore, name: &str) -> Result<(), CommandResult> Ok(()) } -/// Execute scenario lifecycle commands against the `ScenarioStore`. Lives -/// in the web crate because it needs `&mut ScenarioStore` (crate boundary); -/// `commands.rs` only defines the data types. fn execute_scenario( store: &mut ScenarioStore, action: ScenarioAction, @@ -139,8 +133,6 @@ fn execute_scenario( ScenarioAction::Switch { name } => { let from = store.active().to_string(); if name == from { - // idempotent no-op per spec S3.4; caller is responsible for - // suppressing WS broadcast when from == to. return CommandResult::ScenarioSwitched { from, to: name }; } match store.set_active(name.clone()) { @@ -182,14 +174,8 @@ fn fork_scenario( } let from = store.active().to_string(); let mut cloned = store.active_session().clone(); - // Failed Calls are scenario-local observations; the fresh fork has not - // rejected anything yet so the runtime overlay starts clean. cloned.reset_scenario_local_observations(); - // Resolve effective step count. None (legacy) → keep all steps. - // Some(N) → truncate to first N; error if N > current length. - // Mutations live inside each ExplorationStep, so truncating `steps` - // drops their owning step's mutations as well. let len = cloned.steps.len(); let effective = match at_step { None => len, @@ -207,16 +193,10 @@ fn fork_scenario( } }; - // Record the fork origin on the cloned session itself. The frontend - // reads this (via /api/scenarios/all) to render the scenario as a - // branch from its source instead of a parallel timeline. cloned.forked_from = Some(ForkOrigin { scenario: from.clone(), at_step: effective, }); - // The `BranchCreated` variant's field names (`from_function`/`branch_function`) - // are reused here as scenario names per design §2.4 — intentionally not - // renamed to preserve save-file compatibility. cloned.journal.record(JournalEntry::BranchCreated { from_function: from.clone(), branch_function: new_name.clone(), @@ -248,18 +228,10 @@ pub async fn handle_command( })?; let mut scenarios_guard = state.scenarios.write().unwrap(); - // SaveSession/LoadSession bypass the contract-switch reset: Save doesn't - // care about the request's contract field, and Load carries its own - // contract inside the JSON. Without the bypass, loading a file from a - // different contract would clear the store before the load runs. let is_persistence = matches!( solidity_command, SessionCommand::SaveSession | SessionCommand::LoadSession { .. } ); - // Contract switch: only reset if the active session has actual steps. - // An empty session with a mismatched contract means the auto-seed picked - // a default contract that didn't match the first real request — just - // swap it transparently (no ClearAll, nothing was ever on the canvas). let needs_reset = !is_persistence && scenarios_guard.active_session().contract != contract_name; if needs_reset { @@ -272,10 +244,6 @@ pub async fn handle_command( }).ok(); } } - // Scenario / Save / Load commands operate on the store itself; all - // other commands are delegated to the active session. Dispatch happens - // here (before `active_session_mut`) to avoid partial-move errors on - // `req.command`. let result: CommandResult = match solidity_command { SessionCommand::Scenario { sub } => { let active_before = scenarios_guard.active().to_string(); @@ -287,8 +255,6 @@ pub async fn handle_command( } SessionCommand::SaveSession => { let active_before = scenarios_guard.active().to_string(); - // Solidity flow does not persist keypairs (no LiteSVM users to - // reproduce); SDD-03 keypairs feature is Solana-only. let result = match scenarios_guard.save_to_json(crate::state::SaveOpts::none()) { Ok(json) => CommandResult::SessionSaved { json }, Err(message) => CommandResult::Error { message }, @@ -381,11 +347,6 @@ async fn handle_solana_command( if let SolanaCommand::SaveSession { with_keypairs } = &command { let scenarios = state.scenarios.read().unwrap(); - // When `with_keypairs` is set, snapshot the in-memory user keypairs - // (cloned via `insecure_clone` like the Fork path does — same - // semantics, no key derivation) and pass them to save_to_json so - // the resulting JSON can be replayed deterministically. Default - // remains the lighter "shape only" save. let users_snapshot = if *with_keypairs { let users_lock = solana.users.read().unwrap(); let cloned: std::collections::HashMap< @@ -430,16 +391,9 @@ async fn handle_solana_command( .collect(); *scenarios = loaded; - // Drop existing VMs/snapshot stacks; we rebuild from scratch. vms.clear(); snapshots.clear(); - // SDD-03: rehydrate the per-scenario users map from the saved - // keypair bundle BEFORE replay runs, so that signers and PDAs - // come back identical. When the bundle is absent (legacy save - // without `--with-keypairs`) the existing `Keypair::new` path - // inside replay still works — pubkeys regenerate, which is - // the long-standing behaviour. if let Some(bundle) = kp_bundle { users_lock.clear(); for (scn, kps) in bundle { @@ -447,12 +401,6 @@ async fn handle_solana_command( } } - // Rebuild a VM per scenario by replaying each step from its - // saved call_payload. Steps without payload (legacy saves) - // can't replay, so we still boot the VM but leave its state - // un-mutated; the timeline remains visible but inspect/state - // will reflect genesis. This is the only feasible compromise - // without breaking older snapshots. let mut replay_errors: Vec<String> = Vec::new(); let scenario_names: Vec<String> = scenarios.names().to_vec(); for scn_name in &scenario_names { @@ -468,8 +416,6 @@ async fn handle_solana_command( let scn_users = users_lock .entry(scn_name.clone()) .or_insert_with(std::collections::HashMap::new); - // Re-airdrop existing users into the freshly booted VM so - // replayed Calls can pay for `init` constraints. const REPLAY_LAMPORTS: u64 = 10_000_000_000; for kp in scn_users.values() { use solana_keypair::Signer; @@ -507,10 +453,6 @@ async fn handle_solana_command( }) .unwrap_or_default(); let pre = vm.snapshot_state(); - // We replay against the current session in scn_users. - // Calls that reference users not yet recreated will - // fail; we record the error but continue so the rest - // of the timeline still loads. let res = ilold_solana_core::exploration::execute::execute_call( &program, ix_name, @@ -524,10 +466,6 @@ async fn handle_solana_command( ); if matches!(res, ilold_solana_core::exploration::SolanaCommandResult::StepAdded { .. }) { stack.push(pre); - // execute_call appended a NEW step at the end. The - // replayed timeline now has duplicate entries, so - // we pop the freshly-added step and keep the saved - // one (preserves runtime_trace from original run). session.steps.pop(); } else if let ilold_solana_core::exploration::SolanaCommandResult::Error { message } = res { replay_errors.push(format!("{scn_name}#{idx}: {message}")); @@ -580,8 +518,6 @@ async fn handle_solana_command( .or_insert_with(Vec::new); let session = scenarios.active_session_mut(); - // Pre-Call snapshot: taken BEFORE dispatching so Back can rewind to here. - // Computed only for Call; everything else doesn't mutate VM state. let pre_call_snapshot = match &command { SolanaCommand::Call { .. } => Some(vm.snapshot_state()), _ => None, @@ -631,10 +567,6 @@ async fn handle_solana_command( r } SolanaCommand::Clear => { - // Rewind VM to pre-step-0 (the snapshot taken before the very first - // Call). Without this the VM keeps the post-execution state and the - // next Call operates on contaminated accounts. If the stack is empty - // (no Calls yet, or already rewound), Clear is a no-op for the VM. if let Some(genesis) = stack.first().cloned() { if let Err(e) = vm.restore_state(genesis) { return Ok(Json(serde_json::to_value(SolanaCommandResult::Error { @@ -658,9 +590,6 @@ async fn handle_solana_command( SolanaCommand::Step { index } => execute_step(session, index, &program), SolanaCommand::Findings => execute_findings_list(session), SolanaCommand::Export { metadata } => { - // Export aggregates findings and step lists across ALL scenarios so - // the auditor's deliverable reflects the full investigation, not - // just the currently-active branch. let names: Vec<String> = scenarios.names().to_vec(); let entries: Vec<(&str, &ilold_session_core::exploration::session::ExplorationSession)> = names @@ -682,8 +611,6 @@ async fn handle_solana_command( | SolanaCommand::Scenario { .. } => unreachable!("handled above"), }; - // Persist the pre-Call snapshot only when the Call actually appended a - // step. Failed Calls already left the VM untouched, so no rewind needed. if let (Some(snap), SolanaCommandResult::StepAdded { .. }) = (pre_call_snapshot, &result) { stack.push(snap); } @@ -784,8 +711,6 @@ fn solana_scenario_action( } let from = active_before.to_string(); let mut cloned = scenarios.active_session().clone(); - // Failed Calls are scenario-local observations: a fork hasn't - // rejected anything yet, so the runtime overlay starts clean. cloned.reset_scenario_local_observations(); let len = cloned.steps.len(); let effective = match at_step { @@ -831,12 +756,6 @@ fn solana_scenario_action( } }; - // Fork at a specific step requires rewinding the cloned VM to that - // step's pre-Call snapshot (otherwise VM has the full timeline's - // state but the cloned `steps` are truncated — auditor sees diverging - // state vs timeline). origin_stack[N] is the snapshot taken right - // before step N executed; restoring it produces the state where - // exactly steps [0..N) have run. let mut snapshots = solana.step_snapshots.write().unwrap(); let origin_stack = snapshots.entry(from.clone()).or_insert_with(Vec::new); let cloned_stack: Vec<ilold_solana_core::execute::StateSnapshot> = @@ -848,8 +767,6 @@ fn solana_scenario_action( }; } } - // If effective == origin_stack.len() the branch keeps the full state - // (no rewind needed) — typical "fork at HEAD" usage. let mut users = solana.users.write().unwrap(); let cloned_users: std::collections::HashMap<String, Keypair> = users @@ -911,9 +828,6 @@ pub async fn get_step_detail( Ok(Json(narrative)) } -/// Return the persisted FlowTree of a session step. The tree is read -/// directly from `step.flow_tree` — no recomputation against the source -/// — so the result reflects what the auditor saw when `c <func>` ran. pub async fn get_session_step_trace( State(state): State<Arc<AppState>>, Path(step_index): Path<usize>, @@ -937,7 +851,6 @@ pub async fn get_session_step_trace( Ok(Json(tree)) } -/// Cross-step variable history with path conditions for each write. pub async fn get_variable_timeline_handler( State(state): State<Arc<AppState>>, Path(variable): Path<String>, @@ -990,8 +903,6 @@ pub struct TraceQuery { pub depth: Option<usize>, #[serde(default)] pub reverts: Option<bool>, - /// Comma-separated step_ids to force-inline beyond `depth`. Example: - /// `?expand=17,24` will inline both calls regardless of max_depth. #[serde(default)] pub expand: Option<String>, } @@ -1016,16 +927,10 @@ pub async fn get_flow_trace( #[derive(Deserialize)] pub struct SliceQuery { - /// `backward`, `forward`, or `both`. Defaults to `both` when absent. - /// Short forms `b`/`f` and synonyms `back`/`fwd`/`all` are accepted. #[serde(default)] pub direction: Option<String>, } -/// Dataflow slice for `variable` inside `function` of the session's -/// current contract. The function is resolved from the active session so -/// the auditor doesn't have to re-type the contract name; if no session -/// exists the endpoint returns 404. pub async fn get_function_slice( State(state): State<Arc<AppState>>, Path((func_name, variable)): Path<(String, String)>, @@ -1078,10 +983,6 @@ fn parse_slice_direction(raw: Option<&str>) -> Result<SliceDirection, String> { } } -/// Lightweight per-step view for the `/api/scenarios/all` snapshot. The -/// access level is resolved against `AppState.classifications` (same lookup -/// pattern used by `execute_who` in the core crate) so the frontend can -/// colour the node without re-classifying. #[derive(Serialize)] pub struct SessionStepView { pub function: String, @@ -1089,8 +990,6 @@ pub struct SessionStepView { pub step_index: usize, } -/// Per-scenario snapshot in `AllScenariosResponse`. `forked_from` is `None` -/// for `main` and for any scenario loaded from a pre-fork-origin save file. #[derive(Serialize)] pub struct ScenarioSnapshot { pub name: String, @@ -1101,13 +1000,9 @@ pub struct ScenarioSnapshot { #[derive(Serialize)] pub struct AllScenariosResponse { pub active: String, - /// Ordered by creation order (main first, then insertion order). Not a - /// HashMap — the frontend composes scenarios into a visual tree and needs - /// stable iteration so "main" always anchors the canvas. pub scenarios: Vec<ScenarioSnapshot>, } -/// Return the list of scenarios — mirrors the `scenario list` CLI command. pub async fn get_scenarios( State(state): State<Arc<AppState>>, ) -> Json<Vec<ScenarioInfo>> { @@ -1125,8 +1020,6 @@ pub async fn get_scenarios( Json(items) } -/// Bulk snapshot of every scenario's step list. Used by the frontend canvas -/// to paint all scenarios at once (e.g. on reconnect / initial load). pub async fn get_all_scenarios( State(state): State<Arc<AppState>>, ) -> Result<Json<AllScenariosResponse>, (StatusCode, String)> { @@ -1162,9 +1055,6 @@ pub async fn get_all_scenarios( Ok(Json(AllScenariosResponse { active, scenarios })) } -/// Parse a comma-separated `expand` query value into a set of step_ids. -/// Empty input → empty set. Whitespace around values is tolerated. -/// Returns `Err` with a descriptive message if any value is not a usize. fn parse_expand_set(raw: Option<&str>) -> Result<std::collections::HashSet<usize>, String> { let mut set = std::collections::HashSet::new(); let raw = match raw { diff --git a/crates/ilold-web/src/state.rs b/crates/ilold-web/src/state.rs index c1352c6..4739699 100644 --- a/crates/ilold-web/src/state.rs +++ b/crates/ilold-web/src/state.rs @@ -28,12 +28,8 @@ use solana_keypair::Keypair; use serde::{Deserialize, Serialize}; -/// The default scenario name, auto-created for every fresh session. pub const DEFAULT_SCENARIO: &str = "main"; -/// Holds all scenarios for a contract. One is "active" at any time; commands -/// without explicit scenario targeting operate on it. Insertion order is -/// preserved via `order` for deterministic `names()` output. pub struct ScenarioStore { pub version: u32, pub contract: String, @@ -118,13 +114,6 @@ impl ScenarioStore { Ok(()) } - /// Serialize the entire store as v2 JSON (`{ version: 2, contract, active, - /// scenarios, order, [keypairs_present, keypairs] }`). When `opts.keypairs` - /// is `Some`, the file embeds the per-scenario user keypairs as 64-byte - /// arrays so a future load can rehydrate the same identities (PDAs and - /// signatures match across save/load). The boolean header - /// `keypairs_present` lets a reader detect a bundle with secrets without - /// parsing the body — see SDD-03 design.md threat-model section. pub fn save_to_json(&self, opts: SaveOpts<'_>) -> Result<String, String> { let (keypairs_present, keypairs) = match opts.keypairs { Some(map) => { @@ -154,10 +143,6 @@ impl ScenarioStore { serde_json::to_string_pretty(&file).map_err(|e| format!("Serialize failed: {e}")) } - /// Parse a save file. Tries v2 (`ScenarioStoreFile`) first; on failure - /// falls back to v1 (bare `ExplorationSession`) and wraps it as a single - /// `main` scenario. Any structural anomaly (active not in scenarios, - /// empty order) is repaired so the returned store is always valid. pub fn load_from_json(json: &str) -> Result<(Self, Option<KeypairBundle>), String> { match serde_json::from_str::<ScenarioStoreFile>(json) { Ok(file) => { @@ -194,7 +179,6 @@ impl ScenarioStore { if file.scenarios.is_empty() { return Err("Save file has no scenarios".into()); } - // Repair `order`: if missing names or empty, rebuild from scenarios. let mut order = file.order; order.retain(|n| file.scenarios.contains_key(n)); for name in file.scenarios.keys() { @@ -202,8 +186,6 @@ impl ScenarioStore { order.push(name.clone()); } } - // Repair `active`: fall back to first ordered name if the recorded - // active was deleted out-of-band before the save. let active = if file.scenarios.contains_key(&file.active) { file.active } else { @@ -222,13 +204,6 @@ impl ScenarioStore { } } -/// On-disk wire format for `ScenarioStore`. Decoupled from the in-memory -/// type to keep private fields private and allow the wire format to evolve -/// independently. v1 saves are bare `ExplorationSession` JSON — they're -/// detected by the failed parse + retry in `ScenarioStore::load_from_json`. -/// -/// SDD-03 added the optional `keypairs_present` / `keypairs` pair, both -/// `#[serde(default)]` so older v2 saves keep loading. #[derive(Serialize, Deserialize)] struct ScenarioStoreFile { version: u32, @@ -242,14 +217,8 @@ struct ScenarioStoreFile { keypairs: Option<HashMap<String, HashMap<String, Vec<u8>>>>, } -/// Bundle of per-scenario, per-user-name keypairs surfaced by -/// `ScenarioStore::load_from_json`. Solana's LoadSession dispatcher uses it -/// to rehydrate `solana.users` before the replay loop runs. pub type KeypairBundle = HashMap<String, HashMap<String, Keypair>>; -/// Options for `ScenarioStore::save_to_json`. Today only `keypairs` is -/// configurable; future work (encrypted bundle) will extend this struct -/// without breaking call sites. pub struct SaveOpts<'a> { pub keypairs: Option<&'a HashMap<String, HashMap<String, Keypair>>>, } @@ -298,10 +267,6 @@ pub struct SolanaState { pub program_artifacts: Vec<(Address, Vec<u8>)>, pub vms: RwLock<HashMap<String, VmHost>>, pub users: RwLock<HashMap<String, HashMap<String, Keypair>>>, - /// Per-scenario stack of pre-Call snapshots, indexed by step. Used by - /// `Back` to rewind the VM state and by `Fork(at_step=N)` to seed the - /// new scenario's VM with the N-th snapshot. Cleared on `Clear` and - /// truncated on `Back`. Empty after `LoadSession` (replay rebuilds it). pub step_snapshots: RwLock<HashMap<String, Vec<ilold_solana_core::execute::StateSnapshot>>>, } @@ -473,7 +438,6 @@ impl AppState { classifications.insert(contract.name.clone(), classify_all(contract)); } - // Compute transitive effects across contracts (inheritance-aware). analyze_project(&project, &mut sequence_analyses); let (session_tx, _) = broadcast::channel(64); diff --git a/crates/ilold-web/src/ws/pty.rs b/crates/ilold-web/src/ws/pty.rs index 604443f..2bdf396 100644 --- a/crates/ilold-web/src/ws/pty.rs +++ b/crates/ilold-web/src/ws/pty.rs @@ -89,7 +89,6 @@ async fn handle_pty_session(mut socket: WebSocket, port: u16, contract_path: Pat } }; - // PTY stdout -> mpsc channel (blocking read in dedicated thread) let (stdout_tx, mut stdout_rx) = mpsc::channel::<Vec<u8>>(64); tokio::task::spawn_blocking(move || { let mut reader = reader; @@ -106,7 +105,6 @@ async fn handle_pty_session(mut socket: WebSocket, port: u16, contract_path: Pat } }); - // PTY stdin writes happen in a blocking thread to avoid Send issues let (stdin_tx, stdin_rx) = mpsc::channel::<Vec<u8>>(64); tokio::task::spawn_blocking(move || { let mut writer = writer; @@ -118,7 +116,6 @@ async fn handle_pty_session(mut socket: WebSocket, port: u16, contract_path: Pat } }); - // Resize channel — master stays in a blocking thread for resize calls let (resize_tx, resize_rx) = mpsc::channel::<(u16, u16)>(4); tokio::task::spawn_blocking(move || { let master = pair.master; diff --git a/crates/ilold-web/src/ws/search.rs b/crates/ilold-web/src/ws/search.rs index 1d14abf..a3dca7c 100644 --- a/crates/ilold-web/src/ws/search.rs +++ b/crates/ilold-web/src/ws/search.rs @@ -104,7 +104,6 @@ pub fn search_paths(state: &SolidityState, query: &SearchQuery) -> Vec<SearchRes }); } - // Match on terminal type let terminal_str = format!("{:?}", path.terminal); if terminal_str.to_lowercase().contains(&q) { matches.push(SearchMatch { @@ -113,7 +112,6 @@ pub fn search_paths(state: &SolidityState, query: &SearchQuery) -> Vec<SearchRes }); } - // Match on function name if function.to_lowercase().contains(&q) { matches.push(SearchMatch { field: "function".into(), From fb4dc9ab471ba7ef3d7ebf5d55db5616159069ef Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Mon, 11 May 2026 17:45:58 +0200 Subject: [PATCH 114/115] chore(guide): drop internal LLM demo from public book - Move recipe to docs/internal/ which is gitignored - Keep it local-only as personal demo notes --- docs/guide/src/SUMMARY.md | 4 - docs/guide/src/recipes/llm-demo.md | 134 ----------------------------- 2 files changed, 138 deletions(-) diff --git a/docs/guide/src/SUMMARY.md b/docs/guide/src/SUMMARY.md index 0d1cc48..0fc65bb 100644 --- a/docs/guide/src/SUMMARY.md +++ b/docs/guide/src/SUMMARY.md @@ -38,10 +38,6 @@ - [Workflow: Scenarios and forks](./solana/workflows/scenarios.md) - [Limitations](./solana/limitations.md) -# Recipes - -- [LLM-driven audit demo](./recipes/llm-demo.md) - # Reference - [HTTP API](./reference/api-endpoints.md) diff --git a/docs/guide/src/recipes/llm-demo.md b/docs/guide/src/recipes/llm-demo.md index 7749461..292de15 100644 --- a/docs/guide/src/recipes/llm-demo.md +++ b/docs/guide/src/recipes/llm-demo.md @@ -1,135 +1 @@ # LLM-driven audit demo - -Two walkthroughs for driving ilold with an LLM: - -- **Solana via MCP** — natural language prompts handed to Claude Code / Desktop / Cursor / Continue. The LLM picks the tools. -- **Solidity via REPL** — manual command sequence in `ilold explore`. Solidity does not have an MCP server in v1. - -Setup is documented in [Reference: MCP server](../reference/mcp.md) and [Getting Started](../getting-started.md). The recipes below assume the backend and the canvas are already running. - -## Solana via MCP - -Prerequisites: - -- `ilold serve tests/fixtures/solana/staking --port 8080` running in one terminal. -- (Optional) `npm run dev` running so the canvas at `http://localhost:5173` reflects each tool call. -- `claude mcp list` shows `ilold ✓ Connected`. - -Paste any of the prompts below into the LLM client. The LLM picks the right MCP tools by itself. - -### One-liner audit - -``` -Audit the active Solana program in ilold and hand me the deliverable when done. -``` - -The LLM will typically explore the program, set up actors, run a happy path, probe admin paths, and export a Markdown audit. Total time: ~30 seconds and ~15 tool calls. - -### Senior auditor framing - -``` -You have ilold available, a workbench that executes smart contracts in a real VM. -Act as a senior security auditor for Solana. A program just landed on your desk -for review. Find anything that could break it in production. - -Work as you would in a real audit: map the surface, set up realistic scenarios, -probe whatever attack vectors come to mind, and hand me the deliverable at the end. - -Narrate briefly as you go. -``` - -Best version for video recordings: the LLM stays explanatory, picks tools on its own, and the narration matches what the audience sees in the chat. - -### Adversarial framing - -``` -You are a hacker looking at a freshly deployed Solana program with 50M TVL. -Your only tool is ilold. Find out if you can drain funds without permission, -steal rewards from other users, or break the accounting. Test everything against -the real VM. Walk me through what you find and how you would exploit it. -``` - -More dramatic tone. Generates findings with attacker phrasing, useful for talks but not for production deliverables. - -### Switching programs mid-session - -After any of the prompts above, the LLM stays in the same MCP session. To audit a different program in the same backend: - -``` -Now list all programs available with ilold_programs, switch to a different one, -and repeat the same audit pattern. -``` - -If you swap the backend (stop `ilold serve` and start it again pointing at a new -project), the MCP keeps working: the next `ilold_programs` call sees the new -workspace. - -### Closing the session - -``` -Summarise everything you did. How many tool calls? Which paths did you cover? -Which paths are still untested? Save the scenario so I can replay it later. -``` - -The LLM will print the cumulative coverage and call `ilold_save` so the session lives in `~/.ilold/sessions/`. - -## Solidity via REPL - -Solidity is exposed through the interactive REPL (`ilold explore`), not through MCP. The REPL takes typed commands directly. - -Prerequisites: - -- `ilold explore tests/fixtures/staking.sol --port 8080` running. -- (Optional) `npm run dev` for the canvas. - -### Discovery and analysis - -``` -f List functions -i deposit Detail one function -v List state variables -who totalStaked Writers and readers of a state variable -seq Sequence narrative -``` - -### Building a session - -``` -c deposit Add deposit to the session -c withdraw Add withdraw -s View accumulated state -tr deposit Trace deposit execution -sl deposit totalStaked Dataflow slice for totalStaked through deposit -tl totalStaked Cross-step timeline of totalStaked -``` - -### Scenarios - -``` -scenario new attacker -scenario fork attacker 1 Fork active scenario at step 1 -scenario list -scenario switch main -``` - -### Findings and export - -``` -finding High "reentrancy in withdraw" -note "external call before state update" -status withdraw finding -export Generate Markdown deliverable -``` - -### Inline help - -``` -? Full command reference -sl? Quick help for a single command -``` - -## Recording tips - -The Solana MCP path is the one to record on video: the LLM picks tools live and the canvas paints alongside. Pair the chat (50% of the screen) with the canvas (the other 50%) and let the prompt run end to end without pauses. - -The Solidity REPL is better for narrated walkthroughs where you want to demonstrate each primitive deliberately. From 445918d4c759c77ea6097fafe3889376d51aa099 Mon Sep 17 00:00:00 2001 From: scab24 <git.seco@protonmail.com> Date: Mon, 11 May 2026 17:51:44 +0200 Subject: [PATCH 115/115] chore(guide): finish removing recipe stub from tracking --- docs/guide/src/recipes/llm-demo.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 docs/guide/src/recipes/llm-demo.md diff --git a/docs/guide/src/recipes/llm-demo.md b/docs/guide/src/recipes/llm-demo.md deleted file mode 100644 index 292de15..0000000 --- a/docs/guide/src/recipes/llm-demo.md +++ /dev/null @@ -1 +0,0 @@ -# LLM-driven audit demo