From cd9e67e3f42673de6da84d67fbd7a0418d93e5cf Mon Sep 17 00:00:00 2001 From: Manyfestation <240733973+Manyfestation@users.noreply.github.com> Date: Fri, 27 Mar 2026 13:10:19 +0300 Subject: [PATCH 01/11] Support source-level covenant debugger sessions --- debugger/cli/src/main.rs | 299 ++++++++++-- debugger/session/src/args.rs | 53 ++- debugger/session/src/session.rs | 434 +++++++++++++++--- debugger/session/src/test_runner.rs | 20 +- silverscript-lang/src/compiler.rs | 82 +++- .../src/compiler/covenant_declarations.rs | 191 ++++++-- 6 files changed, 907 insertions(+), 172 deletions(-) diff --git a/debugger/cli/src/main.rs b/debugger/cli/src/main.rs index 97f1ba95..8c89f475 100644 --- a/debugger/cli/src/main.rs +++ b/debugger/cli/src/main.rs @@ -4,8 +4,8 @@ use std::io::{self, BufRead, Write}; use std::path::{Path, PathBuf}; use clap::Parser; -use debugger_session::args::{parse_call_args, parse_ctor_args, parse_hex_bytes}; -use debugger_session::session::{DebugEngine, DebugSession, ShadowTxContext, Variable, VariableOrigin}; +use debugger_session::args::{parse_call_args, parse_ctor_args, parse_hex_bytes, parse_state_value}; +use debugger_session::session::{DebugEngine, DebugSession, DebugValue, ShadowTxContext, Variable, VariableOrigin}; use debugger_session::test_runner::{ TestExpectation, TestTxInputScenarioResolved, TestTxOutputScenarioResolved, TestTxScenarioResolved, discover_sidecar_path, resolve_contract_test, @@ -21,8 +21,11 @@ use kaspa_txscript::caches::Cache; use kaspa_txscript::covenants::CovenantsContext; use kaspa_txscript::script_builder::ScriptBuilder; use kaspa_txscript::{EngineCtx, EngineFlags, pay_to_script_hash_script}; -use silverscript_lang::ast::{ContractAst, parse_contract_ast}; -use silverscript_lang::compiler::{CompileOptions, compile_contract}; +use silverscript_lang::ast::{ContractAst, Expr, ExprKind, parse_contract_ast}; +use silverscript_lang::compiler::{ + CompileOptions, CovenantDeclBinding, CovenantDeclCallOptions, compile_contract, compile_contract_ast, + resolve_contract_state_values, +}; const PROMPT: &str = "(sdb) "; @@ -46,6 +49,8 @@ struct CliArgs { raw_ctor_args: Vec, #[arg(long = "arg", short = 'a')] raw_args: Vec, + #[arg(long = "delegate")] + delegate: bool, } fn compile_script_for_ctor_args( @@ -63,6 +68,136 @@ fn compile_script_for_ctor_args( Ok(compiled.script) } +fn expr_to_debug_value(expr: &Expr<'_>) -> Result { + use debugger_session::session::DebugValue; + + match &expr.kind { + ExprKind::Int(value) => Ok(DebugValue::Int(*value)), + ExprKind::Bool(value) => Ok(DebugValue::Bool(*value)), + ExprKind::Byte(value) => Ok(DebugValue::Bytes(vec![*value])), + ExprKind::String(value) => Ok(DebugValue::String(value.clone())), + ExprKind::Array(values) => { + if values.iter().all(|value| matches!(value.kind, ExprKind::Byte(_))) { + return Ok(DebugValue::Bytes( + values + .iter() + .map(|value| match value.kind { + ExprKind::Byte(byte) => byte, + _ => unreachable!("checked"), + }) + .collect(), + )); + } + Ok(DebugValue::Array(values.iter().map(expr_to_debug_value).collect::, _>>()?)) + } + ExprKind::StateObject(fields) => Ok(DebugValue::Object( + fields + .iter() + .map(|field| Ok((field.name.clone(), expr_to_debug_value(&field.expr)?))) + .collect::, String>>()?, + )), + other => Err(format!("unsupported resolved state expression in debugger: {other:?}")), + } +} + +fn resolve_state_for_ctor_args( + parsed_contract: &ContractAst<'_>, + raw_ctor_args: &[String], + cache: &mut HashMap, debugger_session::session::DebugValue>, +) -> Result> { + if let Some(value) = cache.get(raw_ctor_args) { + return Ok(value.clone()); + } + + let ctor_args = parse_ctor_args(parsed_contract, raw_ctor_args)?; + let state_fields = resolve_contract_state_values(parsed_contract, &ctor_args)?; + let value = debugger_session::session::DebugValue::Object( + state_fields + .iter() + .map(|field| Ok((field.name.clone(), expr_to_debug_value(&field.value)?))) + .collect::, String>>()?, + ); + cache.insert(raw_ctor_args.to_vec(), value.clone()); + Ok(value) +} + +fn resolve_state_from_raw( + parsed_contract: &ContractAst<'_>, + raw_state: &str, + cache: &mut HashMap, +) -> Result> { + if let Some(value) = cache.get(raw_state) { + return Ok(value.clone()); + } + + let expr = parse_state_value(parsed_contract, raw_state)?; + let value = expr_to_debug_value(&expr)?; + cache.insert(raw_state.to_string(), value.clone()); + Ok(value) +} + +fn materialize_script_for_explicit_state( + source: &str, + parsed_contract: &ContractAst<'_>, + raw_instance_args: &[String], + raw_state: &str, +) -> Result, Box> { + let instance_args = parse_ctor_args(parsed_contract, raw_instance_args)?; + let state = parse_state_value(parsed_contract, raw_state)?; + let base_compiled = compile_contract(source, &instance_args, CompileOptions::default())?; + let materialized_contract = contract_with_explicit_state(parsed_contract, &state)?; + let materialized = compile_contract_ast(&materialized_contract, &instance_args, CompileOptions::default())?; + let base_layout = base_compiled.state_layout; + let materialized_layout = materialized.state_layout; + if base_layout.len == 0 { + return Err("contract does not expose a materializable state segment".into()); + } + if materialized_layout.len == 0 { + return Err("materialized contract did not expose a state segment".into()); + } + let base_start = base_layout.start; + let base_end = base_layout.start + base_layout.len; + let materialized_start = materialized_layout.start; + let materialized_end = materialized_layout.start + materialized_layout.len; + let base_len = base_layout.len; + let materialized_len = materialized_layout.len; + if base_len != materialized_len { + return Err("explicit state changes encoded script size; provide raw script_hex instead".into()); + } + if base_compiled.script.len() < base_end || materialized.script.len() < materialized_end { + return Err("state template range exceeds compiled script length".into()); + } + if base_compiled.script[..base_start] != materialized.script[..materialized_start] + || base_compiled.script[base_end..] != materialized.script[materialized_end..] + { + return Err("explicit state changed non-state bytecode; provide raw script_hex instead".into()); + } + + let mut script = base_compiled.script; + script[base_start..base_end].copy_from_slice(&materialized.script[materialized_start..materialized_end]); + Ok(script) +} + +fn contract_with_explicit_state<'i>(contract: &ContractAst<'i>, state: &Expr<'i>) -> Result, String> { + let ExprKind::StateObject(entries) = &state.kind else { + return Err("State value must be an object literal".to_string()); + }; + + let mut provided = entries.iter().map(|entry| (entry.name.as_str(), entry.expr.clone())).collect::>(); + if provided.len() != contract.fields.len() { + return Err("State value must include all contract fields exactly once".to_string()); + } + + let mut materialized = contract.clone(); + for field in &mut materialized.fields { + field.expr = provided.remove(field.name.as_str()).ok_or_else(|| format!("missing state field '{}'", field.name))?; + } + if let Some(extra) = provided.keys().next() { + return Err(format!("unknown state field '{}'", extra)); + } + Ok(materialized) +} + fn parse_hex_32(raw: &str, name: &str) -> Result<[u8; 32], Box> { let bytes = parse_hex_bytes(raw)?; if bytes.len() != 32 { @@ -74,6 +209,14 @@ fn parse_hex_32(raw: &str, name: &str) -> Result<[u8; 32], Box Result> { + if raw.starts_with("0x") || raw.starts_with("0X") { + return Ok(Hash::from_bytes(parse_short_or_full_hex_32(raw, "hash")?)); + } + + if let Ok(value) = raw.parse::() { + return Ok(Hash::from_bytes(u64_to_hash_bytes(value))); + } + Ok(Hash::from_bytes(parse_hex_32(raw, "hash")?)) } @@ -81,6 +224,22 @@ fn parse_txid32(raw: &str) -> Result> Ok(TransactionId::from_bytes(parse_hex_32(raw, "txid")?)) } +fn parse_short_or_full_hex_32(raw: &str, name: &str) -> Result<[u8; 32], Box> { + let bytes = parse_hex_bytes(raw)?; + if bytes.len() > 32 { + return Err(format!("{name} expects at most 32 bytes, got {}", bytes.len()).into()); + } + let mut array = [0u8; 32]; + array[32 - bytes.len()..].copy_from_slice(&bytes); + Ok(array) +} + +fn u64_to_hash_bytes(value: u64) -> [u8; 32] { + let mut array = [0u8; 32]; + array[24..].copy_from_slice(&value.to_be_bytes()); + array +} + fn build_p2pk_script(pubkey: &[u8]) -> Vec { ScriptBuilder::new() .add_data(pubkey) @@ -195,7 +354,7 @@ fn run_repl(session: &mut DebugSession<'_, '_>) -> Result<(), Box { let console_output = session.take_console_output(); @@ -218,7 +377,7 @@ fn run_repl(session: &mut DebugSession<'_, '_>) -> Result<(), Box match session.step_into() { + "step" | "s" | "into" => match session.step_into() { Ok(Some(_)) => { let console_output = session.take_console_output(); show_step_view(session, &console_output); @@ -248,7 +407,7 @@ fn run_repl(session: &mut DebugSession<'_, '_>) -> Result<(), Box match session.step_out() { + "finish" | "out" | "so" => match session.step_out() { Ok(Some(_)) => { let console_output = session.take_console_output(); show_step_view(session, &console_output); @@ -329,11 +488,11 @@ fn run_repl(session: &mut DebugSession<'_, '_>) -> Result<(), Box break, "help" | "h" | "?" => { println!( - "Commands: next/over (n), step/into (s), step opcode (si), finish/out, continue (c), break (b ), list (l), vars, eval (e), print , stack, quit (q)" + "Commands: next/over (n), step/into (s), step opcode (si), finish/out/so, continue (c), break (b ), list (l), vars, eval (e), print , stack, quit (q)" ) } _ => println!( - "Commands: next/over (n), step/into (s), step opcode (si), finish/out, continue (c), break (b ), list (l), vars, eval (e), print , stack, quit (q)" + "Commands: next/over (n), step/into (s), step opcode (si), finish/out/so, continue (c), break (b ), list (l), vars, eval (e), print , stack, quit (q)" ), } } @@ -408,39 +567,62 @@ fn main() -> Result<(), Box> { return run_all_tests(&test_file, cli.script_path.as_deref()); } - // Resolve source, ctor args, function, call args, and tx from test file or CLI flags + // Resolve source, constructor args, function, call args, and tx from test file or CLI flags let inferred_test_file = if cli.test_file.is_some() || cli.test_name.is_some() { resolve_test_file_path(cli.test_file.as_deref(), cli.script_path.as_deref(), "run-test")? } else { None }; - let (script_path, raw_ctor_args, selected_name, raw_args, tx_scenario, expect) = + let (script_path, raw_constructor_args, selected_name, raw_args, delegate, tx_scenario, expect) = if let Some(test_file) = inferred_test_file.as_deref() { let test_name = cli.test_name.as_deref().ok_or("--test-name requires --test-file or SCRIPT_PATH")?; let script_override = cli.script_path.as_deref().map(Path::new); let resolved = resolve_contract_test(test_file, test_name, script_override) .map_err(|e| -> Box { e.into() })?; - let ctor = if !cli.raw_ctor_args.is_empty() { cli.raw_ctor_args.clone() } else { resolved.test.constructor_args }; + let constructor_args = + if !cli.raw_ctor_args.is_empty() { cli.raw_ctor_args.clone() } else { resolved.test.constructor_args }; let fname = cli.function_name.clone().unwrap_or(resolved.test.function); let args = if !cli.raw_args.is_empty() { cli.raw_args.clone() } else { resolved.test.args }; let expect = Some(resolved.test.expect); - (resolved.script_path, ctor, fname, args, resolved.test.tx, expect) + ( + resolved.script_path, + constructor_args, + fname, + args, + cli.delegate || resolved.test.delegate, + resolved.test.tx, + expect, + ) } else { let path = cli.script_path.as_deref().ok_or("missing script path: pass SCRIPT_PATH or --test-file")?; - let ctor = cli.raw_ctor_args.clone(); - let args = cli.raw_args.clone(); - (PathBuf::from(path), ctor, cli.function_name.clone().unwrap_or_default(), args, None, None) + let constructor_args = cli.raw_ctor_args.clone(); + let entrypoint_args = cli.raw_args.clone(); + ( + PathBuf::from(path), + constructor_args, + cli.function_name.clone().unwrap_or_default(), + entrypoint_args, + cli.delegate, + None, + None, + ) }; let source = fs::read_to_string(&script_path)?; let parsed_contract = parse_contract_ast(&source)?; - let ctor_args = parse_ctor_args(&parsed_contract, &raw_ctor_args)?; + let ctor_args = parse_ctor_args(&parsed_contract, &raw_constructor_args)?; let compile_opts = CompileOptions { record_debug_infos: true, ..Default::default() }; let compiled = compile_contract(&source, &ctor_args, compile_opts)?; let debug_info = compiled.debug_info.clone(); let mut ctor_script_cache = HashMap::, Vec>::new(); - ctor_script_cache.insert(raw_ctor_args.clone(), compiled.script.clone()); + let mut ctor_state_cache = HashMap::, debugger_session::session::DebugValue>::new(); + let mut explicit_state_cache = HashMap::::new(); + ctor_script_cache.insert(raw_constructor_args.clone(), compiled.script.clone()); + if !parsed_contract.fields.is_empty() { + let root_state = resolve_state_for_ctor_args(&parsed_contract, &raw_constructor_args, &mut ctor_state_cache)?; + ctor_state_cache.insert(raw_constructor_args.clone(), root_state); + } let selected_name = if selected_name.is_empty() { compiled.abi.first().map(|entry| entry.name.clone()).ok_or("contract has no functions")? @@ -448,8 +630,22 @@ fn main() -> Result<(), Box> { selected_name }; - let typed_args = parse_call_args(&compiled.ast, &selected_name, &raw_args)?; - let sigscript = compiled.build_sig_script(&selected_name, typed_args)?; + let covenant_target = compiled.resolve_covenant_call_target(&selected_name, CovenantDeclCallOptions { is_leader: !delegate }); + let covenant_binding = covenant_target.as_ref().map(|target| target.info.binding); + let enable_covenant_session_mode = covenant_target.is_some(); + let sigscript = if let Some(target) = covenant_target.as_ref() { + if delegate && target.info.binding != CovenantDeclBinding::Cov { + return Err("--delegate only applies to binding=cov covenant declarations".into()); + } + let typed_args = parse_call_args(&compiled.ast, &target.generated_entrypoint_name, &raw_args)?; + compiled.build_sig_script_for_covenant_decl(&selected_name, typed_args, CovenantDeclCallOptions { is_leader: !delegate })? + } else { + if delegate { + return Err("--delegate only applies when --function names a source-level binding=cov covenant declaration".into()); + } + let typed_args = parse_call_args(&compiled.ast, &selected_name, &raw_args)?; + compiled.build_sig_script(&selected_name, typed_args)? + }; let tx = tx_scenario.unwrap_or_else(|| TestTxScenarioResolved { version: 1, @@ -463,6 +659,7 @@ fn main() -> Result<(), Box> { utxo_value: 5000, covenant_id: None, constructor_args: None, + state: None, signature_script_hex: None, utxo_script_hex: None, }], @@ -471,6 +668,7 @@ fn main() -> Result<(), Box> { covenant_id: None, authorizing_input: None, constructor_args: None, + state: None, script_hex: None, p2pk_pubkey: None, }], @@ -485,6 +683,9 @@ fn main() -> Result<(), Box> { let mut tx_inputs = Vec::with_capacity(tx.inputs.len()); let mut utxo_specs = Vec::with_capacity(tx.inputs.len()); + let mut input_covenant_ids = Vec::with_capacity(tx.inputs.len()); + let mut input_covenant_states = Vec::with_capacity(tx.inputs.len()); + let mut input_redeem_scripts = Vec::with_capacity(tx.inputs.len()); for (input_idx, input) in tx.inputs.iter().enumerate() { let mut default_prev_txid = [0u8; 32]; default_prev_txid.fill(input_idx as u8); @@ -494,9 +695,20 @@ fn main() -> Result<(), Box> { TransactionId::from_bytes(default_prev_txid) }; - let input_ctor_raw = input.constructor_args.clone().unwrap_or_else(|| raw_ctor_args.clone()); + let input_constructor_args = input.constructor_args.clone().unwrap_or_else(|| raw_constructor_args.clone()); + let input_covenant_state = if let Some(raw_state) = input.state.as_deref() { + Some(resolve_state_from_raw(&parsed_contract, raw_state, &mut explicit_state_cache)?) + } else if input.utxo_script_hex.is_none() || input.constructor_args.is_some() { + Some(resolve_state_for_ctor_args(&parsed_contract, &input_constructor_args, &mut ctor_state_cache)?) + } else { + None + }; let redeem_script = if input.utxo_script_hex.is_none() { - Some(compile_script_for_ctor_args(&source, &parsed_contract, &input_ctor_raw, &mut ctor_script_cache)?) + if let Some(raw_state) = input.state.as_deref() { + Some(materialize_script_for_explicit_state(&source, &parsed_contract, &input_constructor_args, raw_state)?) + } else { + Some(compile_script_for_ctor_args(&source, &parsed_contract, &input_constructor_args, &mut ctor_script_cache)?) + } } else { None }; @@ -527,6 +739,9 @@ fn main() -> Result<(), Box> { sig_op_count: input.sig_op_count, }); utxo_specs.push((input.utxo_value, utxo_spk, covenant_id)); + input_covenant_ids.push(covenant_id); + input_covenant_states.push(input_covenant_state); + input_redeem_scripts.push(redeem_script); } let mut tx_outputs = Vec::with_capacity(tx.outputs.len()); @@ -538,8 +753,12 @@ fn main() -> Result<(), Box> { let p2pk_script = build_p2pk_script(&pubkey_bytes); ScriptPublicKey::new(0, p2pk_script.into()) } else { - let output_ctor_raw = output.constructor_args.clone().unwrap_or_else(|| raw_ctor_args.clone()); - let output_script = compile_script_for_ctor_args(&source, &parsed_contract, &output_ctor_raw, &mut ctor_script_cache)?; + let output_constructor_args = output.constructor_args.clone().unwrap_or_else(|| raw_constructor_args.clone()); + let output_script = if let Some(raw_state) = output.state.as_deref() { + materialize_script_for_explicit_state(&source, &parsed_contract, &output_constructor_args, raw_state)? + } else { + compile_script_for_ctor_args(&source, &parsed_contract, &output_constructor_args, &mut ctor_script_cache)? + }; pay_to_script_hash_script(&output_script) }; @@ -572,16 +791,32 @@ fn main() -> Result<(), Box> { kas_tx.inputs.get(tx.active_input_index).ok_or_else(|| format!("missing tx input at index {}", tx.active_input_index))?; let active_utxo = populated_tx.utxo(tx.active_input_index).ok_or_else(|| format!("missing utxo entry for input {}", tx.active_input_index))?; - let engine = DebugEngine::from_transaction_input(&populated_tx, active_input, tx.active_input_index, active_utxo, ctx, flags); - let shadow_tx_context = ShadowTxContext { - tx: &populated_tx, - input: active_input, - input_index: tx.active_input_index, - utxo_entry: active_utxo, - covenants_ctx: &cov_ctx, + let active_covenant_input_state = input_covenant_states.get(tx.active_input_index).cloned().flatten(); + let active_lockscript = + input_redeem_scripts.get(tx.active_input_index).cloned().flatten().unwrap_or_else(|| compiled.script.clone()); + let covenant_input_states = active_utxo.covenant_id.and_then(|covenant_id| { + let mut values = Vec::new(); + for (input_covenant_id, covenant_input_state) in input_covenant_ids.iter().zip(input_covenant_states.iter()) { + if *input_covenant_id != Some(covenant_id) { + continue; + } + values.push(covenant_input_state.clone()?); + } + Some(values) + }); + let covenant_param_value = match covenant_binding { + Some(CovenantDeclBinding::Auth) => active_covenant_input_state.clone(), + Some(CovenantDeclBinding::Cov) => covenant_input_states.clone().map(DebugValue::Array), + None => None, }; + let engine = DebugEngine::from_transaction_input(&populated_tx, active_input, tx.active_input_index, active_utxo, ctx, flags); + let shadow_tx_context = + ShadowTxContext { tx: &populated_tx, input: active_input, input_index: tx.active_input_index, utxo_entry: active_utxo }; let mut session = - DebugSession::full(&sigscript, &compiled.script, &source, debug_info, engine)?.with_shadow_tx_context(shadow_tx_context); + DebugSession::full(&sigscript, &active_lockscript, &source, debug_info, engine)?.with_shadow_tx_context(shadow_tx_context); + if enable_covenant_session_mode { + session = session.with_covenant_mode(compiled.covenant_infos.clone(), covenant_param_value, covenant_target); + } if cli.run { let expect_fail = expect == Some(TestExpectation::Fail); diff --git a/debugger/session/src/args.rs b/debugger/session/src/args.rs index c39bf77f..2347d773 100644 --- a/debugger/session/src/args.rs +++ b/debugger/session/src/args.rs @@ -27,7 +27,7 @@ pub fn parse_hex_bytes(raw: &str) -> Result, String> { Ok(out) } -pub fn bytes_expr(bytes: Vec) -> Expr<'static> { +pub fn bytes_expr<'i>(bytes: Vec) -> Expr<'i> { Expr::new(ExprKind::Array(bytes.into_iter().map(Expr::byte).collect()), span::Span::default()) } @@ -98,13 +98,13 @@ fn validate_array_len(type_ref: &TypeRef, len: usize) -> Result<(), String> { Ok(()) } -fn parse_byte_array_arg(type_ref: &TypeRef, raw: &str) -> Result, String> { +fn parse_byte_array_arg<'i>(type_ref: &TypeRef, raw: &str) -> Result, String> { let bytes = parse_hex_bytes(raw)?; validate_byte_array_len(type_ref, bytes.len())?; Ok(bytes_expr(bytes)) } -fn parse_scalar_arg(type_ref: &TypeRef, raw: &str) -> Result, String> { +fn parse_scalar_arg<'i>(type_ref: &TypeRef, raw: &str) -> Result, String> { match type_ref.base { TypeBase::Int => Ok(Expr::int(parse_int_arg(raw)?)), TypeBase::Bool => match raw { @@ -143,11 +143,11 @@ fn parse_scalar_arg(type_ref: &TypeRef, raw: &str) -> Result, Stri } } -fn parse_struct_arg( +fn parse_struct_arg<'i>( entries: &serde_json::Map, declared_fields: &[StructShapeField], shapes: &StructShapeRegistry, -) -> Result, String> { +) -> Result, String> { let mut provided = entries.iter().collect::>(); let mut out = Vec::with_capacity(declared_fields.len()); @@ -168,7 +168,7 @@ fn parse_struct_arg( Ok(Expr::new(ExprKind::StateObject(out), span::Span::default())) } -fn parse_array_arg(values: &[Value], type_ref: &TypeRef, shapes: &StructShapeRegistry) -> Result, String> { +fn parse_array_arg<'i>(values: &[Value], type_ref: &TypeRef, shapes: &StructShapeRegistry) -> Result, String> { validate_array_len(type_ref, values.len())?; let element_type = type_ref.element_type().ok_or_else(|| format!("unsupported arg type '{}'", type_ref.type_name()))?; values @@ -178,7 +178,7 @@ fn parse_array_arg(values: &[Value], type_ref: &TypeRef, shapes: &StructShapeReg .map(|values| Expr::new(ExprKind::Array(values), span::Span::default())) } -fn parse_json_value_for_type(value: &Value, type_ref: &TypeRef, shapes: &StructShapeRegistry) -> Result, String> { +fn parse_json_value_for_type<'i>(value: &Value, type_ref: &TypeRef, shapes: &StructShapeRegistry) -> Result, String> { if matches!(value, Value::Null) { return Err("null is not supported in structured args".to_string()); } @@ -218,7 +218,7 @@ fn parse_json_value_for_type(value: &Value, type_ref: &TypeRef, shapes: &StructS } } -fn parse_typed_arg(type_ref: &TypeRef, shapes: &StructShapeRegistry, raw: &str) -> Result, String> { +fn parse_typed_arg<'i>(type_ref: &TypeRef, shapes: &StructShapeRegistry, raw: &str) -> Result, String> { let trimmed = raw.trim(); if trimmed == "null" { return Err("null is not supported in structured args".to_string()); @@ -245,7 +245,7 @@ fn parse_typed_arg(type_ref: &TypeRef, shapes: &StructShapeRegistry, raw: &str) parse_scalar_arg(type_ref, trimmed) } -fn parse_params(params: &[ParamAst<'_>], shapes: &StructShapeRegistry, raw_args: &[String]) -> Result>, String> { +fn parse_params<'i>(params: &[ParamAst<'_>], shapes: &StructShapeRegistry, raw_args: &[String]) -> Result>, String> { if params.len() != raw_args.len() { return Err(format!("function expects {} arguments, got {}", params.len(), raw_args.len())); } @@ -257,7 +257,7 @@ fn parse_params(params: &[ParamAst<'_>], shapes: &StructShapeRegistry, raw_args: Ok(typed_args) } -pub fn parse_ctor_args(parsed_contract: &ContractAst<'_>, raw_ctor_args: &[String]) -> Result>, String> { +pub fn parse_ctor_args<'i>(parsed_contract: &ContractAst<'_>, raw_ctor_args: &[String]) -> Result>, String> { let shapes = StructShapeRegistry::from_contract(parsed_contract); if parsed_contract.params.len() != raw_ctor_args.len() { return Err(format!("constructor expects {} arguments, got {}", parsed_contract.params.len(), raw_ctor_args.len())); @@ -265,7 +265,7 @@ pub fn parse_ctor_args(parsed_contract: &ContractAst<'_>, raw_ctor_args: &[Strin parse_params(&parsed_contract.params, &shapes, raw_ctor_args) } -pub fn parse_call_args(contract: &ContractAst<'_>, function_name: &str, raw_args: &[String]) -> Result>, String> { +pub fn parse_call_args<'i>(contract: &ContractAst<'_>, function_name: &str, raw_args: &[String]) -> Result>, String> { let function = contract .functions .iter() @@ -275,9 +275,25 @@ pub fn parse_call_args(contract: &ContractAst<'_>, function_name: &str, raw_args parse_params(&function.params, &shapes, raw_args) } +pub fn parse_state_value<'i>(contract: &ContractAst<'_>, raw_state: &str) -> Result, String> { + let value = + serde_json::from_str::(raw_state).map_err(|err| format!("invalid State value '{raw_state}': {err}"))?; + let Value::Object(entries) = value else { + return Err("State value must be a JSON object".to_string()); + }; + + let shapes = StructShapeRegistry::from_contract(contract); + let declared_fields = contract + .fields + .iter() + .map(|field| StructShapeField { name: field.name.clone(), type_ref: field.type_ref.clone() }) + .collect::>(); + parse_struct_arg(&entries, &declared_fields, &shapes) +} + #[cfg(test)] mod tests { - use super::{parse_call_args, parse_ctor_args}; + use super::{parse_call_args, parse_ctor_args, parse_state_value}; use silverscript_lang::ast::{ExprKind, parse_contract_ast}; fn debug_shapes_contract() -> silverscript_lang::ast::ContractAst<'static> { @@ -347,6 +363,19 @@ mod tests { assert!(matches!(tag.expr.kind, ExprKind::Array(ref values) if values.len() == 1)); } + #[test] + fn parses_explicit_state_value() { + let contract = debug_shapes_contract(); + let value = parse_state_value(&contract, r#"{"amount":9,"active":false,"tag":"0xcc"}"#).expect("parse State value"); + let ExprKind::StateObject(fields) = &value.kind else { + panic!("expected state object"); + }; + assert_eq!(fields.len(), 3); + assert!(fields.iter().any(|field| field.name == "amount")); + assert!(fields.iter().any(|field| field.name == "active")); + assert!(fields.iter().any(|field| field.name == "tag")); + } + #[test] fn rejects_missing_struct_field() { let contract = debug_shapes_contract(); diff --git a/debugger/session/src/session.rs b/debugger/session/src/session.rs index 23437da7..27077d5e 100644 --- a/debugger/session/src/session.rs +++ b/debugger/session/src/session.rs @@ -9,9 +9,10 @@ use kaspa_txscript::{DynOpcodeImplementation, EngineCtx, EngineFlags, TxScriptEn use serde::{Deserialize, Serialize}; use silverscript_lang::ast::{ - Expr, ExprKind, StateFieldExpr, TypeBase, UnarySuffixKind, parse_contract_ast, parse_expression_ast, parse_type_ref, + ContractAst, Expr, ExprKind, StateFieldExpr, TypeBase, TypeRef, UnarySuffixKind, parse_contract_ast, parse_expression_ast, + parse_type_ref, }; -use silverscript_lang::compiler::{compile_debug_expr, flattened_struct_name}; +use silverscript_lang::compiler::{CovenantDeclBinding, CovenantDeclInfo, ResolvedCovenantCallTarget, compile_debug_expr, flattened_struct_name}; use silverscript_lang::debug_info::{ DebugFunctionRange, DebugInfo, DebugLeafBinding, DebugNamedValue, DebugParamBinding, DebugStep, DebugVariableUpdate, RuntimeBinding, SourceSpan, StepId, StepKind, @@ -27,13 +28,12 @@ pub type DebugReused = SigHashReusedValuesUnsync; pub type DebugOpcode<'a> = DynOpcodeImplementation, DebugReused>; pub type DebugEngine<'a> = TxScriptEngine<'a, DebugTx<'a>, DebugReused>; -#[derive(Clone, Copy)] +#[derive(Clone)] pub struct ShadowTxContext<'a> { pub tx: &'a DebugTx<'a>, pub input: &'a TransactionInput, pub input_index: usize, pub utxo_entry: &'a UtxoEntry, - pub covenants_ctx: &'a CovenantsContext, } #[derive(Debug, Clone)] @@ -127,6 +127,25 @@ pub struct OpcodeMeta<'i> { pub step: Option>, } +#[derive(Clone)] +struct CovenantSessionContext { + bindings: Vec, +} + +impl CovenantSessionContext { + fn binding_for_function(&self, function_name: &str) -> Option<&CovenantDeclInfo> { + self.bindings.iter().find(|binding| binding.matches_generated_name(function_name)) + } + + fn display_name_for_function(&self, function_name: &str) -> Option { + self.binding_for_function(function_name).and_then(|binding| binding.display_name_for_function(function_name)) + } + + fn hides_name(&self, name: &str) -> bool { + name.starts_with("__cov_") || name.starts_with("__covenant_policy_") + } +} + pub struct DebugSession<'a, 'i> { engine: DebugEngine<'a>, shadow_tx_context: Option>, @@ -136,7 +155,11 @@ pub struct DebugSession<'a, 'i> { script_len: usize, pc: usize, debug_info: DebugInfo<'i>, - function_param_counts: HashMap, + contract_ast: Option>, + covenant_ctx: Option, + covenant_param_value: Option, + active_covenant_policy_name: Option, + active_covenant_display_name: Option, step_order: Vec, current_step_index: Option, source_lines: Vec, @@ -153,15 +176,15 @@ struct ShadowBindingValue { value: Vec, } -struct VariableContext<'a> { - function_name: &'a str, +struct VariableContext { + function_name: String, function_start: usize, function_end: usize, step_id: StepId, } struct VisibleScope<'a, 'i> { - context: VariableContext<'a>, + context: VariableContext, updates: HashMap>, } @@ -170,6 +193,7 @@ enum ScopeValueSource<'i> { RuntimeSlot { from_top: i64 }, StructuredBinding { base_name: String, leaf_bindings: Vec }, Expr(Expr<'i>), + Unavailable { message: String }, } struct ScopeBinding<'i> { @@ -194,7 +218,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { pub fn full( sigscript: &[u8], lockscript: &[u8], - source: &str, + source: &'i str, debug_info: Option>, mut engine: DebugEngine<'a>, ) -> Result { @@ -205,15 +229,12 @@ impl<'a, 'i> DebugSession<'a, 'i> { /// Internal constructor: parses script, prepares opcodes, extracts statement steps. pub fn from_scripts( script: &[u8], - source: &str, + source: &'i str, debug_info: Option>, engine: DebugEngine<'a>, ) -> Result { let debug_info = debug_info.unwrap_or_else(DebugInfo::empty); - let function_param_counts = parse_contract_ast(source) - .ok() - .map(|contract| contract.functions.into_iter().map(|function| (function.name, function.params.len())).collect()) - .unwrap_or_default(); + let contract_ast = parse_contract_ast(source).ok(); let opcodes = parse_script::, DebugReused>(script).collect::, _>>()?; let op_displays = opcodes.iter().map(|op| format!("{op:?}")).collect(); let opcodes: Vec>> = opcodes.into_iter().map(Some).collect(); @@ -237,7 +258,11 @@ impl<'a, 'i> DebugSession<'a, 'i> { script_len, pc: 0, debug_info, - function_param_counts, + contract_ast, + covenant_ctx: None, + covenant_param_value: None, + active_covenant_policy_name: None, + active_covenant_display_name: None, step_order, current_step_index: None, source_lines, @@ -266,6 +291,19 @@ impl<'a, 'i> DebugSession<'a, 'i> { self } + pub fn with_covenant_mode( + mut self, + infos: Vec, + param_value: Option, + active_call: Option, + ) -> Self { + self.covenant_ctx = (!infos.is_empty()).then_some(CovenantSessionContext { bindings: infos }); + self.covenant_param_value = param_value; + self.active_covenant_policy_name = active_call.as_ref().map(|call| call.info.lowered.policy_function.clone()); + self.active_covenant_display_name = active_call.as_ref().map(display_name_for_active_covenant_call); + self + } + /// Step into: advance to next source step regardless of call depth. pub fn step_into(&mut self) -> Result>, kaspa_txscript_errors::TxScriptError> { self.step_with_depth_predicate(|_, _| true) @@ -347,7 +385,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { /// Advances execution to the first user statement, skipping dispatcher/synthetic bytecode. /// Call this after session creation to skip over contract setup code. - /// Skips opcodes until the first source step is encountered. + /// Skips opcodes until the first real source step is encountered. pub fn run_to_first_executed_statement(&mut self) -> Result>, kaspa_txscript_errors::TxScriptError> { if self.step_order.is_empty() { return Ok(None); @@ -358,7 +396,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { } let offset = self.current_byte_offset(); if self.engine.is_executing() { - if let Some(index) = self.steppable_step_index_for_offset(offset, None) { + if let Some(index) = self.initial_step_index_for_offset(offset, None) { self.mark_step_executed(index); return Ok(Some(self.state())); } @@ -491,8 +529,9 @@ impl<'a, 'i> DebugSession<'a, 'i> { /// Returns a specific variable by name, or error if not in scope. pub fn variable_by_name(&self, name: &str) -> Result { let scope_state = self.current_scope_state()?; - let variables = self.collect_variables_map(&scope_state); - variables.get(name).cloned().ok_or_else(|| format!("unknown variable '{name}'")) + let binding = scope_state.get(name).ok_or_else(|| format!("unknown variable '{name}'"))?; + let value = self.resolve_scope_binding(&scope_state, binding).unwrap_or_else(DebugValue::Unknown); + Ok(Variable { name: name.to_string(), type_name: binding.type_name.clone(), value, origin: binding.origin }) } pub fn evaluate_expression(&self, expr_src: &str) -> Result<(String, DebugValue), String> { @@ -535,7 +574,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { for step in self.active_steps() { match &step.kind { StepKind::InlineCallEnter { callee } => stack.push(CallStackEntry { - callee_name: callee.clone(), + callee_name: self.display_function_name(callee), call_site_span: Some(step.span), sequence: step.sequence, frame_id: step.frame_id, @@ -550,16 +589,86 @@ impl<'a, 'i> DebugSession<'a, 'i> { } /// Returns the name of the function currently being executed. - pub fn current_function_name(&self) -> Option<&str> { - self.current_function_range().map(|range| range.name.as_str()) + pub fn current_function_name(&self) -> Option { + self.current_compiled_function_name().map(|function_name| self.display_function_name(&function_name)) } - fn current_function_range(&self) -> Option<&DebugFunctionRange> { - let offset = self.current_byte_offset(); - self.debug_info.functions.iter().find(|function| offset >= function.bytecode_start && offset < function.bytecode_end) + fn current_compiled_function_name(&self) -> Option { + self.compiled_function_name_for_step(self.current_scope_step_id()) + } + + fn function_range_for_step(&self, step_id: StepId) -> Option<&DebugFunctionRange> { + let offset = self + .debug_info + .steps + .iter() + .find(|step| step.id() == step_id) + .map(|step| step.bytecode_start) + .unwrap_or_else(|| self.current_byte_offset()); + self.debug_info.functions.iter().find(|function| range_matches_offset(function.bytecode_start, function.bytecode_end, offset)) + } + + fn compiled_function_name_for_step(&self, step_id: StepId) -> Option { + let entrypoint = self.function_range_for_step(step_id)?; + if step_id.frame_id == 0 { + return Some(entrypoint.name.clone()); + } + + let mut active_calls = Vec::new(); + let mut steps = self + .debug_info + .steps + .iter() + .filter(|step| { + step.sequence <= step_id.sequence + && range_matches_offset(entrypoint.bytecode_start, entrypoint.bytecode_end, step.bytecode_start) + }) + .collect::>(); + steps.sort_by_key(|step| step.sequence); + + for step in steps { + match &step.kind { + StepKind::InlineCallEnter { callee } => active_calls.push((step.frame_id, callee.clone())), + StepKind::InlineCallExit { .. } => { + active_calls.pop(); + } + StepKind::Source {} => {} + } + } + + active_calls + .into_iter() + .rev() + .find_map(|(frame_id, callee)| (frame_id == step_id.frame_id).then_some(callee)) + .or_else(|| Some(entrypoint.name.clone())) + } + + fn covenant_ctx(&self) -> Option<&CovenantSessionContext> { + self.covenant_ctx.as_ref() + } + + fn param_origin(&self, name: &str) -> VariableOrigin { + if self.contract_ast.as_ref().is_some_and(|contract| contract.fields.iter().any(|field| field.name == name)) { + VariableOrigin::ContractField + } else { + VariableOrigin::Param + } + } + + fn display_function_name(&self, function_name: &str) -> String { + if self.active_covenant_policy_name.as_deref() == Some(function_name) { + if let Some(name) = &self.active_covenant_display_name { + return name.clone(); + } + } + self.covenant_ctx().and_then(|ctx| ctx.display_name_for_function(function_name)).unwrap_or_else(|| function_name.to_string()) + } + + fn is_hidden_debug_name(&self, name: &str) -> bool { + is_inline_synthetic_name(name) || self.covenant_ctx().is_some_and(|ctx| ctx.hides_name(name)) } - fn current_variable_updates(&self, context: &VariableContext<'_>) -> HashMap> { + fn current_variable_updates(&self, context: &VariableContext) -> HashMap> { let mut latest_by_name: HashMap)> = HashMap::new(); for step in self.debug_info.steps.iter().filter(|step| self.step_updates_are_visible(step, context)) { for update in &step.variable_updates { @@ -574,14 +683,11 @@ impl<'a, 'i> DebugSession<'a, 'i> { latest_by_name.into_iter().map(|(name, (_, update))| (name, update)).collect() } - fn current_variable_context(&self, step_id: StepId) -> Result, String> { - let function = self.current_function_range().ok_or_else(|| "No function context available".to_string())?; - Ok(VariableContext { - function_name: function.name.as_str(), - function_start: function.bytecode_start, - function_end: function.bytecode_end, - step_id, - }) + fn current_variable_context(&self, step_id: StepId) -> Result { + let function = self.function_range_for_step(step_id).ok_or_else(|| "No function context available".to_string())?; + let function_name = + self.compiled_function_name_for_step(step_id).ok_or_else(|| "No function context available".to_string())?; + Ok(VariableContext { function_name, function_start: function.bytecode_start, function_end: function.bytecode_end, step_id }) } fn scope_state(&self, step_id: StepId) -> Result, String> { @@ -594,10 +700,9 @@ impl<'a, 'i> DebugSession<'a, 'i> { let mut bindings = HashMap::new(); let function_params: Vec<_> = self.debug_info.params.iter().filter(|param| param.function == scope.context.function_name).collect(); - let source_param_count = self.function_param_counts.get(scope.context.function_name).copied().unwrap_or(function_params.len()); - for (index, param) in function_params.into_iter().enumerate() { - let origin = if index < source_param_count { VariableOrigin::Param } else { VariableOrigin::ContractField }; + for param in function_params { + let origin = self.param_origin(¶m.name); match ¶m.binding { DebugParamBinding::SingleValue { stack_index } => { bindings.entry(param.name.clone()).or_insert_with(|| ScopeBinding { @@ -671,19 +776,119 @@ impl<'a, 'i> DebugSession<'a, 'i> { .and_modify(|binding| { binding.type_name = update.type_name.clone(); binding.source = source.clone(); - binding.hidden = is_inline_synthetic_name(name); + binding.hidden = self.is_hidden_debug_name(name); }) .or_insert_with(|| ScopeBinding { type_name: update.type_name.clone(), source, origin: VariableOrigin::Local, - hidden: is_inline_synthetic_name(name), + hidden: self.is_hidden_debug_name(name), }); } + self.inject_covenant_overlay_bindings(scope, &mut bindings); bindings } + fn inject_covenant_overlay_bindings(&self, scope: &VisibleScope<'_, 'i>, bindings: &mut ScopeState<'i>) { + let Some(binding_spec) = self.covenant_ctx().and_then(|ctx| ctx.binding_for_function(&scope.context.function_name)) else { + return; + }; + let state_param_type = match parse_type_ref(&binding_spec.source_binding.param_type_name) { + Ok(type_ref) => type_ref, + Err(_) => { + bindings.insert( + binding_spec.source_binding.param_name.clone(), + ScopeBinding { + type_name: binding_spec.source_binding.param_type_name.clone(), + source: ScopeValueSource::Unavailable { + message: format!( + "failed to parse covenant state parameter type '{}'", + binding_spec.source_binding.param_type_name + ), + }, + origin: VariableOrigin::Param, + hidden: false, + }, + ); + return; + } + }; + + let injected = self.covenant_param_value.as_ref().and_then(|value| { + self.inject_debug_value_binding( + bindings, + &binding_spec.source_binding.param_name, + &state_param_type, + value, + VariableOrigin::Param, + ) + }); + if injected.is_some() { + return; + } + + let message = match binding_spec.binding { + CovenantDeclBinding::Auth => "prev_state is unavailable".to_string(), + CovenantDeclBinding::Cov => "prev_states is unavailable".to_string(), + }; + bindings.insert( + binding_spec.source_binding.param_name.clone(), + ScopeBinding { + type_name: binding_spec.source_binding.param_type_name.clone(), + source: ScopeValueSource::Unavailable { message }, + origin: VariableOrigin::Param, + hidden: false, + }, + ); + } + + fn inject_debug_value_binding( + &self, + bindings: &mut ScopeState<'i>, + name: &str, + type_ref: &TypeRef, + value: &DebugValue, + origin: VariableOrigin, + ) -> Option<()> { + let type_name = type_ref.type_name(); + let leaf_specs = flatten_contract_type_leaves(self.contract_ast.as_ref()?, type_ref).ok()?; + if leaf_specs.is_empty() { + let expr = debug_value_to_expr(value)?; + bindings.insert(name.to_string(), ScopeBinding { type_name, source: ScopeValueSource::Expr(expr), origin, hidden: false }); + return Some(()); + } + let leaf_bindings = leaf_specs + .iter() + .map(|(field_path, leaf_type)| DebugLeafBinding { + field_path: field_path.clone(), + type_name: leaf_type.type_name(), + stack_index: None, + }) + .collect::>(); + + bindings.insert( + name.to_string(), + ScopeBinding { + type_name, + source: ScopeValueSource::StructuredBinding { base_name: name.to_string(), leaf_bindings: leaf_bindings.clone() }, + origin, + hidden: false, + }, + ); + + for (field_path, leaf_type) in leaf_specs { + let leaf_value = structured_leaf_value(value, &field_path)?; + let leaf_expr = debug_value_to_expr(&leaf_value)?; + let leaf_name = flattened_struct_name(name, &field_path); + bindings.insert( + leaf_name, + ScopeBinding { type_name: leaf_type.type_name(), source: ScopeValueSource::Expr(leaf_expr), origin, hidden: true }, + ); + } + Some(()) + } + fn freeze_inline_snapshot_bindings(&self, bindings: &mut ScopeState<'i>, frame_id: u32) -> HashSet { let Some(parent_vars) = self.inline_scope_snapshots.get(&frame_id) else { return HashSet::new(); @@ -723,6 +928,21 @@ impl<'a, 'i> DebugSession<'a, 'i> { continue; } + if is_structured_type_name(&variable.type_name) + && let Ok(type_ref) = parse_type_ref(&variable.type_name) + && self.inject_debug_value_binding(bindings, name, &type_ref, &variable.value, variable.origin).is_some() + { + frozen_names.insert(name.clone()); + if let Some(contract) = self.contract_ast.as_ref() + && let Ok(leaf_specs) = flatten_contract_type_leaves(contract, &type_ref) + { + for (field_path, _) in leaf_specs { + frozen_names.insert(flattened_struct_name(name, &field_path)); + } + } + continue; + } + bindings.insert( name.clone(), ScopeBinding { @@ -755,7 +975,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { variables } - fn step_updates_are_visible(&self, step: &DebugStep<'i>, context: &VariableContext<'_>) -> bool { + fn step_updates_are_visible(&self, step: &DebugStep<'i>, context: &VariableContext) -> bool { if step.bytecode_start < context.function_start || step.bytecode_start >= context.function_end { return false; } @@ -837,7 +1057,17 @@ impl<'a, 'i> DebugSession<'a, 'i> { return; } - let step_id = self.current_scope_step_id(); + let step_id = self + .current_step_index + .map(|index| { + let parent_frame_id = if index == 0 { + 0 + } else { + self.step_at_order(index.saturating_sub(1)).map(|previous| previous.frame_id).unwrap_or(0) + }; + StepId::new(step.sequence, parent_frame_id) + }) + .unwrap_or_else(|| self.current_scope_step_id()); let Ok(scope_state) = self.scope_state(step_id) else { return; }; @@ -893,7 +1123,11 @@ impl<'a, 'i> DebugSession<'a, 'i> { // InlineCallEnter is steppable so `step_into` can land on a call // boundary and build call-stack transitions. InlineCallExit is not // steppable to avoid synthetic extra stops while unwinding. - matches!(&step.kind, StepKind::Source {} | StepKind::InlineCallEnter { .. }) + match &step.kind { + StepKind::Source {} => !is_synthetic_default_span(step.span), + StepKind::InlineCallEnter { .. } => true, + StepKind::InlineCallExit { .. } => false, + } } fn steppable_step_index_for_offset(&self, offset: usize, min_sequence: Option) -> Option { @@ -917,6 +1151,28 @@ impl<'a, 'i> DebugSession<'a, 'i> { }) } + fn initial_step_index_for_offset(&self, offset: usize, min_sequence: Option) -> Option { + let mut best: Option<(usize, u32, u32)> = None; + for (order_index, &step_index) in self.step_order.iter().enumerate() { + let Some(step) = self.debug_info.steps.get(step_index) else { + continue; + }; + if !self.is_steppable_step(step) + || is_synthetic_default_span(step.span) + || !range_matches_offset(step.bytecode_start, step.bytecode_end, offset) + || min_sequence.is_some_and(|min_sequence| step.sequence < min_sequence) + { + continue; + } + + match best { + Some((_, best_depth, best_sequence)) if (best_depth, best_sequence) <= (step.call_depth, step.sequence) => {} + _ => best = Some((order_index, step.call_depth, step.sequence)), + } + } + best.map(|(order_index, _, _)| order_index) + } + fn find_steppable_step_index(&self, predicate: impl Fn(&DebugStep<'i>) -> bool) -> Option { self.step_order.iter().enumerate().find_map(|(order_index, &step_index)| { let step = self.debug_info.steps.get(step_index)?; @@ -1016,7 +1272,9 @@ impl<'a, 'i> DebugSession<'a, 'i> { } fn step_hits_breakpoint(&self, step: &DebugStep<'i>) -> bool { - (step.span.line..=step.span.end_line).any(|line| self.breakpoints.contains(&line)) + matches!(step.kind, StepKind::Source {}) + && !is_synthetic_default_span(step.span) + && (step.span.line..=step.span.end_line).any(|line| self.breakpoints.contains(&line)) } /// Returns the current main stack as hex-encoded strings. @@ -1052,14 +1310,14 @@ impl<'a, 'i> DebugSession<'a, 'i> { pub fn build_failure_report(&self, error: &kaspa_txscript_errors::TxScriptError) -> FailureReport { let failure_span = self.current_span(); let call_stack = self.call_stack_with_spans(); - let innermost_function = self.current_function_name().unwrap_or("").to_string(); + let innermost_function = self.current_function_name().unwrap_or_else(|| "".to_string()); let innermost_vars: Vec = self.list_variables().unwrap_or_default().into_iter().filter(|v| v.origin != VariableOrigin::Constant).collect(); let mut frames = vec![FailureFrame { function_name: innermost_function.clone(), span: failure_span, variables: innermost_vars }]; - let entry_name = self.current_function_name().unwrap_or("").to_string(); + let entry_name = self.current_function_name().unwrap_or_else(|| "".to_string()); for idx in (0..call_stack.len()).rev() { let entry = &call_stack[idx]; let caller_vars: Vec = self @@ -1086,6 +1344,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { self.read_structured_binding_value(scope_state, base_name, &binding.type_name, leaf_bindings) } ScopeValueSource::Expr(expr) => self.evaluate_scope_expr_as(scope_state, expr, &binding.type_name), + ScopeValueSource::Unavailable { message } => Err(message.clone()), } } @@ -1140,7 +1399,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { ShadowBindingValue { name: name.clone(), stack_index: *from_top, value: self.read_stack_at_index(*from_top)? }, ); } - ScopeValueSource::StructuredBinding { .. } => {} + ScopeValueSource::StructuredBinding { .. } | ScopeValueSource::Unavailable { .. } => {} ScopeValueSource::Expr(expr) => { env.insert(name.clone(), expr.clone()); } @@ -1149,11 +1408,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { let mut shadow_bindings = shadow_by_name.into_values().collect::>(); shadow_bindings.sort_by(|left, right| right.stack_index.cmp(&left.stack_index)); - let stack_bindings = shadow_bindings - .iter() - .enumerate() - .map(|(index, binding)| (binding.name.clone(), (shadow_bindings.len() - 1 - index) as i64)) - .collect(); + let stack_bindings = shadow_bindings.iter().map(|binding| (binding.name.clone(), binding.stack_index)).collect(); Ok((shadow_bindings, env, stack_bindings, eval_types)) } @@ -1169,24 +1424,32 @@ impl<'a, 'i> DebugSession<'a, 'i> { fn execute_shadow_script(&self, script: &[u8]) -> Result, String> { let sig_cache = Cache::new(0); let reused_values = SigHashReusedValuesUnsync::new(); - let mut engine: DebugEngine<'_> = if let Some(shadow) = self.shadow_tx_context { - let ctx = EngineCtx::new(&sig_cache).with_reused(&reused_values).with_covenants_ctx(shadow.covenants_ctx); - TxScriptEngine::from_transaction_input( + if let Some(shadow) = self.shadow_tx_context.as_ref() { + let covenants_ctx = CovenantsContext::from_tx(shadow.tx) + .map_err(|err| format!("failed to build covenants context for shadow evaluation: {err}"))?; + let ctx = EngineCtx::new(&sig_cache).with_reused(&reused_values).with_covenants_ctx(&covenants_ctx); + let mut engine = TxScriptEngine::from_transaction_input( shadow.tx, shadow.input, shadow.input_index, shadow.utxo_entry, ctx, EngineFlags { covenants_enabled: true }, - ) + ); + for opcode in parse_script::, DebugReused>(script) { + let opcode = opcode.map_err(|err| format!("failed to parse shadow script: {err}"))?; + engine.execute_opcode(opcode).map_err(|err| format!("failed to execute shadow script: {err}"))?; + } + return engine.stacks().dstack.last().cloned().ok_or_else(|| "shadow VM produced an empty stack".to_string()); } else { - TxScriptEngine::new(EngineCtx::new(&sig_cache).with_reused(&reused_values), EngineFlags { covenants_enabled: true }) - }; - for opcode in parse_script::, DebugReused>(script) { - let opcode = opcode.map_err(|err| format!("failed to parse shadow script: {err}"))?; - engine.execute_opcode(opcode).map_err(|err| format!("failed to execute shadow script: {err}"))?; + let mut engine = + TxScriptEngine::new(EngineCtx::new(&sig_cache).with_reused(&reused_values), EngineFlags { covenants_enabled: true }); + for opcode in parse_script::, DebugReused>(script) { + let opcode = opcode.map_err(|err| format!("failed to parse shadow script: {err}"))?; + engine.execute_opcode(opcode).map_err(|err| format!("failed to execute shadow script: {err}"))?; + } + return engine.stacks().dstack.last().cloned().ok_or_else(|| "shadow VM produced an empty stack".to_string()); } - engine.stacks().dstack.last().cloned().ok_or_else(|| "shadow VM produced an empty stack".to_string()) } fn read_stack_at_index(&self, index: i64) -> Result, String> { @@ -1269,6 +1532,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { self.read_structured_binding_value(scope_state, base_name, &binding.type_name, leaf_bindings).ok() } ScopeValueSource::Expr(expr) => self.try_resolve_expr_value(scope_state, expr, visiting), + ScopeValueSource::Unavailable { .. } => None, } } @@ -1496,6 +1760,23 @@ fn range_matches_offset(bytecode_start: usize, bytecode_end: usize, offset: usiz if bytecode_start == bytecode_end { offset == bytecode_start } else { offset >= bytecode_start && offset < bytecode_end } } +fn is_synthetic_default_span(span: SourceSpan) -> bool { + span.line == 1 && span.col == 1 && span.end_line == 1 && span.end_col == 1 +} + +fn display_name_for_active_covenant_call(target: &ResolvedCovenantCallTarget) -> String { + match target.info.binding { + CovenantDeclBinding::Auth => target.info.source_name.clone(), + CovenantDeclBinding::Cov => { + if target.is_leader { + format!("{} [leader]", target.info.source_name) + } else { + format!("{} [delegate]", target.info.source_name) + } + } + } +} + fn map_expr_children_for_eval<'i, F>(expr: &'i Expr<'i>, map_child: &mut F) -> Result, String> where F: FnMut(&'i Expr<'i>) -> Result, String>, @@ -1687,6 +1968,37 @@ fn is_inline_synthetic_name(name: &str) -> bool { name.starts_with("__arg_") || name.starts_with("__struct_") } +fn contract_struct_fields<'i>(contract: &ContractAst<'i>, name: &str) -> Option> { + if name == "State" { + return Some(contract.fields.iter().map(|field| (field.name.clone(), field.type_ref.clone())).collect()); + } + contract + .structs + .iter() + .find(|item| item.name == name) + .map(|item| item.fields.iter().map(|field| (field.name.clone(), field.type_ref.clone())).collect()) +} + +fn flatten_contract_type_leaves<'i>(contract: &ContractAst<'i>, type_ref: &TypeRef) -> Result, TypeRef)>, String> { + let TypeBase::Custom(name) = &type_ref.base else { + return Ok(vec![(Vec::new(), type_ref.clone())]); + }; + let Some(fields) = contract_struct_fields(contract, name) else { + return Ok(vec![(Vec::new(), type_ref.clone())]); + }; + + let mut leaves = Vec::new(); + for (field_name, field_type_ref) in fields { + let mut nested_type = field_type_ref; + nested_type.array_dims.extend(type_ref.array_dims.iter().cloned()); + for (mut path, leaf_type) in flatten_contract_type_leaves(contract, &nested_type)? { + path.insert(0, field_name.clone()); + leaves.push((path, leaf_type)); + } + } + Ok(leaves) +} + fn is_structured_type_name(type_name: &str) -> bool { parse_type_ref(type_name).ok().is_some_and(|type_ref| is_structured_type_ref(&type_ref)) } diff --git a/debugger/session/src/test_runner.rs b/debugger/session/src/test_runner.rs index 8d65267b..6843de45 100644 --- a/debugger/session/src/test_runner.rs +++ b/debugger/session/src/test_runner.rs @@ -13,6 +13,8 @@ pub struct ContractTestCase { pub name: String, pub function: String, #[serde(default)] + pub delegate: bool, + #[serde(default)] pub constructor_args: Vec, #[serde(default)] pub args: Vec, @@ -52,10 +54,12 @@ pub struct TestTxInputScenario { pub sig_op_count: u8, pub utxo_value: u64, #[serde(default)] - pub covenant_id: Option, + pub covenant_id: Option, #[serde(default)] pub constructor_args: Option>, #[serde(default)] + pub state: Option, + #[serde(default)] pub signature_script_hex: Option, #[serde(default)] pub utxo_script_hex: Option, @@ -65,12 +69,14 @@ pub struct TestTxInputScenario { pub struct TestTxOutputScenario { pub value: u64, #[serde(default)] - pub covenant_id: Option, + pub covenant_id: Option, #[serde(default)] pub authorizing_input: Option, #[serde(default)] pub constructor_args: Option>, #[serde(default)] + pub state: Option, + #[serde(default)] pub script_hex: Option, #[serde(default)] pub p2pk_pubkey: Option, @@ -87,6 +93,7 @@ pub struct ResolvedContractTest { pub struct ContractTestCaseResolved { pub name: String, pub function: String, + pub delegate: bool, pub constructor_args: Vec, pub args: Vec, pub expect: TestExpectation, @@ -111,6 +118,7 @@ pub struct TestTxInputScenarioResolved { pub utxo_value: u64, pub covenant_id: Option, pub constructor_args: Option>, + pub state: Option, pub signature_script_hex: Option, pub utxo_script_hex: Option, } @@ -121,6 +129,7 @@ pub struct TestTxOutputScenarioResolved { pub covenant_id: Option, pub authorizing_input: Option, pub constructor_args: Option>, + pub state: Option, pub script_hex: Option, pub p2pk_pubkey: Option, } @@ -175,6 +184,7 @@ pub fn resolve_contract_test( let resolved = ContractTestCaseResolved { name: test.name, function: test.function, + delegate: test.delegate, constructor_args: values_to_args(&test.constructor_args)?, args: values_to_args(&test.args)?, expect: test.expect, @@ -206,8 +216,9 @@ pub fn resolve_tx_scenario(tx: TestTxScenario) -> Result Result( + contract: &ContractAst<'i>, + constructor_args: &[Expr<'i>], +) -> Result>, CompilerError> { + if contract.params.len() != constructor_args.len() { + return Err(CompilerError::Unsupported("constructor argument count mismatch".to_string())); + } + + let structs = build_struct_registry(contract)?; + let mut env: HashMap> = + contract.constants.iter().map(|constant| (constant.name.clone(), constant.expr.clone())).collect(); + + for (param, value) in contract.params.iter().zip(constructor_args.iter()) { + let param_type_name = type_name_from_ref(¶m.type_ref); + if !expr_matches_declared_type_ref(value, ¶m.type_ref, &structs) { + return Err(CompilerError::Unsupported(format!("constructor argument '{}' expects {}", param.name, param_type_name))); + } + env.insert(param.name.clone(), value.clone()); + } + + let mut resolved_fields = Vec::with_capacity(contract.fields.len()); + for field in &contract.fields { + if env.contains_key(&field.name) { + return Err(CompilerError::Unsupported(format!("duplicate contract field name: {}", field.name))); + } + + let type_name = field.type_ref.type_name(); + let resolved = resolve_expr(field.expr.clone(), &env, &mut HashSet::new())?; + if !expr_matches_declared_type_ref(&resolved, &field.type_ref, &structs) { + return Err(CompilerError::Unsupported(format!("contract field '{}' expects {}", field.name, type_name))); + } + + env.insert(field.name.clone(), resolved.clone()); + resolved_fields.push(DebugNamedValue { name: field.name.clone(), type_name, value: resolved }); + } + + Ok(resolved_fields) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FunctionInputAbi { pub name: String, @@ -80,6 +123,8 @@ pub struct CompiledContract<'i> { pub ast: ContractAst<'i>, pub abi: Vec, pub without_selector: bool, + #[serde(default)] + pub covenant_infos: Vec, pub state_layout: CompiledStateLayout, pub debug_info: Option>, } @@ -930,7 +975,7 @@ fn compile_contract_impl<'i>( constants.insert(param.name.clone(), value.clone()); } - let lowered_contract = lower_covenant_declarations(contract, &constants)?; + let (lowered_contract, covenant_infos) = lower_covenant_declarations(contract, &constants)?; let structs = build_struct_registry(&lowered_contract)?; validate_struct_graph(&structs)?; validate_contract_struct_usage(&lowered_contract, &structs)?; @@ -1028,6 +1073,7 @@ fn compile_contract_impl<'i>( ast: lowered_contract.clone(), abi: function_abi_entries, without_selector, + covenant_infos: covenant_infos.clone(), state_layout, debug_info, }); @@ -1041,6 +1087,7 @@ fn compile_contract_impl<'i>( ast: lowered_contract.clone(), abi: function_abi_entries, without_selector, + covenant_infos: covenant_infos.clone(), state_layout, debug_info, }); @@ -2214,6 +2261,17 @@ fn infer_fixed_array_type_from_initializer<'i>( } impl<'i> CompiledContract<'i> { + pub fn resolve_covenant_call_target( + &self, + function_name: &str, + options: CovenantDeclCallOptions, + ) -> Option { + self.covenant_infos.iter().find(|info| info.source_name == function_name).cloned().and_then(|info| { + let generated_entrypoint_name = info.generated_entrypoint_name(options.is_leader)?.to_string(); + Some(ResolvedCovenantCallTarget { generated_entrypoint_name, info, is_leader: options.is_leader }) + }) + } + pub fn build_sig_script(&self, function_name: &str, args: Vec>) -> Result, CompilerError> { let structs = build_struct_registry(&self.ast)?; let function = self @@ -2250,22 +2308,10 @@ impl<'i> CompiledContract<'i> { args: Vec>, options: CovenantDeclCallOptions, ) -> Result, CompilerError> { - let auth_entrypoint = generated_covenant_entrypoint_name(function_name); - if self.abi.iter().any(|entry| entry.name == auth_entrypoint) { - return self.build_sig_script(&auth_entrypoint, args); - } - - let entrypoint = if options.is_leader { - generated_covenant_leader_entrypoint_name(function_name) - } else { - generated_covenant_delegate_entrypoint_name(function_name) - }; - - if self.abi.iter().any(|entry| entry.name == entrypoint) { - return self.build_sig_script(&entrypoint, args); - } - - Err(CompilerError::Unsupported(format!("covenant declaration '{}' not found", function_name))) + let target = self + .resolve_covenant_call_target(function_name, options) + .ok_or_else(|| CompilerError::Unsupported(format!("covenant declaration '{}' not found", function_name)))?; + self.build_sig_script(&target.generated_entrypoint_name, args) } } diff --git a/silverscript-lang/src/compiler/covenant_declarations.rs b/silverscript-lang/src/compiler/covenant_declarations.rs index dd8f0bd7..0c27ef44 100644 --- a/silverscript-lang/src/compiler/covenant_declarations.rs +++ b/silverscript-lang/src/compiler/covenant_declarations.rs @@ -1,17 +1,91 @@ use super::*; +use serde::{Deserialize, Serialize}; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CovenantBinding { +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CovenantDeclBinding { Auth, Cov, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CovenantMode { +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum CovenantDeclMode { Verification, Transition, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CovenantLoweredNames { + pub policy_function: String, + pub auth_entrypoint: Option, + pub leader_entrypoint: Option, + pub delegate_entrypoint: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CovenantSourceBindingInfo { + pub param_name: String, + pub param_type_name: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CovenantDeclInfo { + pub source_name: String, + pub binding: CovenantDeclBinding, + pub mode: CovenantDeclMode, + pub lowered: CovenantLoweredNames, + pub source_binding: CovenantSourceBindingInfo, +} + +impl CovenantDeclInfo { + pub fn generated_function_names(&self) -> impl Iterator { + [ + Some(self.lowered.policy_function.as_str()), + self.lowered.auth_entrypoint.as_deref(), + self.lowered.leader_entrypoint.as_deref(), + self.lowered.delegate_entrypoint.as_deref(), + ] + .into_iter() + .flatten() + } + + pub fn matches_generated_name(&self, function_name: &str) -> bool { + self.generated_function_names().any(|name| name == function_name) + } + + pub fn generated_entrypoint_name(&self, is_leader: bool) -> Option<&str> { + match self.binding { + CovenantDeclBinding::Auth => self.lowered.auth_entrypoint.as_deref(), + CovenantDeclBinding::Cov => { + if is_leader { + self.lowered.leader_entrypoint.as_deref() + } else { + self.lowered.delegate_entrypoint.as_deref() + } + } + } + } + + pub fn display_name_for_function(&self, function_name: &str) -> Option { + if self.lowered.policy_function == function_name || self.lowered.auth_entrypoint.as_deref() == Some(function_name) { + return Some(self.source_name.clone()); + } + if self.lowered.leader_entrypoint.as_deref() == Some(function_name) { + return Some(format!("{} [leader]", self.source_name)); + } + if self.lowered.delegate_entrypoint.as_deref() == Some(function_name) { + return Some(format!("{} [delegate]", self.source_name)); + } + None + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ResolvedCovenantCallTarget { + pub info: CovenantDeclInfo, + pub generated_entrypoint_name: String, + pub is_leader: bool, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CovenantGroups { Single, @@ -26,8 +100,8 @@ enum CovenantTermination { #[derive(Debug, Clone)] struct CovenantDeclaration<'i> { - binding: CovenantBinding, - mode: CovenantMode, + binding: CovenantDeclBinding, + mode: CovenantDeclMode, groups: CovenantGroups, singleton: bool, termination: CovenantTermination, @@ -48,8 +122,9 @@ enum OutputStateSource<'i> { pub(super) fn lower_covenant_declarations<'i>( contract: &ContractAst<'i>, constants: &HashMap>, -) -> Result, CompilerError> { +) -> Result<(ContractAst<'i>, Vec), CompilerError> { let mut lowered = Vec::new(); + let mut infos = Vec::new(); for function in &contract.functions { if function.attributes.is_empty() { @@ -57,9 +132,11 @@ pub(super) fn lower_covenant_declarations<'i>( continue; } - let declaration = parse_covenant_declaration(function, constants)?; + let declaration = parse_covenant_declaration(function, constants, true)?; validate_covenant_policy_state_shape(function, &declaration, &contract.fields)?; + infos.push(build_covenant_decl_info(function, declaration.binding, declaration.mode)); + let policy_name = generated_covenant_policy_name(&function.name); let mut policy = function.clone(); @@ -69,13 +146,13 @@ pub(super) fn lower_covenant_declarations<'i>( lowered.push(policy.clone()); match declaration.binding { - CovenantBinding::Auth => { + CovenantDeclBinding::Auth => { let entrypoint_name = generated_covenant_entrypoint_name(&function.name); let mut wrapper = build_auth_wrapper(&policy, &policy_name, declaration.clone(), entrypoint_name, &contract.fields)?; wrapper.params = preserved_entrypoint_params(function, declaration, true, &contract.fields); lowered.push(wrapper); } - CovenantBinding::Cov => { + CovenantDeclBinding::Cov => { let leader_name = generated_covenant_leader_entrypoint_name(&function.name); let mut leader_wrapper = build_cov_wrapper(&policy, &policy_name, declaration.clone(), leader_name, true, &contract.fields)?; @@ -93,12 +170,36 @@ pub(super) fn lower_covenant_declarations<'i>( let mut lowered_contract = contract.clone(); lowered_contract.functions = lowered; - Ok(lowered_contract) + infos.sort_by(|left, right| left.source_name.cmp(&right.source_name)); + Ok((lowered_contract, infos)) +} + +fn build_covenant_decl_info<'i>(function: &FunctionAst<'i>, binding: CovenantDeclBinding, mode: CovenantDeclMode) -> CovenantDeclInfo { + let source_binding = function + .params + .first() + .map(|param| CovenantSourceBindingInfo { param_name: param.name.clone(), param_type_name: param.type_ref.type_name() }) + .unwrap_or_else(|| CovenantSourceBindingInfo { param_name: String::new(), param_type_name: String::new() }); + CovenantDeclInfo { + source_name: function.name.clone(), + binding, + mode, + lowered: CovenantLoweredNames { + policy_function: generated_covenant_policy_name(&function.name), + auth_entrypoint: (binding == CovenantDeclBinding::Auth).then(|| generated_covenant_entrypoint_name(&function.name)), + leader_entrypoint: (binding == CovenantDeclBinding::Cov) + .then(|| generated_covenant_leader_entrypoint_name(&function.name)), + delegate_entrypoint: (binding == CovenantDeclBinding::Cov) + .then(|| generated_covenant_delegate_entrypoint_name(&function.name)), + }, + source_binding, + } } fn parse_covenant_declaration<'i>( function: &FunctionAst<'i>, constants: &HashMap>, + emit_warnings: bool, ) -> Result, CompilerError> { #[derive(Clone, Copy, PartialEq, Eq)] enum CovenantSyntax { @@ -192,13 +293,13 @@ fn parse_covenant_declaration<'i>( return Err(CompilerError::Unsupported("covenant 'to' must be >= 1".to_string())); } - let default_binding = if from_value == 1 { CovenantBinding::Auth } else { CovenantBinding::Cov }; + let default_binding = if from_value == 1 { CovenantDeclBinding::Auth } else { CovenantDeclBinding::Cov }; let binding = match args_by_name.get("binding").copied() { Some(expr) => { let binding_name = parse_attr_ident_arg("binding", Some(expr))?; match binding_name.as_str() { - "auth" => CovenantBinding::Auth, - "cov" => CovenantBinding::Cov, + "auth" => CovenantDeclBinding::Auth, + "cov" => CovenantDeclBinding::Cov, other => { return Err(CompilerError::Unsupported(format!("covenant binding must be auth|cov, got '{}'", other))); } @@ -211,8 +312,8 @@ fn parse_covenant_declaration<'i>( Some(expr) => { let mode_name = parse_attr_ident_arg("mode", Some(expr))?; match mode_name.as_str() { - "verification" => CovenantMode::Verification, - "transition" => CovenantMode::Transition, + "verification" => CovenantDeclMode::Verification, + "transition" => CovenantDeclMode::Transition, other => { return Err(CompilerError::Unsupported(format!("covenant mode must be verification|transition, got '{}'", other))); } @@ -220,9 +321,9 @@ fn parse_covenant_declaration<'i>( } None => { if function.return_types.is_empty() { - CovenantMode::Verification + CovenantDeclMode::Verification } else { - CovenantMode::Transition + CovenantDeclMode::Transition } } }; @@ -239,8 +340,8 @@ fn parse_covenant_declaration<'i>( } } None => match binding { - CovenantBinding::Auth => CovenantGroups::Multiple, - CovenantBinding::Cov => CovenantGroups::Single, + CovenantDeclBinding::Auth => CovenantGroups::Multiple, + CovenantDeclBinding::Cov => CovenantGroups::Single, }, }; @@ -261,30 +362,30 @@ fn parse_covenant_declaration<'i>( None => CovenantTermination::Disallowed, }; - if binding == CovenantBinding::Auth && from_value != 1 { + if binding == CovenantDeclBinding::Auth && from_value != 1 { return Err(CompilerError::Unsupported("binding=auth requires from = 1".to_string())); } - if binding == CovenantBinding::Cov && from_value == 1 && args_by_name.contains_key("binding") { + if emit_warnings && binding == CovenantDeclBinding::Cov && from_value == 1 && args_by_name.contains_key("binding") { eprintln!( "warning: #[covenant(...)] on function '{}' uses binding=cov with from=1; binding=auth is usually a better default", function.name ); } - if binding == CovenantBinding::Cov && groups == CovenantGroups::Multiple { + if binding == CovenantDeclBinding::Cov && groups == CovenantGroups::Multiple { return Err(CompilerError::Unsupported("binding=cov with groups=multiple is not supported yet".to_string())); } - if args_by_name.contains_key("termination") && mode != CovenantMode::Transition { + if args_by_name.contains_key("termination") && mode != CovenantDeclMode::Transition { return Err(CompilerError::Unsupported("termination is only supported in mode=transition".to_string())); } if args_by_name.contains_key("termination") && !(from_value == 1 && to_value == 1) { return Err(CompilerError::Unsupported("termination is only supported for singleton covenants (from=1, to=1)".to_string())); } - if mode == CovenantMode::Verification && !function.return_types.is_empty() { + if mode == CovenantDeclMode::Verification && !function.return_types.is_empty() { return Err(CompilerError::Unsupported("verification mode policy functions must not declare return values".to_string())); } - if mode == CovenantMode::Transition && function.return_types.is_empty() { + if mode == CovenantDeclMode::Transition && function.return_types.is_empty() { return Err(CompilerError::Unsupported("transition mode policy functions must declare return values".to_string())); } @@ -317,7 +418,7 @@ fn validate_covenant_policy_state_shape<'i>( } match (declaration.binding, declaration.mode) { - (CovenantBinding::Auth, CovenantMode::Verification) => { + (CovenantDeclBinding::Auth, CovenantDeclMode::Verification) => { if policy.params.len() < 2 || !is_state_type_ref(&policy.params[0].type_ref) || !is_state_array_type_ref(&policy.params[1].type_ref) @@ -328,7 +429,7 @@ fn validate_covenant_policy_state_shape<'i>( ))); } } - (CovenantBinding::Cov, CovenantMode::Verification) => { + (CovenantDeclBinding::Cov, CovenantDeclMode::Verification) => { if policy.params.len() < 2 || !is_state_array_type_ref(&policy.params[0].type_ref) || !is_state_array_type_ref(&policy.params[1].type_ref) @@ -339,7 +440,7 @@ fn validate_covenant_policy_state_shape<'i>( ))); } } - (CovenantBinding::Auth, CovenantMode::Transition) => { + (CovenantDeclBinding::Auth, CovenantDeclMode::Transition) => { if policy.params.is_empty() || !is_state_type_ref(&policy.params[0].type_ref) { return Err(CompilerError::Unsupported(format!( "mode=transition with binding=auth on function '{}' expects parameters '(State prev_state, ...)'", @@ -347,7 +448,7 @@ fn validate_covenant_policy_state_shape<'i>( ))); } } - (CovenantBinding::Cov, CovenantMode::Transition) => { + (CovenantDeclBinding::Cov, CovenantDeclMode::Transition) => { if policy.params.is_empty() || !is_state_array_type_ref(&policy.params[0].type_ref) { return Err(CompilerError::Unsupported(format!( "mode=transition with binding=cov on function '{}' expects parameters '(State[] prev_states, ...)'", @@ -357,7 +458,7 @@ fn validate_covenant_policy_state_shape<'i>( } } - if declaration.mode == CovenantMode::Transition { + if declaration.mode == CovenantDeclMode::Transition { if policy.return_types.len() != 1 { return Err(CompilerError::Unsupported(format!( "mode=transition on function '{}' with contract state expects exactly one return type: 'State' or 'State[]'", @@ -392,16 +493,16 @@ fn preserved_entrypoint_params<'i>( ) -> Vec> { if contract_fields.is_empty() { return match (declaration.binding, leader) { - (CovenantBinding::Cov, false) => Vec::new(), + (CovenantDeclBinding::Cov, false) => Vec::new(), _ => function.params.clone(), }; } match (declaration.binding, declaration.mode, leader) { - (CovenantBinding::Auth, _, _) => function.params.iter().skip(1).cloned().collect(), - (CovenantBinding::Cov, CovenantMode::Verification, true) => function.params.iter().skip(1).cloned().collect(), - (CovenantBinding::Cov, CovenantMode::Transition, true) => function.params.iter().skip(1).cloned().collect(), - (CovenantBinding::Cov, _, false) => Vec::new(), + (CovenantDeclBinding::Auth, _, _) => function.params.iter().skip(1).cloned().collect(), + (CovenantDeclBinding::Cov, CovenantDeclMode::Verification, true) => function.params.iter().skip(1).cloned().collect(), + (CovenantDeclBinding::Cov, CovenantDeclMode::Transition, true) => function.params.iter().skip(1).cloned().collect(), + (CovenantDeclBinding::Cov, _, false) => Vec::new(), } } @@ -433,7 +534,7 @@ fn build_auth_wrapper<'i>( if !contract_fields.is_empty() { match declaration.mode { - CovenantMode::Verification => { + CovenantDeclMode::Verification => { entrypoint_params = policy.params.iter().skip(1).cloned().collect(); let prev_state_name = &policy.params[0].name; let new_states_name = &policy.params[1].name; @@ -457,7 +558,7 @@ fn build_auth_wrapper<'i>( new_states_name, ); } - CovenantMode::Transition => { + CovenantDeclMode::Transition => { entrypoint_params = policy.params.iter().skip(1).cloned().collect(); let prev_state_name = &policy.params[0].name; body.push(var_def_statement( @@ -526,7 +627,7 @@ fn build_auth_wrapper<'i>( if !contract_fields.is_empty() { match state_source { OutputStateSource::Single(next_state_expr) => { - if declaration.mode == CovenantMode::Transition || declaration.singleton { + if declaration.mode == CovenantDeclMode::Transition || declaration.singleton { body.push(require_statement(binary_expr(BinaryOp::Eq, identifier_expr(out_count_name), Expr::int(1)))); let out_idx_name = "__cov_out_idx"; body.push(var_def_statement( @@ -607,7 +708,7 @@ fn build_cov_wrapper<'i>( if !contract_fields.is_empty() { match declaration.mode { - CovenantMode::Verification => { + CovenantDeclMode::Verification => { leader_params = policy.params.iter().skip(1).cloned().collect(); let prev_states_name = &policy.params[0].name; let new_states_name = &policy.params[1].name; @@ -637,7 +738,7 @@ fn build_cov_wrapper<'i>( new_states_name, ); } - CovenantMode::Transition => { + CovenantDeclMode::Transition => { leader_params = policy.params.iter().skip(1).cloned().collect(); let prev_states_name = &policy.params[0].name; append_cov_input_state_reads_into_state_array( @@ -709,7 +810,7 @@ fn build_cov_wrapper<'i>( if !contract_fields.is_empty() { match state_source { OutputStateSource::Single(next_state_expr) => { - if declaration.mode == CovenantMode::Transition || declaration.singleton { + if declaration.mode == CovenantDeclMode::Transition || declaration.singleton { body.push(require_statement(binary_expr(BinaryOp::Eq, identifier_expr(out_count_name), Expr::int(1)))); let out_idx_name = "__cov_out_idx"; body.push(var_def_statement( @@ -996,18 +1097,18 @@ fn append_policy_call_and_capture_next_state<'i>( body: &mut Vec>, policy: &FunctionAst<'i>, policy_name: &str, - mode: CovenantMode, + mode: CovenantDeclMode, singleton: bool, termination: CovenantTermination, contract_fields: &[ContractFieldAst<'i>], call_args: Vec>, ) -> Result, CompilerError> { match mode { - CovenantMode::Verification => { + CovenantDeclMode::Verification => { body.push(call_statement(policy_name, call_args)); Ok(OutputStateSource::Single(state_object_expr_from_contract_fields(contract_fields))) } - CovenantMode::Transition => { + CovenantDeclMode::Transition => { if policy.return_types.len() != contract_fields.len() { return Err(CompilerError::Unsupported(format!( "transition mode policy function '{}' must return exactly {} values (one per contract field)", From 5a8aa71e10305e4a771ce7cd316de9247dec0569 Mon Sep 17 00:00:00 2001 From: Manyfestation <240733973+Manyfestation@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:00:10 +0300 Subject: [PATCH 02/11] Fix covenant debugger CI and port docs --- README.md | 4 + debugger/cli/src/main.rs | 60 +- debugger/session/src/args.rs | 3 +- debugger/session/src/session.rs | 15 +- debugger/session/tests/debug_session_tests.rs | 6 +- docs/DEBUGGER_STRUCT_STATE_OVERVIEW.md | 1121 +++++++++++++++++ docs/DEBUGGER_STRUCT_STATE_QUICK_FLOW.md | 710 +++++++++++ examples/debug_small_inline.sil | 15 + examples/debug_struct_state_matrix.ctor.json | 30 + examples/debug_struct_state_matrix.sil | 110 ++ 10 files changed, 2030 insertions(+), 44 deletions(-) create mode 100644 docs/DEBUGGER_STRUCT_STATE_OVERVIEW.md create mode 100644 docs/DEBUGGER_STRUCT_STATE_QUICK_FLOW.md create mode 100644 examples/debug_small_inline.sil create mode 100644 examples/debug_struct_state_matrix.ctor.json create mode 100644 examples/debug_struct_state_matrix.sil diff --git a/README.md b/README.md index 3926cc03..aa326299 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,10 @@ cargo run -p cli-debugger -- \ See [TUTORIAL.md](docs/TUTORIAL.md) for a full language and usage tutorial, and [DECL.md](docs/DECL.md) for the covenant declaration spec. +For a debugger-focused walkthrough of structured state, source-level variable reconstruction, and evaluation flow, see [DEBUGGER_STRUCT_STATE_OVERVIEW.md](docs/DEBUGGER_STRUCT_STATE_OVERVIEW.md) and the shorter [DEBUGGER_STRUCT_STATE_QUICK_FLOW.md](docs/DEBUGGER_STRUCT_STATE_QUICK_FLOW.md). + +The accompanying debugger examples live under `examples/`, including `debug_struct_state_matrix.sil` and its constructor fixture. + ## Credits See [CREDITS.md](CREDITS.md) for acknowledgements and credits. diff --git a/debugger/cli/src/main.rs b/debugger/cli/src/main.rs index 8c89f475..314f9878 100644 --- a/debugger/cli/src/main.rs +++ b/debugger/cli/src/main.rs @@ -573,40 +573,32 @@ fn main() -> Result<(), Box> { } else { None }; - let (script_path, raw_constructor_args, selected_name, raw_args, delegate, tx_scenario, expect) = - if let Some(test_file) = inferred_test_file.as_deref() { - let test_name = cli.test_name.as_deref().ok_or("--test-name requires --test-file or SCRIPT_PATH")?; - let script_override = cli.script_path.as_deref().map(Path::new); - let resolved = resolve_contract_test(test_file, test_name, script_override) - .map_err(|e| -> Box { e.into() })?; - let constructor_args = - if !cli.raw_ctor_args.is_empty() { cli.raw_ctor_args.clone() } else { resolved.test.constructor_args }; - let fname = cli.function_name.clone().unwrap_or(resolved.test.function); - let args = if !cli.raw_args.is_empty() { cli.raw_args.clone() } else { resolved.test.args }; - let expect = Some(resolved.test.expect); - ( - resolved.script_path, - constructor_args, - fname, - args, - cli.delegate || resolved.test.delegate, - resolved.test.tx, - expect, - ) - } else { - let path = cli.script_path.as_deref().ok_or("missing script path: pass SCRIPT_PATH or --test-file")?; - let constructor_args = cli.raw_ctor_args.clone(); - let entrypoint_args = cli.raw_args.clone(); - ( - PathBuf::from(path), - constructor_args, - cli.function_name.clone().unwrap_or_default(), - entrypoint_args, - cli.delegate, - None, - None, - ) - }; + let (script_path, raw_constructor_args, selected_name, raw_args, delegate, tx_scenario, expect) = if let Some(test_file) = + inferred_test_file.as_deref() + { + let test_name = cli.test_name.as_deref().ok_or("--test-name requires --test-file or SCRIPT_PATH")?; + let script_override = cli.script_path.as_deref().map(Path::new); + let resolved = + resolve_contract_test(test_file, test_name, script_override).map_err(|e| -> Box { e.into() })?; + let constructor_args = if !cli.raw_ctor_args.is_empty() { cli.raw_ctor_args.clone() } else { resolved.test.constructor_args }; + let fname = cli.function_name.clone().unwrap_or(resolved.test.function); + let args = if !cli.raw_args.is_empty() { cli.raw_args.clone() } else { resolved.test.args }; + let expect = Some(resolved.test.expect); + (resolved.script_path, constructor_args, fname, args, cli.delegate || resolved.test.delegate, resolved.test.tx, expect) + } else { + let path = cli.script_path.as_deref().ok_or("missing script path: pass SCRIPT_PATH or --test-file")?; + let constructor_args = cli.raw_ctor_args.clone(); + let entrypoint_args = cli.raw_args.clone(); + ( + PathBuf::from(path), + constructor_args, + cli.function_name.clone().unwrap_or_default(), + entrypoint_args, + cli.delegate, + None, + None, + ) + }; let source = fs::read_to_string(&script_path)?; let parsed_contract = parse_contract_ast(&source)?; diff --git a/debugger/session/src/args.rs b/debugger/session/src/args.rs index 2347d773..9038fe60 100644 --- a/debugger/session/src/args.rs +++ b/debugger/session/src/args.rs @@ -276,8 +276,7 @@ pub fn parse_call_args<'i>(contract: &ContractAst<'_>, function_name: &str, raw_ } pub fn parse_state_value<'i>(contract: &ContractAst<'_>, raw_state: &str) -> Result, String> { - let value = - serde_json::from_str::(raw_state).map_err(|err| format!("invalid State value '{raw_state}': {err}"))?; + let value = serde_json::from_str::(raw_state).map_err(|err| format!("invalid State value '{raw_state}': {err}"))?; let Value::Object(entries) = value else { return Err("State value must be a JSON object".to_string()); }; diff --git a/debugger/session/src/session.rs b/debugger/session/src/session.rs index 27077d5e..f6640a21 100644 --- a/debugger/session/src/session.rs +++ b/debugger/session/src/session.rs @@ -12,7 +12,9 @@ use silverscript_lang::ast::{ ContractAst, Expr, ExprKind, StateFieldExpr, TypeBase, TypeRef, UnarySuffixKind, parse_contract_ast, parse_expression_ast, parse_type_ref, }; -use silverscript_lang::compiler::{CovenantDeclBinding, CovenantDeclInfo, ResolvedCovenantCallTarget, compile_debug_expr, flattened_struct_name}; +use silverscript_lang::compiler::{ + CovenantDeclBinding, CovenantDeclInfo, ResolvedCovenantCallTarget, compile_debug_expr, flattened_struct_name, +}; use silverscript_lang::debug_info::{ DebugFunctionRange, DebugInfo, DebugLeafBinding, DebugNamedValue, DebugParamBinding, DebugStep, DebugVariableUpdate, RuntimeBinding, SourceSpan, StepId, StepKind, @@ -1408,7 +1410,12 @@ impl<'a, 'i> DebugSession<'a, 'i> { let mut shadow_bindings = shadow_by_name.into_values().collect::>(); shadow_bindings.sort_by(|left, right| right.stack_index.cmp(&left.stack_index)); - let stack_bindings = shadow_bindings.iter().map(|binding| (binding.name.clone(), binding.stack_index)).collect(); + let binding_count = shadow_bindings.len(); + let stack_bindings = shadow_bindings + .iter() + .enumerate() + .map(|(index, binding)| (binding.name.clone(), (binding_count - index - 1) as i64)) + .collect(); Ok((shadow_bindings, env, stack_bindings, eval_types)) } @@ -1440,7 +1447,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { let opcode = opcode.map_err(|err| format!("failed to parse shadow script: {err}"))?; engine.execute_opcode(opcode).map_err(|err| format!("failed to execute shadow script: {err}"))?; } - return engine.stacks().dstack.last().cloned().ok_or_else(|| "shadow VM produced an empty stack".to_string()); + engine.stacks().dstack.last().cloned().ok_or_else(|| "shadow VM produced an empty stack".to_string()) } else { let mut engine = TxScriptEngine::new(EngineCtx::new(&sig_cache).with_reused(&reused_values), EngineFlags { covenants_enabled: true }); @@ -1448,7 +1455,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { let opcode = opcode.map_err(|err| format!("failed to parse shadow script: {err}"))?; engine.execute_opcode(opcode).map_err(|err| format!("failed to execute shadow script: {err}"))?; } - return engine.stacks().dstack.last().cloned().ok_or_else(|| "shadow VM produced an empty stack".to_string()); + engine.stacks().dstack.last().cloned().ok_or_else(|| "shadow VM produced an empty stack".to_string()) } } diff --git a/debugger/session/tests/debug_session_tests.rs b/debugger/session/tests/debug_session_tests.rs index ec838d84..b5e19535 100644 --- a/debugger/session/tests/debug_session_tests.rs +++ b/debugger/session/tests/debug_session_tests.rs @@ -1220,8 +1220,7 @@ contract CovLocal() { EngineFlags { covenants_enabled: true }, ); - let shadow_ctx = - ShadowTxContext { tx: &populated_tx, input: input_ref, input_index: 0, utxo_entry: utxo_ref, covenants_ctx: &cov_ctx }; + let shadow_ctx = ShadowTxContext { tx: &populated_tx, input: input_ref, input_index: 0, utxo_entry: utxo_ref }; let mut session = DebugSession::full(&sigscript, &compiled.script, source, debug_info, engine)?.with_shadow_tx_context(shadow_ctx); session.run_to_first_executed_statement()?; @@ -1285,8 +1284,7 @@ contract CovEval() { EngineFlags { covenants_enabled: true }, ); - let shadow_ctx = - ShadowTxContext { tx: &populated_tx, input: input_ref, input_index: 0, utxo_entry: utxo_ref, covenants_ctx: &cov_ctx }; + let shadow_ctx = ShadowTxContext { tx: &populated_tx, input: input_ref, input_index: 0, utxo_entry: utxo_ref }; let mut session = DebugSession::full(&sigscript, &compiled.script, source, debug_info, engine)?.with_shadow_tx_context(shadow_ctx); session.run_to_first_executed_statement()?; diff --git a/docs/DEBUGGER_STRUCT_STATE_OVERVIEW.md b/docs/DEBUGGER_STRUCT_STATE_OVERVIEW.md new file mode 100644 index 00000000..17d2900e --- /dev/null +++ b/docs/DEBUGGER_STRUCT_STATE_OVERVIEW.md @@ -0,0 +1,1121 @@ +# Struct/State Debugger Overview + +If you want the shorter version first, read [`DEBUGGER_STRUCT_STATE_QUICK_FLOW.md`](./DEBUGGER_STRUCT_STATE_QUICK_FLOW.md). + +## Why this document exists + +This document is meant to explain the debugger as a system, not just list the struct/state feature work. + +The key question is: + +- how do we go from source code with `State`, custom `struct`s, inline calls, and source-level names +- to compiled bytecode +- and then back to a source-level debugging experience + +If you already understand this much: + +- the compiler records debug info while compiling +- that debug info maps source steps to bytecode ranges +- the session steps through those recorded ranges + +then this document fills in the missing middle: + +- what happens to `State` and `struct` values during compilation +- what extra metadata had to be recorded +- how `vars` and `eval` reconstruct source-level values from flattened runtime data + +This doc uses one concrete contract as the running example: + +- [`examples/debug_struct_state_matrix.sil`](../examples/debug_struct_state_matrix.sil) + +Its constructor args live in: + +- [`examples/debug_struct_state_matrix.ctor.json`](../examples/debug_struct_state_matrix.ctor.json) + +## One Mental Model + +There are really 4 layers: + +1. Source layer + `State`, `Pair`, `next_state.amount`, `next_pairs[1].code` +2. Runtime layer + flattened leaf bindings like `__struct_next_state_amount` +3. Debug metadata layer + bytecode ranges, variable updates, structured leaf metadata, param mappings +4. Session layer + reconstruct source-level variables and evaluate expressions while stepping + +The most important fact is this: + +- `State` and custom `struct`s are source-level concepts +- the runtime does not keep them as one opaque object +- the compiler lowers them into leaf values + +So the debugger has to do two opposite jobs: + +- during compile/eval lowering, go from source-level struct access to flattened leaf access +- during display, go from flattened leaf values back to source-level objects and arrays + +That is the whole story. + +## The Reference Contract + +Here is the important part of the example contract: + +```sil +contract DebugStructStateMatrix(Pair seed_pair) { + struct Pair { + int amount; + byte[2] code; + } + + int amount = 1; + bool active = true; + byte[1] tag = 0xaa; + + function inspect_inner(State inner_state, Pair inner_pair) { + int bumped = inner_state.amount + amount; + require(bumped > 0); + inspect_deeper(inner_state, inner_pair); + } + + entrypoint function inspect_state(State next_state) { + int bumped = next_state.amount + amount; + require(next_state.active == active); + } + + entrypoint function inspect_state_array(State[] next_states) { + int delta = next_states[1].amount - next_states[0].amount; + require(next_states.length == 2); + } + + entrypoint function inspect_pair(Pair next_pair) { + byte[2] pair_code = next_pair.code; + require(pair_code == next_pair.code); + } + + entrypoint function inspect_inline(State next_state, Pair next_pair) { + inspect_inner(next_state, next_pair); + require(next_state.active == active); + } +} +``` + +This one file exercises all the interesting debugger cases: + +- top-level `State` +- top-level `State[]` +- custom `struct` +- custom `struct[]` +- inline structured params +- constructor args visible in debugger scope + +One practical note: + +- `seed_pair` is included so the debugger can expose a structured constructor arg +- in this example it is not used inside contract logic + +## Part 1: What the Compiler Does With `State` and `struct` + +### Source view + +At the source level you can write: + +```sil +next_state.amount +next_states[0].amount +next_pair.code +``` + +This reads like object/field access. + +### Runtime view + +The compiler does not keep a `State` value as one runtime object. + +Instead it flattens it into leaf bindings. + +If `State` is: + +- `amount: int` +- `active: bool` +- `tag: byte[1]` + +then a source binding: + +```sil +State next_state +``` + +becomes the runtime/debug leaf model: + +```text +__struct_next_state_amount : int +__struct_next_state_active : bool +__struct_next_state_tag : byte[1] +``` + +For arrays of structs, each leaf becomes its own array: + +```text +__struct_next_states_amount : int[] +__struct_next_states_active : bool[] +__struct_next_states_tag : byte[1][] +``` + +For `Pair[]`: + +```text +__struct_next_pairs_amount : int[] +__struct_next_pairs_code : byte[2][] +``` + +### Why flattening exists + +This is not debugger-specific. This is how the compiler already reasons about structured runtime data. + +The useful helpers are: + +- `flattened_struct_name(base, path)` +- `flatten_type_ref_leaves(...)` +- `lower_expr(...)` +- `lower_runtime_struct_expr(...)` +- `lower_struct_array_value_expr(...)` + +They do different jobs. + +### Scalar structured access + +For a scalar expression like: + +```sil +next_state.amount + amount +``` + +the compiler lowers only the structured access part: + +```text +__struct_next_state_amount + amount +``` + +This is what `lower_expr(...)` is for. + +Another example: + +```sil +next_states[1].amount - next_states[0].amount +``` + +becomes: + +```text +__struct_next_states_amount[1] - __struct_next_states_amount[0] +``` + +Again, this is still one scalar expression, just rewritten to hidden leaf names. + +### Whole structured values + +Now look at a different category: + +```sil +next_state +next_states +next_pair +``` + +These are not scalar expressions over one value. They are structured values. + +When the compiler already knows the expected type is structured, it lowers the whole value into many leaf expressions. + +Examples: + +- `lower_runtime_struct_expr(...)` handles a whole `State` or custom struct value +- `lower_struct_array_value_expr(...)` handles a whole `State[]` or `Pair[]` + +So: + +```sil +next_state +``` + +does not lower to one expression. It lowers conceptually to: + +```text +[ + __struct_next_state_amount, + __struct_next_state_active, + __struct_next_state_tag, +] +``` + +And: + +```sil +next_pairs +``` + +conceptually lowers to: + +```text +[ + __struct_next_pairs_amount, + __struct_next_pairs_code, +] +``` + +This distinction matters a lot: + +- scalar structured access becomes one lowered `Expr` +- whole structured values become a list of leaf expressions + +That is why there are multiple lowering helpers in the compiler. + +## Part 2: What the Debug Recorder Adds + +Compiling the contract already produces bytecode. + +When debug recording is enabled, the compiler also builds `DebugInfo`. + +At a high level `DebugInfo` contains: + +- `source` +- `steps` +- `params` +- `functions` +- `constructor_args` +- `constants` + +### `functions` + +These map source-level functions to bytecode ranges. + +This lets the session answer questions like: + +- which function is active at byte offset `X`? + +### `steps` + +Each `DebugStep` says: + +- the bytecode range for one source step +- the source span +- the step kind +- the call depth and frame id +- variable updates that became true at that step + +The important step kinds are: + +- `Source` +- `InlineCallEnter` +- `InlineCallExit` + +This is the bridge from bytecode execution back to source stepping. + +For example, the statement: + +```sil +int bumped = next_state.amount + amount; +``` + +is conceptually recorded like this: + +```text +DebugStep { + bytecode_start: ..., + bytecode_end: ..., + span: line/col for "int bumped = ...", + kind: Source, + variable_updates: [ + DebugVariableUpdate { + name: "bumped", + type_name: "int", + expr: resolved source expression for bumped, + runtime_binding: stack slot for bumped + } + ] +} +``` + +For this example, that `expr` is conceptually still source-shaped: + +```text +next_state.amount + amount +``` + +The important distinction is: + +- the recorder stores a resolved expression useful for debugger-side evaluation +- the debugger later calls `prepare_debug_expr(...)` to lower any structured access when the user actually evaluates something + +The real data is richer, but this is the right mental model: + +- one source statement +- one bytecode span +- the variables that became visible or changed there + +### `params` + +Function params are special because the debugger needs them before any local statement update occurs. + +For plain params, debug info stores one runtime slot. + +For structured params, debug info stores leaf bindings. + +Example for: + +```sil +entrypoint function inspect_state(State next_state) +``` + +the debugger metadata is conceptually: + +```text +next_state : State + amount -> stack slot S1 + active -> stack slot S2 + tag -> stack slot S3 +``` + +In actual terms that means: + +```text +DebugParamMapping { + name: "next_state", + type_name: "State", + binding: StructuredValue { + leaf_bindings: [ + { field_path: ["amount"], type_name: "int", stack_index: S1 }, + { field_path: ["active"], type_name: "bool", stack_index: S2 }, + { field_path: ["tag"], type_name: "byte[1]", stack_index: S3 }, + ] + } +} +``` + +For source display, `next_state` is the public name. + +For runtime resolution, the hidden leaf bindings are what matter. + +### `variable_updates` + +Locals and inline values show up as step-local updates. + +For structured values, a `DebugVariableUpdate` can now carry: + +- `type_name` +- `expr` +- optional `runtime_binding` +- optional `structured_binding` + +That optional `structured_binding` says: + +- this visible variable is a structured value +- these are its leaf paths and leaf types + +Without that, the session would know a name like `inner_state` exists, but not how to reconstruct its fields. + +For an inline param alias like `inner_state`, the update is conceptually: + +```text +DebugVariableUpdate { + name: "inner_state", + type_name: "State", + structured_binding: { + leaf_bindings: [ + { field_path: ["amount"], type_name: "int" }, + { field_path: ["active"], type_name: "bool" }, + { field_path: ["tag"], type_name: "byte[1]" }, + ] + } +} +``` + +That is what tells the session: + +- `inner_state` is not a scalar +- it should be reconstructed as a source-level object +- and it should also synthesize matching hidden leaf names for internal resolution + +## Part 3: How Stepping Works + +The stepping model is still the same one you already understand: + +- compile script +- record debug steps with bytecode ranges +- parse the script in the session +- step opcodes +- use current byte offset to locate the current source step + +The extra detail that matters for structs is how variable scope is built at each step. + +### Building scope for the current step + +When the session wants `vars` or `eval`, it builds a source-level scope from 4 ingredients: + +1. function param mappings from `debug_info.entrypoint_param_bindings` +2. constructor args and constants from `debug_info` +3. latest visible variable updates up to the current step +4. inline call snapshots when inside an inline frame + +This becomes a `ScopeState`. + +Each binding in that scope is one of: + +- a direct runtime slot +- a structured binding with leaf metadata +- a pre-resolved expression + +The important consequence is: + +- visible names like `next_state` stay source-level +- hidden names like `__struct_next_state_amount` can still exist internally for evaluation +- `vars` filters out the hidden ones before presentation + +## Part 4: One End-to-End Trace for `inspect_state` + +Look at: + +```sil +entrypoint function inspect_state(State next_state) { + int bumped = next_state.amount + amount; + require(next_state.active == active); +} +``` + +This is the clearest single flow to keep in your head. + +We will follow just one source expression all the way through: + +```sil +next_state.amount + amount +``` + +and then the debugger command: + +```text +eval next_state.amount + amount +``` + +### Step A: the compiler parses the contract + +From this contract, the compiler knows: + +- `State` comes from the contract fields +- `State` has leaves `amount`, `active`, `tag` +- `next_state` has type `State` +- `amount` has type `int` + +Conceptually the struct registry for this contract contains: + +```text +State: + amount -> int + active -> bool + tag -> byte[1] +``` + +### Step B: param bindings are flattened for runtime/debug use + +For: + +```sil +entrypoint function inspect_state(State next_state) +``` + +the compiler records one structured param plus the contract fields. + +Conceptually: + +```text +visible param: + next_state : State + +hidden runtime leaves: + __struct_next_state_amount -> stack slot S1 + __struct_next_state_active -> stack slot S2 + __struct_next_state_tag -> stack slot S3 + +contract field bindings: + amount -> stack slot S4 + active -> stack slot S5 + tag -> stack slot S6 +``` + +The exact slot numbers depend on layout, but this is the shape that matters. + +### Step C: the statement is compiled + +The source statement is: + +```sil +int bumped = next_state.amount + amount; +``` + +For bytecode generation, the compiler lowers the structured field access: + +```text +next_state.amount + amount +``` + +becomes: + +```text +__struct_next_state_amount + amount +``` + +That lowered expression is what matters for actual bytecode generation. + +### Step D: the recorder stores a source step + +While compiling that statement, the recorder creates a `DebugStep` covering the bytecode range for that statement. + +Conceptually: + +```text +DebugStep { + span: "int bumped = next_state.amount + amount;", + kind: Source, + variable_updates: [ + { + name: "bumped", + type_name: "int", + expr: next_state.amount + amount, + runtime_binding: slot for bumped + } + ] +} +``` + +The important point is: + +- debug steps stay source-oriented +- struct/state lowering for ad hoc debugger eval happens later + +### Step E: the session stops on that step and builds scope + +At runtime, when the debugger is stopped on this statement, the session builds `ScopeState`. + +Conceptually it contains: + +```text +visible: + next_state : StructuredBinding(State) + amount : RuntimeSlot + active : RuntimeSlot + tag : RuntimeSlot + bumped : RuntimeSlot or Expr, depending on point in execution + +hidden: + __struct_next_state_amount : RuntimeSlot + __struct_next_state_active : RuntimeSlot + __struct_next_state_tag : RuntimeSlot +``` + +This is why both of these can be true at the same time: + +- `vars` shows `next_state` +- `eval next_state.amount + amount` can still work through hidden leaf bindings + +### Step F: user runs `vars` + +`vars` resolves the visible bindings: + +- `next_state` is reconstructed as an object from its hidden leaves +- `amount`, `active`, and `tag` are read normally +- hidden `__struct_*` names are filtered out + +So the user sees something like: + +```text +next_state = { amount: 5, active: true, tag: 0xaa } +amount = 1 +active = true +tag = 0xaa +bumped = 6 +``` + +### Step G: user runs `eval next_state.amount + amount` + +Now the debugger goes through a second pipeline, separate from the original contract compilation. + +#### G1. Parse the user expression + +The session parses: + +```text +next_state.amount + amount +``` + +into an expression AST. + +#### G2. Build eval context + +From current scope, the session builds: + +- `eval_types` +- `env` +- `stack_bindings` +- `shadow_bindings` + +Conceptually: + +```text +eval_types: + next_state -> State + __struct_next_state_amount -> int + __struct_next_state_active -> bool + __struct_next_state_tag -> byte[1] + amount -> int + active -> bool + tag -> byte[1] + +stack_bindings: + __struct_next_state_amount -> S1 + __struct_next_state_active -> S2 + __struct_next_state_tag -> S3 + amount -> S4 + active -> S5 + tag -> S6 +``` + +`env` only contains bindings represented as expressions rather than runtime slots. + +#### G3. Prepare the expression for eval + +The session calls: + +```text +prepare_debug_expr(next_state.amount + amount, eval_types, source) +``` + +The compiler then: + +- sees that `next_state.amount` is structured access +- parses the contract source if needed +- rebuilds the struct registry +- lowers the expression +- infers the result type + +The prepared result is: + +```text +lowered expr: __struct_next_state_amount + amount +type: int +``` + +#### G4. Compile and run the shadow expression + +Now the session calls: + +```text +compile_debug_expr(__struct_next_state_amount + amount, env, stack_bindings, eval_types) +``` + +That produces bytecode for just the debug expression. + +The session prepends the needed shadow stack values, runs the shadow script, and decodes the result. + +Final result: + +```text +6 +``` + +So the debugger is not inventing struct lowering rules itself. + +It asks the compiler to do the same lowering the compiler already understands. + +## Part 5: Concrete Flow for `inspect_state_array` + +Look at: + +```sil +entrypoint function inspect_state_array(State[] next_states) { + int delta = next_states[1].amount - next_states[0].amount; + require(next_states.length == 2); +} +``` + +### Field access over an array of structs + +This: + +```sil +next_states[1].amount - next_states[0].amount +``` + +lowers to: + +```text +__struct_next_states_amount[1] - __struct_next_states_amount[0] +``` + +### `.length` + +This: + +```sil +next_states.length +``` + +lowers to the length of any one leaf array: + +```text +__struct_next_states_amount.length +``` + +That works because all leaf arrays for one structured array must have the same length. + +### What `eval next_states` does + +This is different from scalar eval. + +`next_states` is a structured result type, so the session does not need to compile a scalar shadow expression for it. + +Instead it reconstructs the visible array by zipping the leaf arrays back together by index. + +Conceptually: + +```text +index 0 -> { amount, active, tag } +index 1 -> { amount, active, tag } +``` + +That is how whole structured values are presented back to the user. + +## Part 6: Concrete Flow for `inspect_pair` + +Look at: + +```sil +entrypoint function inspect_pair(Pair next_pair) { + byte[2] pair_code = next_pair.code; + require(pair_code == next_pair.code); +} +``` + +This is the same model as `State`, just with a user-declared struct. + +Examples: + +```sil +next_pair.code +``` + +lowers to: + +```text +__struct_next_pair_code +``` + +And: + +```sil +eval next_pair +``` + +returns an object: + +```text +{ amount: ..., code: ... } +``` + +The debugger does not treat `State` as magical. `State` is just one particular structured type the compiler knows how to flatten and reconstruct. + +## Part 7: Why Inline Debugging Was Hard + +Look at: + +```sil +entrypoint function inspect_inline(State next_state, Pair next_pair) { + inspect_inner(next_state, next_pair); + require(next_state.active == active); +} +``` + +and: + +```sil +function inspect_inner(State inner_state, Pair inner_pair) { + int bumped = inner_state.amount + amount; + require(bumped > 0); +} +``` + +At the source level, this is simple: + +- `inner_state` is the callee name +- `next_state` is the caller name +- logically they refer to the same immutable value + +At runtime, this is harder: + +- stepping into the inline call changes stack shape +- temporaries appear and disappear +- a slot that used to mean "the source value I care about" can stop being reliable for debugger display + +### The failed idea + +The original idea was: + +- record more inline leaf updates +- keep reading live runtime slots + +That was not enough. + +The source-level value was stable, but the stack layout was not. + +### The final fix + +On `InlineCallEnter`, the session takes a snapshot of caller-visible immutable values: + +- function params +- contract fields + +When building scope inside the inline frame: + +- those snapshot values are frozen +- structured values are decomposed back into hidden leaf bindings +- aliases like `inner_state` can resolve against those frozen values + +Conceptually, right after stepping into: + +```sil +inspect_inner(next_state, next_pair); +``` + +the snapshot is roughly: + +```text +frame 1 snapshot: + next_state = { amount: 5, active: true, tag: 0xaa } + next_pair = { amount: 9, code: 0x1234 } + amount = 1 + active = true + tag = 0xaa +``` + +Inside the inline frame, `inner_state` and `inner_pair` can then be rebuilt from that frozen caller data instead of trusting live slots. + +So inside the callee: + +- `vars` shows `inner_state` and `inner_pair` correctly +- `eval inner_state.amount` +- `eval inner_pair.code` + +keep working even after more stack traffic happens inside the inline body. + +This is the part that makes inline debugging feel source-level instead of stack-level. + +## Part 8: What `eval` Does for Different Kinds of Expressions + +### Plain scalar expression + +Example: + +```sil +amount + 1 +``` + +Flow: + +- parse expression +- no struct lowering needed +- no contract source parse needed +- compile scalar shadow expression +- run shadow VM + +### Scalar expression with struct/state access + +Example: + +```sil +next_state.amount + amount +``` + +Flow: + +- parse expression +- compiler detects structured lowering is needed +- compiler parses source if needed +- compiler rebuilds struct registry +- compiler lowers to hidden leaf names +- session compiles and executes shadow expression + +### Whole structured value + +Example: + +```sil +next_state +next_states +next_pair +``` + +Flow: + +- parse expression +- compiler still prepares the expression and infers the result type +- session sees the final type is structured +- session resolves the value directly from scope data instead of running a scalar shadow script + +That split is important: + +- structured access inside a scalar expression still goes through compiler lowering +- whole structured results are reconstructed directly by the session + +## Part 9: Why Source Text Is Sometimes Required + +This rule becomes simple once you keep the lowering model in mind. + +### Source text is not required + +For expressions like: + +```sil +amount + 1 +``` + +there is no need to know struct layout, so source text is irrelevant. + +### Source text is required + +For expressions like: + +```sil +next_state.amount +next_states[0].amount +next_pair.code +``` + +the compiler needs to know: + +- is `next_state` a `State`? +- is `next_pair` a `Pair`? +- what fields exist? +- what are their types? + +So the compiler must be able to parse the contract source and rebuild the struct registry. + +That is why missing or invalid source is an error only when the expression actually needs structured lowering. + +## Part 10: How to Read the Debugger Architecture + +If you want a compact mental map of the codebase, use this: + +### Compiler + +- lowers source expressions to runtime form +- compiles bytecode +- records debug steps and variable metadata + +Important pieces: + +- `lower_expr(...)` +- `lower_runtime_struct_expr(...)` +- `lower_struct_array_value_expr(...)` +- `prepare_debug_expr(...)` +- `compile_debug_expr(...)` +- `DebugRecorder` + +### Debug info + +- stores source-to-bytecode mapping +- stores param mappings +- stores step-local variable updates +- stores structured leaf metadata + +Important types: + +- `DebugInfo` +- `DebugStep` +- `DebugParamMapping` +- `DebugVariableUpdate` +- `DebugStructuredBinding` + +### Session + +- executes the script opcode by opcode +- finds the active source step for the current byte offset +- builds current source-level scope +- reconstructs structured values +- compiles/evaluates shadow expressions + +Important pieces: + +- `DebugSession` +- `scope_state(...)` +- `capture_inline_scope_snapshot(...)` +- `prepare_expr_for_eval(...)` +- `evaluate_expr_in_scope(...)` + +## Part 11: Practical Ways to Explore This + +Use the example contract directly. + +### Top-level `State` + +```bash +cli-debugger examples/debug_struct_state_matrix.sil --function inspect_state \ + --ctor-arg '{"amount":3,"code":"0x1234"}' \ + --arg '{"amount":5,"active":true,"tag":"0xaa"}' +``` + +Try: + +- `vars` +- `eval next_state` +- `eval next_state.amount` +- `eval next_state.amount + amount` + +### `State[]` + +```bash +cli-debugger examples/debug_struct_state_matrix.sil --function inspect_state_array \ + --ctor-arg '{"amount":3,"code":"0x1234"}' \ + --arg '[{"amount":5,"active":true,"tag":"0xaa"},{"amount":7,"active":true,"tag":"0xaa"}]' +``` + +Try: + +- `eval next_states` +- `eval next_states.length` +- `eval next_states[1].amount - next_states[0].amount` + +### Inline structured scope + +```bash +cli-debugger examples/debug_struct_state_matrix.sil --function inspect_inline \ + --ctor-arg '{"amount":3,"code":"0x1234"}' \ + --arg '{"amount":5,"active":true,"tag":"0xaa"}' \ + --arg '{"amount":9,"code":"0x1234"}' +``` + +Then: + +- `si` +- `si` +- `vars` +- `eval inner_state` +- `eval inner_state.amount` +- `eval inner_pair.code` +- `eval seed_pair` + +That last session is the best one for understanding why inline snapshots exist. + +## Summary + +If you only keep 5 facts in your head, keep these: + +1. `State` and custom `struct`s are source-level shapes, not runtime opaque objects. +2. The compiler flattens them into leaf bindings like `__struct_next_state_amount`. +3. Debug info records enough metadata to map bytecode back to source steps and reconstruct structured values. +4. The session hides flattened names from the user, but still uses them internally for eval and reconstruction. +5. Inline debugging needs snapshots because source-level immutable values stay stable while live stack slots do not. + +Once that model is clear, the rest of the debugger becomes much easier to read: + +- stepping is bytecode range tracking +- `vars` is source-scope reconstruction +- `eval` is compiler-assisted lowering plus either shadow execution or direct structured reconstruction diff --git a/docs/DEBUGGER_STRUCT_STATE_QUICK_FLOW.md b/docs/DEBUGGER_STRUCT_STATE_QUICK_FLOW.md new file mode 100644 index 00000000..bfd8199a --- /dev/null +++ b/docs/DEBUGGER_STRUCT_STATE_QUICK_FLOW.md @@ -0,0 +1,710 @@ +# Struct/State Debugger Quick Flow + +This is the short version of [`DEBUGGER_STRUCT_STATE_OVERVIEW.md`](./DEBUGGER_STRUCT_STATE_OVERVIEW.md). + +Use this if you already understand: + +- the compiler records debug info while compiling + +and you want the missing piece: + +- how `State`, custom `struct`s, and covenant prior-state values fit into that model + +If that stepping sentence is still fuzzy, here is the plain version: + +- each source statement is recorded as a `DebugStep` +- each `DebugStep` has a bytecode range: `bytecode_start..bytecode_end` +- while debugging, the session executes one opcode at a time +- after each opcode, the session knows its current bytecode offset +- it finds which recorded step covers that offset +- that is how it knows "we are currently at this source statement" + +So source stepping is not magic. It is just: + +- run opcodes +- keep track of the current byte offset +- look up which recorded source step owns that byte range + +Example: + +```text +statement A -> bytecode 20..27 +statement B -> bytecode 27..33 +statement C -> bytecode 33..41 +``` + +If the VM is currently executing bytecode offset `29`, the session says: + +- offset `29` is inside `27..33` +- so the active source step is statement B + +That is what "mapping bytecode offsets back to recorded source steps" means. + +## The 5 facts that matter + +1. `State` and custom `struct`s are source-level shapes, not runtime objects. +2. The compiler flattens them into hidden leaf bindings like `__struct_next_state_amount`. +3. Debug info records enough metadata to rebuild the source-level shape later. +4. `vars` shows source-level objects, not hidden leaf names. +5. `eval` lowers structured access inside the session by using recorded structured metadata. + +If you keep those 5 facts in your head, the rest of the debugger becomes much easier to read. + +## Before structs: how stepping works + +The debugger has two different notions of position: + +- opcode position: where the VM currently is in the compiled script +- source position: which source statement that bytecode belongs to + +The compiler creates the bridge between them by recording `DebugStep`s. + +Conceptually: + +```text +source: + int bumped = next_state.amount + amount; -> step range 120..129 + require(next_state.active == active); -> step range 129..138 +``` + +During debugging, the session: + +1. executes opcodes +2. tracks the current bytecode offset +3. finds the `DebugStep` whose range contains that offset +4. treats that step's source span as the current source location + +So when you run `si` or `next`, the session is not stepping by source code directly. + +It is: + +- executing bytecode +- watching when the active recorded step changes + +That is the base debugger model. Struct/state support sits on top of that. + +## One concrete contract + +Use: + +- [`examples/debug_struct_state_matrix.sil`](../examples/debug_struct_state_matrix.sil) + +Relevant part: + +```sil +contract DebugStructStateMatrix(Pair seed_pair) { + struct Pair { + int amount; + byte[2] code; + } + + int amount = 1; + bool active = true; + byte[1] tag = 0xaa; + + entrypoint function inspect_state(State next_state) { + int bumped = next_state.amount + amount; + require(next_state.active == active); + } + + function inspect_inner(State inner_state, Pair inner_pair) { + int bumped = inner_state.amount + amount; + require(bumped > 0); + } + + entrypoint function inspect_inline(State next_state, Pair next_pair) { + inspect_inner(next_state, next_pair); + require(next_state.active == active); + } +} +``` + +## The core idea + +At source level: + +```sil +next_state.amount +``` + +At runtime/debug leaf level: + +```text +__struct_next_state_amount +``` + +A whole `State next_state` is really treated as: + +```text +__struct_next_state_amount : int +__struct_next_state_active : bool +__struct_next_state_tag : byte[1] +``` + +So the debugger always does two opposite things: + +- for compilation/eval, it lowers source-level field access to leaf names +- for display, it rebuilds a source-level object from leaf values + +## One end-to-end flow + +Take this statement: + +```sil +int bumped = next_state.amount + amount; +``` + +and this debugger command: + +```text +eval next_state.amount + amount +``` + +### 1. Compiler view + +The compiler knows `State` from the contract fields: + +```text +State: + amount -> int + active -> bool + tag -> byte[1] +``` + +For bytecode generation it lowers: + +```text +next_state.amount + amount +``` + +to: + +```text +__struct_next_state_amount + amount +``` + +That is the runtime form. + +### 2. Debug recording view + +The recorder stores: + +- param metadata for `next_state` +- a source step for the statement +- a variable update for `bumped` + +In Rust terms, this lives inside `DebugInfo`: + +- `DebugInfo.entrypoint_param_bindings: Vec` +- `DebugInfo.steps: Vec` + +For a structured param like `next_state: State`, the important internal shape is: + +- `DebugParamMapping` +- with `binding: DebugParamBinding::StructuredValue` +- containing `leaf_bindings: Vec` + +Conceptually the param metadata says: + +```text +next_state : State + amount -> stack slot + active -> stack slot + tag -> stack slot +``` + +So the debugger is not tracking `next_state` as one opaque runtime thing. + +It is tracking: + +- one visible source name: `next_state` +- plus leaf metadata for `amount`, `active`, `tag` +- plus the runtime slot for each leaf + +That is the internal bridge between source-level structs and runtime stack values. + +For step-local structured values, the same idea appears in a different Rust struct: + +- `DebugVariableUpdate` +- with `structured_leaf_bindings: Option>` + +That is how inline names like `inner_state` can also be treated as structured values, not just entrypoint params. + +That is enough for the session to later rebuild both: + +- the visible object `next_state` +- the hidden leaf bindings `__struct_next_state_amount`, `__struct_next_state_active`, `__struct_next_state_tag` + +### 3. Session view for `vars` + +When stopped on that statement, the session builds scope from: + +- param mappings +- contract fields +- variable updates seen so far +- inline snapshots if needed + +Internally, scope contains both: + +- visible names like `next_state` +- hidden names like `__struct_next_state_amount` + +This is the main job of `session.rs`. + +The important change is: the session now owns the bridge from recorded metadata to debugger-visible scope. + +It does that in two steps: + +1. `scope_state(...)` / `scope_state_from_visible(...)` +2. `collect_variables_map(...)` + +`scope_state_from_visible(...)` reads the recorded metadata and creates `ScopeBinding`s for both views of the same value: + +- one visible structured binding for `next_state` +- one hidden leaf binding per field, like `__struct_next_state_amount` + +For a structured value, the visible binding uses: + +- `ScopeValueSource::StructuredBinding { base_name, leaf_bindings }` + +and for each hidden leaf: + +- `ScopeValueSource::RuntimeSlot { ... }` + +Then `collect_variables_map(...)` iterates the scope and resolves only the visible bindings into user-facing variables. + +That is why `vars` shows: + +```text +next_state = { amount: 5, active: true, tag: 0xaa } +amount = 1 +bumped = 6 +``` + +and does not show: + +```text +__struct_next_state_amount +__struct_next_state_active +__struct_next_state_tag +``` + +Those hidden names still exist in scope. They are just debugger-internal. + +One more small session change matters here: + +- `VariableOrigin` now distinguishes `Param` from `ContractField` + +That is why the CLI can print: + +- `Contract State` +- `Call Arguments` +- `Locals` + +### 4. Session view for `eval` + +For: + +```text +eval next_state.amount + amount +``` + +the session: + +1. parses the user expression +2. builds current `scope_state` +3. checks whether the expression is already a direct structured value like `next_state` +4. if it is scalar structured access, lowers it inside `session.rs` + +The important session-side lowering helpers are: + +- `lower_structured_field_access_for_eval(...)` +- `lower_structured_length_for_eval(...)` +- `lower_expr_for_eval(...)` + +So for: + +```text +next_state.amount + amount +``` + +the session lowers it to: + +```text +__struct_next_state_amount + amount +``` + +using the current `ScopeValueSource::StructuredBinding` metadata, not by reparsing contract source through the compiler. + +Then it: + +5. builds shadow bindings with `scope_state_eval_context(...)` +6. calls `compile_debug_expr(...)` +7. runs the shadow expression +8. decodes the result + +So the current design is: + +- compiler records struct/state metadata +- session reconstructs scope from that metadata +- session lowers structured eval using that metadata +- compiler still compiles the final scalar debug expression + +## Why whole structured values are different + +These two cases are different: + +```text +eval next_state.amount + amount +eval next_state +``` + +The first one is scalar, so the debugger: + +- lowers it +- compiles it +- runs it in the shadow VM + +The second one is structured, so the debugger: + +- does not need a scalar shadow expression +- reconstructs the object directly from leaf values + +That distinction explains a lot of the code shape. + +In `session.rs`, that split appears here: + +- `evaluate_expr_in_scope(...)` first checks `direct_expr_type_name(...)` +- if the result type is structured, it reconstructs the value directly +- otherwise it uses `lower_expr_for_eval(...)` and shadow execution + +## Why inline calls are tricky + +Inside: + +```sil +inspect_inner(next_state, next_pair) +``` + +the source-level values are stable, but live stack slots can drift as the inline body executes. + +So the session snapshots caller-visible immutable values at `InlineCallEnter`. + +That is why these keep working inside the inline frame: + +- `vars` +- `eval inner_state` +- `eval inner_state.amount` + +without trusting whatever the live stack happens to look like later. + +## The missing top half: what happens before stepping starts + +Everything above explains how a stopped session reconstructs source-level values. + +But there is an earlier half of the flow: + +1. the CLI loads source and parses the contract AST +2. constructor args are parsed into typed AST expressions +3. the contract is compiled with debug recording enabled +4. call args are parsed against the selected callable +5. the CLI builds the sigscript for that callable +6. the CLI builds a transaction scenario around it +7. the session starts from bytecode plus `DebugInfo` +8. the session runs forward until the first real source statement + +That is the full debugger pipeline. + +In code, the start of that flow is mainly in: + +- `debugger/cli/src/main.rs` +- `debugger/session/src/args.rs` + +Conceptually it looks like this: + +```text +source text + -> parse contract AST + -> parse ctor args / call args + -> compile contract + DebugInfo + -> build sigscript + -> build debug tx context + -> create DebugSession + -> run to first executed source statement + -> vars / eval / stepping +``` + +## Constructor args, constants, and contract state + +There are 3 different categories of source-level values that the user sees very early: + +- constructor args +- contract fields +- call arguments + +They do not all come from the same place. + +### Constructor args + +Constructor args are parsed first by the CLI and compiled into the contract script. + +For debugger display, recorded constructor arg values are later inserted into scope from `DebugInfo`: + +- `record_debug_named_values(..., &self.debug_info.constructor_args, ...)` + +Those are not read from the live VM stack at inspection time. + +They are already known debug values. + +### Contract fields + +Contract fields are source-level state, but at runtime they are still just normal lowered bindings. + +The session reconstructs them from param metadata and then classifies them by source contract field name: + +- `param_origin(...)` + +That is why the CLI can separate: + +- `Contract State` +- `Call Arguments` + +instead of dumping everything into one flat variable list. + +### Call arguments + +Call arguments come from the selected function signature. + +For normal entrypoints, `parse_call_args(...)` parses them directly against the chosen function in the lowered AST. + +For covenant declarations, there is one extra translation layer, described below. + +## Why `run_to_first_executed_statement()` matters + +When the session is first created, the VM is still at the start of the compiled script. + +That does not necessarily mean the user is at the first meaningful source statement. + +There may be: + +- dispatch/setup opcodes +- selector handling +- covenant wrapper prelude bytecode +- other synthetic setup ranges + +So the CLI calls: + +- `run_to_first_executed_statement()` + +That method keeps stepping raw opcodes until: + +- the engine is executing +- the current byte offset falls inside a steppable `DebugStep` + +Only then does the REPL show the initial source location. + +That is why the first debugger screen already feels source-oriented instead of exposing compiler setup bytecode. + +## The covenant-specific flow + +Struct/state support and covenant support meet in one important place: + +- the debugger should show source-level covenant names and source-level prior state values + +not the generated wrapper names and hidden covenant temporaries. + +### Function selection + +For a normal function, the CLI path is simple: + +- parse args against that function +- call `build_sig_script(...)` + +For a source-level covenant declaration like: + +```sil +#[covenant(binding = cov, from = 2, to = 2, mode = verification)] +function rebalance(State[] prev_states, State[] new_states) { ... } +``` + +the CLI does something more careful: + +1. parse the original contract AST +2. analyze covenant declarations with `analyze_covenant_declarations(...)` +3. resolve `rebalance` plus role into a generated lowered entrypoint with `resolve_covenant_decl_call_target(...)` +4. parse call args against that generated lowered entrypoint name +5. build the sigscript through `build_sig_script_for_covenant_decl(...)` + +That distinction matters because: + +- source-level covenant params include implicit prior-state params like `prev_state` or `prev_states` +- the lowered callable signature is what the current argument parser understands +- the user-facing function name should still stay source-level + +For `binding = cov`: + +- leader is the default +- `--delegate` opts into the delegate path +- in `.test.json`, `"delegate": true` does the same thing + +### Prior state injection + +For covenant debugging, source-level prior state is not read from normal local updates. + +Instead, the CLI builds a debug-only shadow transaction context: + +- it resolves constructor args into source-level state values +- it attaches those values to `ShadowTxContext` +- the session receives that context via `with_shadow_tx_context(...)` + +Then `session.rs` injects source-level bindings with: + +- `inject_covenant_prev_state_bindings(...)` + +That means: + +- auth covenants get `prev_state` +- cov covenants get `prev_states` + +as real debugger-visible names. + +So commands like: + +- `vars` +- `p prev_states` +- `eval prev_states[0].value` + +work through the same structured binding machinery as any other `State` or `State[]` value. + +If the shadow tx context does not contain those values, the bindings still exist conceptually, but resolve as unavailable instead of silently disappearing. + +### Source-level covenant names + +The session also parses the source AST at startup and analyzes covenant declarations again. + +That allows it to normalize lowered names like: + +- `__leader_rebalance` +- `__delegate_rebalance` +- `__covenant_policy_rebalance` + +back into source-oriented labels like: + +- `rebalance [leader]` +- `rebalance [delegate]` +- `rebalance` + +That normalization is used in: + +- current function display +- call stack display +- failure reports + +So the debugger stays focused on source intent, not lowering internals. + +## One compressed end-to-end pipeline + +If you want the whole thing in one pass, this is the shortest accurate version: + +1. The CLI parses source into a contract AST. +2. It parses constructor args into typed expressions with `parse_ctor_args(...)`. +3. It compiles the contract with debug recording enabled, producing bytecode plus `DebugInfo`. +4. It resolves the selected function. +5. For covenant declarations, it resolves source name plus role to a lowered target for arg parsing, but still builds the action script from the source-level declaration name. +6. It parses call args into typed expressions with `parse_call_args(...)`. +7. It builds the sigscript with `build_sig_script(...)` or `build_sig_script_for_covenant_decl(...)`. +8. It builds a debug transaction scenario and stores prior state values in `ShadowTxContext`. +9. It creates `DebugSession::full(...)` and calls `run_to_first_executed_statement()`. +10. While stepping, the session maps bytecode offsets to `DebugStep`s, reconstructs visible scope from param mappings plus variable updates plus inline snapshots plus covenant prior-state injection, and then serves `vars` / `print` / `eval`. + +That is the entire flow. + +## Arrays and nested structured values + +One subtle point is that arrays of structured values are still flattened leaf-first. + +For: + +```sil +State[] next_states +``` + +the runtime/debug leaf model is conceptually: + +```text +__struct_next_states_amount : int[] +__struct_next_states_active : bool[] +__struct_next_states_tag : byte[1][] +``` + +So: + +```text +eval next_states[1].amount +``` + +becomes a leaf-array access: + +```text +__struct_next_states_amount[1] +``` + +This is why the same design works for: + +- `State` +- `State[]` +- custom `struct` +- custom `struct[]` + +The debugger never needs a separate object runtime. + +It just keeps rebuilding source-level shapes from flattened leaves. + +## If you want to read the code + +Use this map: + +- CLI entry + tx setup: `main.rs` +- CLI arg parsing: `parse_ctor_args(...)`, `parse_call_args(...)` +- covenant source-level resolution: `analyze_covenant_declarations(...)`, `resolve_covenant_decl_call_target(...)` +- covenant sigscript building: `build_sig_script_for_covenant_decl(...)` +- state reconstruction from ctor args: `resolve_contract_state_values(...)` +- compiler lowering: `lower_expr(...)`, `lower_runtime_struct_expr(...)` +- debug recording: `DebugRecorder` +- session startup: `DebugSession::full(...)`, `run_to_first_executed_statement(...)` +- session scope reconstruction: `scope_state(...)`, `scope_state_from_visible(...)`, `collect_variables_map(...)` +- covenant binding injection: `inject_covenant_prev_state_bindings(...)` +- inline scope freezing: `freeze_inline_snapshot_bindings(...)` +- session eval lowering: `lower_structured_field_access_for_eval(...)`, `lower_structured_length_for_eval(...)`, `lower_expr_for_eval(...)` +- session eval execution: `evaluate_expr_in_scope(...)`, `scope_state_eval_context(...)` + +## If you want to explore it live + +```bash +cli-debugger examples/debug_struct_state_matrix.sil --function inspect_state \ + --ctor-arg '{"amount":3,"code":"0x1234"}' \ + --arg '{"amount":5,"active":true,"tag":"0xaa"}' +``` + +Then try: + +- `vars` +- `eval next_state` +- `eval next_state.amount` +- `eval next_state.amount + amount` + +For inline behavior: + +```bash +cli-debugger examples/debug_struct_state_matrix.sil --function inspect_inline \ + --ctor-arg '{"amount":3,"code":"0x1234"}' \ + --arg '{"amount":5,"active":true,"tag":"0xaa"}' \ + --arg '{"amount":9,"code":"0x1234"}' +``` + +Then: + +- `si` +- `si` +- `vars` +- `eval inner_state.amount` diff --git a/examples/debug_small_inline.sil b/examples/debug_small_inline.sil new file mode 100644 index 00000000..07560c75 --- /dev/null +++ b/examples/debug_small_inline.sil @@ -0,0 +1,15 @@ +pragma silverscript ^0.1.0; + +contract DebugSmallInline() { + int amount = 1; + bool active = true; + byte[1] tag = 0xaa; + + + entrypoint function inspect(State[] next_states) { + console.log("total sum of amounts: ", next_states[0].amount + next_states[1].amount); + require(next_states[0].active == active); + require(next_states[0].tag == tag); + require(next_states[1].tag == 0xbb); + } +} diff --git a/examples/debug_struct_state_matrix.ctor.json b/examples/debug_struct_state_matrix.ctor.json new file mode 100644 index 00000000..a1a30386 --- /dev/null +++ b/examples/debug_struct_state_matrix.ctor.json @@ -0,0 +1,30 @@ +[ + { + "kind": "state_object", + "data": [ + { + "name": "amount", + "expr": { + "kind": "int", + "data": 3 + } + }, + { + "name": "code", + "expr": { + "kind": "array", + "data": [ + { + "kind": "byte", + "data": 18 + }, + { + "kind": "byte", + "data": 52 + } + ] + } + } + ] + } +] diff --git a/examples/debug_struct_state_matrix.sil b/examples/debug_struct_state_matrix.sil new file mode 100644 index 00000000..eae2ea6e --- /dev/null +++ b/examples/debug_struct_state_matrix.sil @@ -0,0 +1,110 @@ +pragma silverscript ^0.1.0; + +// Structured debugger feature matrix. +// +// Suggested CLI sessions: +// +// 1. Top-level State +// silverc examples/debug_struct_state_matrix.sil \ +// --constructor-args examples/debug_struct_state_matrix.ctor.json -c +// cli-debugger examples/debug_struct_state_matrix.sil --function inspect_state \ +// --ctor-arg '{"amount":3,"code":"0x1234"}' \ +// --arg '{"amount":5,"active":true,"tag":"0xaa"}' +// eval next_state +// eval next_state.amount +// eval next_state.amount + amount +// +// 2. State[] +// cli-debugger examples/debug_struct_state_matrix.sil --function inspect_state_array \ +// --ctor-arg '{"amount":3,"code":"0x1234"}' \ +// --arg '[{"amount":5,"active":true,"tag":"0xaa"},{"amount":7,"active":true,"tag":"0xaa"}]' +// eval next_states +// eval next_states.length +// eval next_states[1].amount - next_states[0].amount +// +// 3. Custom struct and custom struct[] +// cli-debugger examples/debug_struct_state_matrix.sil --function inspect_pair \ +// --ctor-arg '{"amount":3,"code":"0x1234"}' \ +// --arg '{"amount":9,"code":"0x1234"}' +// eval next_pair +// eval next_pair.code +// +// cli-debugger examples/debug_struct_state_matrix.sil --function inspect_pair_array \ +// --ctor-arg '{"amount":3,"code":"0x1234"}' \ +// --arg '[{"amount":9,"code":"0x1234"},{"amount":11,"code":"0x1234"}]' +// eval next_pairs.length +// eval next_pairs[1].amount - next_pairs[0].amount +// +// 4. Inline structured scope +// cli-debugger examples/debug_struct_state_matrix.sil --function inspect_inline \ +// --ctor-arg '{"amount":3,"code":"0x1234"}' \ +// --arg '{"amount":5,"active":true,"tag":"0xaa"}' \ +// --arg '{"amount":9,"code":"0x1234"}' +// si +// si +// vars +// eval inner_state +// eval inner_pair.code +// eval seed_pair +// eval seed_pair.code + +contract DebugStructStateMatrix(Pair seed_pair) { + struct Pair { + int amount; + byte[2] code; + } + + int amount = 1; + bool active = true; + byte[1] tag = 0xaa; + + function inspect_deeper(State deeper_state, Pair deeper_pair) { + int state_plus_amount = deeper_state.amount + amount; + require(state_plus_amount > 0); + require(deeper_pair.amount > 0); + } + + function inspect_inner(State inner_state, Pair inner_pair) { + int bumped = inner_state.amount + amount; + require(bumped > 0); + inspect_deeper(inner_state, inner_pair); + } + + entrypoint function inspect_state(State next_state) { + int bumped = next_state.amount + amount; + require(bumped > 0); + require(next_state.active == active); + require(next_state.tag == tag); + } + + entrypoint function inspect_state_array(State[] next_states) { + int delta = next_states[1].amount - next_states[0].amount; + require(next_states.length == 2); + require(delta > 0); + require(next_states[0].tag == tag); + } + + entrypoint function inspect_pair(Pair next_pair) { + int bumped = next_pair.amount + 1; + byte[2] pair_code = next_pair.code; + require(bumped > 0); + require(pair_code == next_pair.code); + } + + entrypoint function inspect_pair_array(Pair[] next_pairs) { + int delta = next_pairs[1].amount - next_pairs[0].amount; + require(next_pairs.length == 2); + require(delta > 0); + require(next_pairs[0].code == next_pairs[1].code); + } + + function lol(State inner_state) { + int bumped = inner_state.amount + 1; + return bumped + } + + entrypoint function inspect_inline(State next_state, Pair next_pair) { + inspect_inner(next_state, next_pair); + require(next_state.active == active); + } +} From 7dca55441c921f3748151df5d7dfddaa4ae85530 Mon Sep 17 00:00:00 2001 From: Manyfestation <240733973+Manyfestation@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:10:39 +0300 Subject: [PATCH 03/11] Remove docs and fix covenant debugger naming --- README.md | 4 - debugger/session/src/session.rs | 2 +- debugger/session/tests/debug_session_tests.rs | 92 +- docs/DEBUGGER_STRUCT_STATE_OVERVIEW.md | 1121 ----------------- docs/DEBUGGER_STRUCT_STATE_QUICK_FLOW.md | 710 ----------- examples/debug_small_inline.sil | 15 - examples/debug_struct_state_matrix.ctor.json | 30 - examples/debug_struct_state_matrix.sil | 110 -- 8 files changed, 92 insertions(+), 1992 deletions(-) delete mode 100644 docs/DEBUGGER_STRUCT_STATE_OVERVIEW.md delete mode 100644 docs/DEBUGGER_STRUCT_STATE_QUICK_FLOW.md delete mode 100644 examples/debug_small_inline.sil delete mode 100644 examples/debug_struct_state_matrix.ctor.json delete mode 100644 examples/debug_struct_state_matrix.sil diff --git a/README.md b/README.md index aa326299..3926cc03 100644 --- a/README.md +++ b/README.md @@ -40,10 +40,6 @@ cargo run -p cli-debugger -- \ See [TUTORIAL.md](docs/TUTORIAL.md) for a full language and usage tutorial, and [DECL.md](docs/DECL.md) for the covenant declaration spec. -For a debugger-focused walkthrough of structured state, source-level variable reconstruction, and evaluation flow, see [DEBUGGER_STRUCT_STATE_OVERVIEW.md](docs/DEBUGGER_STRUCT_STATE_OVERVIEW.md) and the shorter [DEBUGGER_STRUCT_STATE_QUICK_FLOW.md](docs/DEBUGGER_STRUCT_STATE_QUICK_FLOW.md). - -The accompanying debugger examples live under `examples/`, including `debug_struct_state_matrix.sil` and its constructor fixture. - ## Credits See [CREDITS.md](CREDITS.md) for acknowledgements and credits. diff --git a/debugger/session/src/session.rs b/debugger/session/src/session.rs index f6640a21..29bc794e 100644 --- a/debugger/session/src/session.rs +++ b/debugger/session/src/session.rs @@ -560,7 +560,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { let mut stack = Vec::new(); for step in self.active_steps() { match &step.kind { - StepKind::InlineCallEnter { callee } => stack.push(callee.clone()), + StepKind::InlineCallEnter { callee } => stack.push(self.display_function_name(callee)), StepKind::InlineCallExit { .. } => { stack.pop(); } diff --git a/debugger/session/tests/debug_session_tests.rs b/debugger/session/tests/debug_session_tests.rs index b5e19535..6af6d4d5 100644 --- a/debugger/session/tests/debug_session_tests.rs +++ b/debugger/session/tests/debug_session_tests.rs @@ -17,7 +17,7 @@ use debugger_session::{ session::{DebugSession, DebugValue, ShadowTxContext}, }; use silverscript_lang::ast::{Expr, ExprKind, parse_contract_ast}; -use silverscript_lang::compiler::{CompileOptions, compile_contract, struct_object}; +use silverscript_lang::compiler::{CompileOptions, CovenantDeclCallOptions, compile_contract, struct_object}; use silverscript_lang::debug_info::StepKind; const IF_STATEMENT_CONTRACT: &str = r#"pragma silverscript ^0.1.0; @@ -1294,3 +1294,93 @@ contract CovEval() { assert_eq!(format_value(&type_name, &value), format!("0x{}", "22".repeat(32))); Ok(()) } + +#[test] +fn debug_session_uses_source_level_names_for_covenant_declarations() -> Result<(), Box> { + let source = r#"pragma silverscript ^0.1.0; + +contract Counter(int init_value) { + int value = init_value; + + #[covenant.singleton] + function step(State prev_state, State[] new_states) { + require(prev_state.value == value); + require(new_states.length <= 1); + } +} +"#; + + let compile_opts = CompileOptions { record_debug_infos: true, ..Default::default() }; + let compiled = compile_contract(source, &[Expr::int(7)], compile_opts)?; + let debug_info = compiled.debug_info.clone(); + let covenant_target = compiled + .resolve_covenant_call_target("step", CovenantDeclCallOptions { is_leader: false }) + .ok_or("missing covenant call target")?; + let sigscript = compiled.build_sig_script_for_covenant_decl( + "step", + vec![vec![struct_object(vec![("value", Expr::int(8))])].into()], + CovenantDeclCallOptions { is_leader: false }, + )?; + + let input = TransactionInput { + previous_outpoint: TransactionOutpoint { transaction_id: TransactionId::from_bytes([0x55u8; 32]), index: 0 }, + signature_script: sigscript.clone(), + sequence: 0, + sig_op_count: 0, + }; + let output = TransactionOutput { value: 1000, script_public_key: ScriptPublicKey::new(0, vec![OpTrue].into()), covenant: None }; + let tx = Transaction::new(1, vec![input], vec![output], 0, Default::default(), 0, vec![]); + + let covenant_id = Hash::from_bytes([0x33u8; 32]); + let utxo_entry = + UtxoEntry::new(1000, ScriptPublicKey::new(0, compiled.script.clone().into()), 0, tx.is_coinbase(), Some(covenant_id)); + let populated_tx = PopulatedTransaction::new(&tx, vec![utxo_entry]); + let cov_ctx = CovenantsContext::from_tx(&populated_tx)?; + + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let ctx = EngineCtx::new(&sig_cache).with_reused(&reused_values).with_covenants_ctx(&cov_ctx); + let input_ref = &tx.inputs[0]; + let utxo_ref = populated_tx.utxo(0).ok_or("missing utxo for input 0")?; + let engine = debugger_session::session::DebugEngine::from_transaction_input( + &populated_tx, + input_ref, + 0, + utxo_ref, + ctx, + EngineFlags { covenants_enabled: true }, + ); + + let shadow_ctx = ShadowTxContext { tx: &populated_tx, input: input_ref, input_index: 0, utxo_entry: utxo_ref }; + let mut session = DebugSession::full(&sigscript, &compiled.script, source, debug_info, engine)? + .with_shadow_tx_context(shadow_ctx) + .with_covenant_mode( + compiled.covenant_infos.clone(), + Some(DebugValue::Object(vec![("value".to_string(), DebugValue::Int(7))])), + Some(covenant_target), + ); + + session.run_to_first_executed_statement()?; + + for _ in 0..24 { + if !session.call_stack().is_empty() { + assert_eq!(session.call_stack(), vec!["step".to_string()]); + assert_eq!(session.current_function_name().as_deref(), Some("step")); + + let prev_state = session.variable_by_name("prev_state")?; + assert_eq!(prev_state.type_name, "State"); + assert_eq!(format_value(&prev_state.type_name, &prev_state.value), "{value: 7}"); + + let (type_name, value) = session.evaluate_expression("prev_state.value")?; + assert_eq!(type_name, "int"); + assert_eq!(format_value(&type_name, &value), "7"); + return Ok(()); + } + + if session.step_into()?.is_none() { + break; + } + } + + Err("expected to step into source-level covenant policy frame".into()) +} diff --git a/docs/DEBUGGER_STRUCT_STATE_OVERVIEW.md b/docs/DEBUGGER_STRUCT_STATE_OVERVIEW.md deleted file mode 100644 index 17d2900e..00000000 --- a/docs/DEBUGGER_STRUCT_STATE_OVERVIEW.md +++ /dev/null @@ -1,1121 +0,0 @@ -# Struct/State Debugger Overview - -If you want the shorter version first, read [`DEBUGGER_STRUCT_STATE_QUICK_FLOW.md`](./DEBUGGER_STRUCT_STATE_QUICK_FLOW.md). - -## Why this document exists - -This document is meant to explain the debugger as a system, not just list the struct/state feature work. - -The key question is: - -- how do we go from source code with `State`, custom `struct`s, inline calls, and source-level names -- to compiled bytecode -- and then back to a source-level debugging experience - -If you already understand this much: - -- the compiler records debug info while compiling -- that debug info maps source steps to bytecode ranges -- the session steps through those recorded ranges - -then this document fills in the missing middle: - -- what happens to `State` and `struct` values during compilation -- what extra metadata had to be recorded -- how `vars` and `eval` reconstruct source-level values from flattened runtime data - -This doc uses one concrete contract as the running example: - -- [`examples/debug_struct_state_matrix.sil`](../examples/debug_struct_state_matrix.sil) - -Its constructor args live in: - -- [`examples/debug_struct_state_matrix.ctor.json`](../examples/debug_struct_state_matrix.ctor.json) - -## One Mental Model - -There are really 4 layers: - -1. Source layer - `State`, `Pair`, `next_state.amount`, `next_pairs[1].code` -2. Runtime layer - flattened leaf bindings like `__struct_next_state_amount` -3. Debug metadata layer - bytecode ranges, variable updates, structured leaf metadata, param mappings -4. Session layer - reconstruct source-level variables and evaluate expressions while stepping - -The most important fact is this: - -- `State` and custom `struct`s are source-level concepts -- the runtime does not keep them as one opaque object -- the compiler lowers them into leaf values - -So the debugger has to do two opposite jobs: - -- during compile/eval lowering, go from source-level struct access to flattened leaf access -- during display, go from flattened leaf values back to source-level objects and arrays - -That is the whole story. - -## The Reference Contract - -Here is the important part of the example contract: - -```sil -contract DebugStructStateMatrix(Pair seed_pair) { - struct Pair { - int amount; - byte[2] code; - } - - int amount = 1; - bool active = true; - byte[1] tag = 0xaa; - - function inspect_inner(State inner_state, Pair inner_pair) { - int bumped = inner_state.amount + amount; - require(bumped > 0); - inspect_deeper(inner_state, inner_pair); - } - - entrypoint function inspect_state(State next_state) { - int bumped = next_state.amount + amount; - require(next_state.active == active); - } - - entrypoint function inspect_state_array(State[] next_states) { - int delta = next_states[1].amount - next_states[0].amount; - require(next_states.length == 2); - } - - entrypoint function inspect_pair(Pair next_pair) { - byte[2] pair_code = next_pair.code; - require(pair_code == next_pair.code); - } - - entrypoint function inspect_inline(State next_state, Pair next_pair) { - inspect_inner(next_state, next_pair); - require(next_state.active == active); - } -} -``` - -This one file exercises all the interesting debugger cases: - -- top-level `State` -- top-level `State[]` -- custom `struct` -- custom `struct[]` -- inline structured params -- constructor args visible in debugger scope - -One practical note: - -- `seed_pair` is included so the debugger can expose a structured constructor arg -- in this example it is not used inside contract logic - -## Part 1: What the Compiler Does With `State` and `struct` - -### Source view - -At the source level you can write: - -```sil -next_state.amount -next_states[0].amount -next_pair.code -``` - -This reads like object/field access. - -### Runtime view - -The compiler does not keep a `State` value as one runtime object. - -Instead it flattens it into leaf bindings. - -If `State` is: - -- `amount: int` -- `active: bool` -- `tag: byte[1]` - -then a source binding: - -```sil -State next_state -``` - -becomes the runtime/debug leaf model: - -```text -__struct_next_state_amount : int -__struct_next_state_active : bool -__struct_next_state_tag : byte[1] -``` - -For arrays of structs, each leaf becomes its own array: - -```text -__struct_next_states_amount : int[] -__struct_next_states_active : bool[] -__struct_next_states_tag : byte[1][] -``` - -For `Pair[]`: - -```text -__struct_next_pairs_amount : int[] -__struct_next_pairs_code : byte[2][] -``` - -### Why flattening exists - -This is not debugger-specific. This is how the compiler already reasons about structured runtime data. - -The useful helpers are: - -- `flattened_struct_name(base, path)` -- `flatten_type_ref_leaves(...)` -- `lower_expr(...)` -- `lower_runtime_struct_expr(...)` -- `lower_struct_array_value_expr(...)` - -They do different jobs. - -### Scalar structured access - -For a scalar expression like: - -```sil -next_state.amount + amount -``` - -the compiler lowers only the structured access part: - -```text -__struct_next_state_amount + amount -``` - -This is what `lower_expr(...)` is for. - -Another example: - -```sil -next_states[1].amount - next_states[0].amount -``` - -becomes: - -```text -__struct_next_states_amount[1] - __struct_next_states_amount[0] -``` - -Again, this is still one scalar expression, just rewritten to hidden leaf names. - -### Whole structured values - -Now look at a different category: - -```sil -next_state -next_states -next_pair -``` - -These are not scalar expressions over one value. They are structured values. - -When the compiler already knows the expected type is structured, it lowers the whole value into many leaf expressions. - -Examples: - -- `lower_runtime_struct_expr(...)` handles a whole `State` or custom struct value -- `lower_struct_array_value_expr(...)` handles a whole `State[]` or `Pair[]` - -So: - -```sil -next_state -``` - -does not lower to one expression. It lowers conceptually to: - -```text -[ - __struct_next_state_amount, - __struct_next_state_active, - __struct_next_state_tag, -] -``` - -And: - -```sil -next_pairs -``` - -conceptually lowers to: - -```text -[ - __struct_next_pairs_amount, - __struct_next_pairs_code, -] -``` - -This distinction matters a lot: - -- scalar structured access becomes one lowered `Expr` -- whole structured values become a list of leaf expressions - -That is why there are multiple lowering helpers in the compiler. - -## Part 2: What the Debug Recorder Adds - -Compiling the contract already produces bytecode. - -When debug recording is enabled, the compiler also builds `DebugInfo`. - -At a high level `DebugInfo` contains: - -- `source` -- `steps` -- `params` -- `functions` -- `constructor_args` -- `constants` - -### `functions` - -These map source-level functions to bytecode ranges. - -This lets the session answer questions like: - -- which function is active at byte offset `X`? - -### `steps` - -Each `DebugStep` says: - -- the bytecode range for one source step -- the source span -- the step kind -- the call depth and frame id -- variable updates that became true at that step - -The important step kinds are: - -- `Source` -- `InlineCallEnter` -- `InlineCallExit` - -This is the bridge from bytecode execution back to source stepping. - -For example, the statement: - -```sil -int bumped = next_state.amount + amount; -``` - -is conceptually recorded like this: - -```text -DebugStep { - bytecode_start: ..., - bytecode_end: ..., - span: line/col for "int bumped = ...", - kind: Source, - variable_updates: [ - DebugVariableUpdate { - name: "bumped", - type_name: "int", - expr: resolved source expression for bumped, - runtime_binding: stack slot for bumped - } - ] -} -``` - -For this example, that `expr` is conceptually still source-shaped: - -```text -next_state.amount + amount -``` - -The important distinction is: - -- the recorder stores a resolved expression useful for debugger-side evaluation -- the debugger later calls `prepare_debug_expr(...)` to lower any structured access when the user actually evaluates something - -The real data is richer, but this is the right mental model: - -- one source statement -- one bytecode span -- the variables that became visible or changed there - -### `params` - -Function params are special because the debugger needs them before any local statement update occurs. - -For plain params, debug info stores one runtime slot. - -For structured params, debug info stores leaf bindings. - -Example for: - -```sil -entrypoint function inspect_state(State next_state) -``` - -the debugger metadata is conceptually: - -```text -next_state : State - amount -> stack slot S1 - active -> stack slot S2 - tag -> stack slot S3 -``` - -In actual terms that means: - -```text -DebugParamMapping { - name: "next_state", - type_name: "State", - binding: StructuredValue { - leaf_bindings: [ - { field_path: ["amount"], type_name: "int", stack_index: S1 }, - { field_path: ["active"], type_name: "bool", stack_index: S2 }, - { field_path: ["tag"], type_name: "byte[1]", stack_index: S3 }, - ] - } -} -``` - -For source display, `next_state` is the public name. - -For runtime resolution, the hidden leaf bindings are what matter. - -### `variable_updates` - -Locals and inline values show up as step-local updates. - -For structured values, a `DebugVariableUpdate` can now carry: - -- `type_name` -- `expr` -- optional `runtime_binding` -- optional `structured_binding` - -That optional `structured_binding` says: - -- this visible variable is a structured value -- these are its leaf paths and leaf types - -Without that, the session would know a name like `inner_state` exists, but not how to reconstruct its fields. - -For an inline param alias like `inner_state`, the update is conceptually: - -```text -DebugVariableUpdate { - name: "inner_state", - type_name: "State", - structured_binding: { - leaf_bindings: [ - { field_path: ["amount"], type_name: "int" }, - { field_path: ["active"], type_name: "bool" }, - { field_path: ["tag"], type_name: "byte[1]" }, - ] - } -} -``` - -That is what tells the session: - -- `inner_state` is not a scalar -- it should be reconstructed as a source-level object -- and it should also synthesize matching hidden leaf names for internal resolution - -## Part 3: How Stepping Works - -The stepping model is still the same one you already understand: - -- compile script -- record debug steps with bytecode ranges -- parse the script in the session -- step opcodes -- use current byte offset to locate the current source step - -The extra detail that matters for structs is how variable scope is built at each step. - -### Building scope for the current step - -When the session wants `vars` or `eval`, it builds a source-level scope from 4 ingredients: - -1. function param mappings from `debug_info.entrypoint_param_bindings` -2. constructor args and constants from `debug_info` -3. latest visible variable updates up to the current step -4. inline call snapshots when inside an inline frame - -This becomes a `ScopeState`. - -Each binding in that scope is one of: - -- a direct runtime slot -- a structured binding with leaf metadata -- a pre-resolved expression - -The important consequence is: - -- visible names like `next_state` stay source-level -- hidden names like `__struct_next_state_amount` can still exist internally for evaluation -- `vars` filters out the hidden ones before presentation - -## Part 4: One End-to-End Trace for `inspect_state` - -Look at: - -```sil -entrypoint function inspect_state(State next_state) { - int bumped = next_state.amount + amount; - require(next_state.active == active); -} -``` - -This is the clearest single flow to keep in your head. - -We will follow just one source expression all the way through: - -```sil -next_state.amount + amount -``` - -and then the debugger command: - -```text -eval next_state.amount + amount -``` - -### Step A: the compiler parses the contract - -From this contract, the compiler knows: - -- `State` comes from the contract fields -- `State` has leaves `amount`, `active`, `tag` -- `next_state` has type `State` -- `amount` has type `int` - -Conceptually the struct registry for this contract contains: - -```text -State: - amount -> int - active -> bool - tag -> byte[1] -``` - -### Step B: param bindings are flattened for runtime/debug use - -For: - -```sil -entrypoint function inspect_state(State next_state) -``` - -the compiler records one structured param plus the contract fields. - -Conceptually: - -```text -visible param: - next_state : State - -hidden runtime leaves: - __struct_next_state_amount -> stack slot S1 - __struct_next_state_active -> stack slot S2 - __struct_next_state_tag -> stack slot S3 - -contract field bindings: - amount -> stack slot S4 - active -> stack slot S5 - tag -> stack slot S6 -``` - -The exact slot numbers depend on layout, but this is the shape that matters. - -### Step C: the statement is compiled - -The source statement is: - -```sil -int bumped = next_state.amount + amount; -``` - -For bytecode generation, the compiler lowers the structured field access: - -```text -next_state.amount + amount -``` - -becomes: - -```text -__struct_next_state_amount + amount -``` - -That lowered expression is what matters for actual bytecode generation. - -### Step D: the recorder stores a source step - -While compiling that statement, the recorder creates a `DebugStep` covering the bytecode range for that statement. - -Conceptually: - -```text -DebugStep { - span: "int bumped = next_state.amount + amount;", - kind: Source, - variable_updates: [ - { - name: "bumped", - type_name: "int", - expr: next_state.amount + amount, - runtime_binding: slot for bumped - } - ] -} -``` - -The important point is: - -- debug steps stay source-oriented -- struct/state lowering for ad hoc debugger eval happens later - -### Step E: the session stops on that step and builds scope - -At runtime, when the debugger is stopped on this statement, the session builds `ScopeState`. - -Conceptually it contains: - -```text -visible: - next_state : StructuredBinding(State) - amount : RuntimeSlot - active : RuntimeSlot - tag : RuntimeSlot - bumped : RuntimeSlot or Expr, depending on point in execution - -hidden: - __struct_next_state_amount : RuntimeSlot - __struct_next_state_active : RuntimeSlot - __struct_next_state_tag : RuntimeSlot -``` - -This is why both of these can be true at the same time: - -- `vars` shows `next_state` -- `eval next_state.amount + amount` can still work through hidden leaf bindings - -### Step F: user runs `vars` - -`vars` resolves the visible bindings: - -- `next_state` is reconstructed as an object from its hidden leaves -- `amount`, `active`, and `tag` are read normally -- hidden `__struct_*` names are filtered out - -So the user sees something like: - -```text -next_state = { amount: 5, active: true, tag: 0xaa } -amount = 1 -active = true -tag = 0xaa -bumped = 6 -``` - -### Step G: user runs `eval next_state.amount + amount` - -Now the debugger goes through a second pipeline, separate from the original contract compilation. - -#### G1. Parse the user expression - -The session parses: - -```text -next_state.amount + amount -``` - -into an expression AST. - -#### G2. Build eval context - -From current scope, the session builds: - -- `eval_types` -- `env` -- `stack_bindings` -- `shadow_bindings` - -Conceptually: - -```text -eval_types: - next_state -> State - __struct_next_state_amount -> int - __struct_next_state_active -> bool - __struct_next_state_tag -> byte[1] - amount -> int - active -> bool - tag -> byte[1] - -stack_bindings: - __struct_next_state_amount -> S1 - __struct_next_state_active -> S2 - __struct_next_state_tag -> S3 - amount -> S4 - active -> S5 - tag -> S6 -``` - -`env` only contains bindings represented as expressions rather than runtime slots. - -#### G3. Prepare the expression for eval - -The session calls: - -```text -prepare_debug_expr(next_state.amount + amount, eval_types, source) -``` - -The compiler then: - -- sees that `next_state.amount` is structured access -- parses the contract source if needed -- rebuilds the struct registry -- lowers the expression -- infers the result type - -The prepared result is: - -```text -lowered expr: __struct_next_state_amount + amount -type: int -``` - -#### G4. Compile and run the shadow expression - -Now the session calls: - -```text -compile_debug_expr(__struct_next_state_amount + amount, env, stack_bindings, eval_types) -``` - -That produces bytecode for just the debug expression. - -The session prepends the needed shadow stack values, runs the shadow script, and decodes the result. - -Final result: - -```text -6 -``` - -So the debugger is not inventing struct lowering rules itself. - -It asks the compiler to do the same lowering the compiler already understands. - -## Part 5: Concrete Flow for `inspect_state_array` - -Look at: - -```sil -entrypoint function inspect_state_array(State[] next_states) { - int delta = next_states[1].amount - next_states[0].amount; - require(next_states.length == 2); -} -``` - -### Field access over an array of structs - -This: - -```sil -next_states[1].amount - next_states[0].amount -``` - -lowers to: - -```text -__struct_next_states_amount[1] - __struct_next_states_amount[0] -``` - -### `.length` - -This: - -```sil -next_states.length -``` - -lowers to the length of any one leaf array: - -```text -__struct_next_states_amount.length -``` - -That works because all leaf arrays for one structured array must have the same length. - -### What `eval next_states` does - -This is different from scalar eval. - -`next_states` is a structured result type, so the session does not need to compile a scalar shadow expression for it. - -Instead it reconstructs the visible array by zipping the leaf arrays back together by index. - -Conceptually: - -```text -index 0 -> { amount, active, tag } -index 1 -> { amount, active, tag } -``` - -That is how whole structured values are presented back to the user. - -## Part 6: Concrete Flow for `inspect_pair` - -Look at: - -```sil -entrypoint function inspect_pair(Pair next_pair) { - byte[2] pair_code = next_pair.code; - require(pair_code == next_pair.code); -} -``` - -This is the same model as `State`, just with a user-declared struct. - -Examples: - -```sil -next_pair.code -``` - -lowers to: - -```text -__struct_next_pair_code -``` - -And: - -```sil -eval next_pair -``` - -returns an object: - -```text -{ amount: ..., code: ... } -``` - -The debugger does not treat `State` as magical. `State` is just one particular structured type the compiler knows how to flatten and reconstruct. - -## Part 7: Why Inline Debugging Was Hard - -Look at: - -```sil -entrypoint function inspect_inline(State next_state, Pair next_pair) { - inspect_inner(next_state, next_pair); - require(next_state.active == active); -} -``` - -and: - -```sil -function inspect_inner(State inner_state, Pair inner_pair) { - int bumped = inner_state.amount + amount; - require(bumped > 0); -} -``` - -At the source level, this is simple: - -- `inner_state` is the callee name -- `next_state` is the caller name -- logically they refer to the same immutable value - -At runtime, this is harder: - -- stepping into the inline call changes stack shape -- temporaries appear and disappear -- a slot that used to mean "the source value I care about" can stop being reliable for debugger display - -### The failed idea - -The original idea was: - -- record more inline leaf updates -- keep reading live runtime slots - -That was not enough. - -The source-level value was stable, but the stack layout was not. - -### The final fix - -On `InlineCallEnter`, the session takes a snapshot of caller-visible immutable values: - -- function params -- contract fields - -When building scope inside the inline frame: - -- those snapshot values are frozen -- structured values are decomposed back into hidden leaf bindings -- aliases like `inner_state` can resolve against those frozen values - -Conceptually, right after stepping into: - -```sil -inspect_inner(next_state, next_pair); -``` - -the snapshot is roughly: - -```text -frame 1 snapshot: - next_state = { amount: 5, active: true, tag: 0xaa } - next_pair = { amount: 9, code: 0x1234 } - amount = 1 - active = true - tag = 0xaa -``` - -Inside the inline frame, `inner_state` and `inner_pair` can then be rebuilt from that frozen caller data instead of trusting live slots. - -So inside the callee: - -- `vars` shows `inner_state` and `inner_pair` correctly -- `eval inner_state.amount` -- `eval inner_pair.code` - -keep working even after more stack traffic happens inside the inline body. - -This is the part that makes inline debugging feel source-level instead of stack-level. - -## Part 8: What `eval` Does for Different Kinds of Expressions - -### Plain scalar expression - -Example: - -```sil -amount + 1 -``` - -Flow: - -- parse expression -- no struct lowering needed -- no contract source parse needed -- compile scalar shadow expression -- run shadow VM - -### Scalar expression with struct/state access - -Example: - -```sil -next_state.amount + amount -``` - -Flow: - -- parse expression -- compiler detects structured lowering is needed -- compiler parses source if needed -- compiler rebuilds struct registry -- compiler lowers to hidden leaf names -- session compiles and executes shadow expression - -### Whole structured value - -Example: - -```sil -next_state -next_states -next_pair -``` - -Flow: - -- parse expression -- compiler still prepares the expression and infers the result type -- session sees the final type is structured -- session resolves the value directly from scope data instead of running a scalar shadow script - -That split is important: - -- structured access inside a scalar expression still goes through compiler lowering -- whole structured results are reconstructed directly by the session - -## Part 9: Why Source Text Is Sometimes Required - -This rule becomes simple once you keep the lowering model in mind. - -### Source text is not required - -For expressions like: - -```sil -amount + 1 -``` - -there is no need to know struct layout, so source text is irrelevant. - -### Source text is required - -For expressions like: - -```sil -next_state.amount -next_states[0].amount -next_pair.code -``` - -the compiler needs to know: - -- is `next_state` a `State`? -- is `next_pair` a `Pair`? -- what fields exist? -- what are their types? - -So the compiler must be able to parse the contract source and rebuild the struct registry. - -That is why missing or invalid source is an error only when the expression actually needs structured lowering. - -## Part 10: How to Read the Debugger Architecture - -If you want a compact mental map of the codebase, use this: - -### Compiler - -- lowers source expressions to runtime form -- compiles bytecode -- records debug steps and variable metadata - -Important pieces: - -- `lower_expr(...)` -- `lower_runtime_struct_expr(...)` -- `lower_struct_array_value_expr(...)` -- `prepare_debug_expr(...)` -- `compile_debug_expr(...)` -- `DebugRecorder` - -### Debug info - -- stores source-to-bytecode mapping -- stores param mappings -- stores step-local variable updates -- stores structured leaf metadata - -Important types: - -- `DebugInfo` -- `DebugStep` -- `DebugParamMapping` -- `DebugVariableUpdate` -- `DebugStructuredBinding` - -### Session - -- executes the script opcode by opcode -- finds the active source step for the current byte offset -- builds current source-level scope -- reconstructs structured values -- compiles/evaluates shadow expressions - -Important pieces: - -- `DebugSession` -- `scope_state(...)` -- `capture_inline_scope_snapshot(...)` -- `prepare_expr_for_eval(...)` -- `evaluate_expr_in_scope(...)` - -## Part 11: Practical Ways to Explore This - -Use the example contract directly. - -### Top-level `State` - -```bash -cli-debugger examples/debug_struct_state_matrix.sil --function inspect_state \ - --ctor-arg '{"amount":3,"code":"0x1234"}' \ - --arg '{"amount":5,"active":true,"tag":"0xaa"}' -``` - -Try: - -- `vars` -- `eval next_state` -- `eval next_state.amount` -- `eval next_state.amount + amount` - -### `State[]` - -```bash -cli-debugger examples/debug_struct_state_matrix.sil --function inspect_state_array \ - --ctor-arg '{"amount":3,"code":"0x1234"}' \ - --arg '[{"amount":5,"active":true,"tag":"0xaa"},{"amount":7,"active":true,"tag":"0xaa"}]' -``` - -Try: - -- `eval next_states` -- `eval next_states.length` -- `eval next_states[1].amount - next_states[0].amount` - -### Inline structured scope - -```bash -cli-debugger examples/debug_struct_state_matrix.sil --function inspect_inline \ - --ctor-arg '{"amount":3,"code":"0x1234"}' \ - --arg '{"amount":5,"active":true,"tag":"0xaa"}' \ - --arg '{"amount":9,"code":"0x1234"}' -``` - -Then: - -- `si` -- `si` -- `vars` -- `eval inner_state` -- `eval inner_state.amount` -- `eval inner_pair.code` -- `eval seed_pair` - -That last session is the best one for understanding why inline snapshots exist. - -## Summary - -If you only keep 5 facts in your head, keep these: - -1. `State` and custom `struct`s are source-level shapes, not runtime opaque objects. -2. The compiler flattens them into leaf bindings like `__struct_next_state_amount`. -3. Debug info records enough metadata to map bytecode back to source steps and reconstruct structured values. -4. The session hides flattened names from the user, but still uses them internally for eval and reconstruction. -5. Inline debugging needs snapshots because source-level immutable values stay stable while live stack slots do not. - -Once that model is clear, the rest of the debugger becomes much easier to read: - -- stepping is bytecode range tracking -- `vars` is source-scope reconstruction -- `eval` is compiler-assisted lowering plus either shadow execution or direct structured reconstruction diff --git a/docs/DEBUGGER_STRUCT_STATE_QUICK_FLOW.md b/docs/DEBUGGER_STRUCT_STATE_QUICK_FLOW.md deleted file mode 100644 index bfd8199a..00000000 --- a/docs/DEBUGGER_STRUCT_STATE_QUICK_FLOW.md +++ /dev/null @@ -1,710 +0,0 @@ -# Struct/State Debugger Quick Flow - -This is the short version of [`DEBUGGER_STRUCT_STATE_OVERVIEW.md`](./DEBUGGER_STRUCT_STATE_OVERVIEW.md). - -Use this if you already understand: - -- the compiler records debug info while compiling - -and you want the missing piece: - -- how `State`, custom `struct`s, and covenant prior-state values fit into that model - -If that stepping sentence is still fuzzy, here is the plain version: - -- each source statement is recorded as a `DebugStep` -- each `DebugStep` has a bytecode range: `bytecode_start..bytecode_end` -- while debugging, the session executes one opcode at a time -- after each opcode, the session knows its current bytecode offset -- it finds which recorded step covers that offset -- that is how it knows "we are currently at this source statement" - -So source stepping is not magic. It is just: - -- run opcodes -- keep track of the current byte offset -- look up which recorded source step owns that byte range - -Example: - -```text -statement A -> bytecode 20..27 -statement B -> bytecode 27..33 -statement C -> bytecode 33..41 -``` - -If the VM is currently executing bytecode offset `29`, the session says: - -- offset `29` is inside `27..33` -- so the active source step is statement B - -That is what "mapping bytecode offsets back to recorded source steps" means. - -## The 5 facts that matter - -1. `State` and custom `struct`s are source-level shapes, not runtime objects. -2. The compiler flattens them into hidden leaf bindings like `__struct_next_state_amount`. -3. Debug info records enough metadata to rebuild the source-level shape later. -4. `vars` shows source-level objects, not hidden leaf names. -5. `eval` lowers structured access inside the session by using recorded structured metadata. - -If you keep those 5 facts in your head, the rest of the debugger becomes much easier to read. - -## Before structs: how stepping works - -The debugger has two different notions of position: - -- opcode position: where the VM currently is in the compiled script -- source position: which source statement that bytecode belongs to - -The compiler creates the bridge between them by recording `DebugStep`s. - -Conceptually: - -```text -source: - int bumped = next_state.amount + amount; -> step range 120..129 - require(next_state.active == active); -> step range 129..138 -``` - -During debugging, the session: - -1. executes opcodes -2. tracks the current bytecode offset -3. finds the `DebugStep` whose range contains that offset -4. treats that step's source span as the current source location - -So when you run `si` or `next`, the session is not stepping by source code directly. - -It is: - -- executing bytecode -- watching when the active recorded step changes - -That is the base debugger model. Struct/state support sits on top of that. - -## One concrete contract - -Use: - -- [`examples/debug_struct_state_matrix.sil`](../examples/debug_struct_state_matrix.sil) - -Relevant part: - -```sil -contract DebugStructStateMatrix(Pair seed_pair) { - struct Pair { - int amount; - byte[2] code; - } - - int amount = 1; - bool active = true; - byte[1] tag = 0xaa; - - entrypoint function inspect_state(State next_state) { - int bumped = next_state.amount + amount; - require(next_state.active == active); - } - - function inspect_inner(State inner_state, Pair inner_pair) { - int bumped = inner_state.amount + amount; - require(bumped > 0); - } - - entrypoint function inspect_inline(State next_state, Pair next_pair) { - inspect_inner(next_state, next_pair); - require(next_state.active == active); - } -} -``` - -## The core idea - -At source level: - -```sil -next_state.amount -``` - -At runtime/debug leaf level: - -```text -__struct_next_state_amount -``` - -A whole `State next_state` is really treated as: - -```text -__struct_next_state_amount : int -__struct_next_state_active : bool -__struct_next_state_tag : byte[1] -``` - -So the debugger always does two opposite things: - -- for compilation/eval, it lowers source-level field access to leaf names -- for display, it rebuilds a source-level object from leaf values - -## One end-to-end flow - -Take this statement: - -```sil -int bumped = next_state.amount + amount; -``` - -and this debugger command: - -```text -eval next_state.amount + amount -``` - -### 1. Compiler view - -The compiler knows `State` from the contract fields: - -```text -State: - amount -> int - active -> bool - tag -> byte[1] -``` - -For bytecode generation it lowers: - -```text -next_state.amount + amount -``` - -to: - -```text -__struct_next_state_amount + amount -``` - -That is the runtime form. - -### 2. Debug recording view - -The recorder stores: - -- param metadata for `next_state` -- a source step for the statement -- a variable update for `bumped` - -In Rust terms, this lives inside `DebugInfo`: - -- `DebugInfo.entrypoint_param_bindings: Vec` -- `DebugInfo.steps: Vec` - -For a structured param like `next_state: State`, the important internal shape is: - -- `DebugParamMapping` -- with `binding: DebugParamBinding::StructuredValue` -- containing `leaf_bindings: Vec` - -Conceptually the param metadata says: - -```text -next_state : State - amount -> stack slot - active -> stack slot - tag -> stack slot -``` - -So the debugger is not tracking `next_state` as one opaque runtime thing. - -It is tracking: - -- one visible source name: `next_state` -- plus leaf metadata for `amount`, `active`, `tag` -- plus the runtime slot for each leaf - -That is the internal bridge between source-level structs and runtime stack values. - -For step-local structured values, the same idea appears in a different Rust struct: - -- `DebugVariableUpdate` -- with `structured_leaf_bindings: Option>` - -That is how inline names like `inner_state` can also be treated as structured values, not just entrypoint params. - -That is enough for the session to later rebuild both: - -- the visible object `next_state` -- the hidden leaf bindings `__struct_next_state_amount`, `__struct_next_state_active`, `__struct_next_state_tag` - -### 3. Session view for `vars` - -When stopped on that statement, the session builds scope from: - -- param mappings -- contract fields -- variable updates seen so far -- inline snapshots if needed - -Internally, scope contains both: - -- visible names like `next_state` -- hidden names like `__struct_next_state_amount` - -This is the main job of `session.rs`. - -The important change is: the session now owns the bridge from recorded metadata to debugger-visible scope. - -It does that in two steps: - -1. `scope_state(...)` / `scope_state_from_visible(...)` -2. `collect_variables_map(...)` - -`scope_state_from_visible(...)` reads the recorded metadata and creates `ScopeBinding`s for both views of the same value: - -- one visible structured binding for `next_state` -- one hidden leaf binding per field, like `__struct_next_state_amount` - -For a structured value, the visible binding uses: - -- `ScopeValueSource::StructuredBinding { base_name, leaf_bindings }` - -and for each hidden leaf: - -- `ScopeValueSource::RuntimeSlot { ... }` - -Then `collect_variables_map(...)` iterates the scope and resolves only the visible bindings into user-facing variables. - -That is why `vars` shows: - -```text -next_state = { amount: 5, active: true, tag: 0xaa } -amount = 1 -bumped = 6 -``` - -and does not show: - -```text -__struct_next_state_amount -__struct_next_state_active -__struct_next_state_tag -``` - -Those hidden names still exist in scope. They are just debugger-internal. - -One more small session change matters here: - -- `VariableOrigin` now distinguishes `Param` from `ContractField` - -That is why the CLI can print: - -- `Contract State` -- `Call Arguments` -- `Locals` - -### 4. Session view for `eval` - -For: - -```text -eval next_state.amount + amount -``` - -the session: - -1. parses the user expression -2. builds current `scope_state` -3. checks whether the expression is already a direct structured value like `next_state` -4. if it is scalar structured access, lowers it inside `session.rs` - -The important session-side lowering helpers are: - -- `lower_structured_field_access_for_eval(...)` -- `lower_structured_length_for_eval(...)` -- `lower_expr_for_eval(...)` - -So for: - -```text -next_state.amount + amount -``` - -the session lowers it to: - -```text -__struct_next_state_amount + amount -``` - -using the current `ScopeValueSource::StructuredBinding` metadata, not by reparsing contract source through the compiler. - -Then it: - -5. builds shadow bindings with `scope_state_eval_context(...)` -6. calls `compile_debug_expr(...)` -7. runs the shadow expression -8. decodes the result - -So the current design is: - -- compiler records struct/state metadata -- session reconstructs scope from that metadata -- session lowers structured eval using that metadata -- compiler still compiles the final scalar debug expression - -## Why whole structured values are different - -These two cases are different: - -```text -eval next_state.amount + amount -eval next_state -``` - -The first one is scalar, so the debugger: - -- lowers it -- compiles it -- runs it in the shadow VM - -The second one is structured, so the debugger: - -- does not need a scalar shadow expression -- reconstructs the object directly from leaf values - -That distinction explains a lot of the code shape. - -In `session.rs`, that split appears here: - -- `evaluate_expr_in_scope(...)` first checks `direct_expr_type_name(...)` -- if the result type is structured, it reconstructs the value directly -- otherwise it uses `lower_expr_for_eval(...)` and shadow execution - -## Why inline calls are tricky - -Inside: - -```sil -inspect_inner(next_state, next_pair) -``` - -the source-level values are stable, but live stack slots can drift as the inline body executes. - -So the session snapshots caller-visible immutable values at `InlineCallEnter`. - -That is why these keep working inside the inline frame: - -- `vars` -- `eval inner_state` -- `eval inner_state.amount` - -without trusting whatever the live stack happens to look like later. - -## The missing top half: what happens before stepping starts - -Everything above explains how a stopped session reconstructs source-level values. - -But there is an earlier half of the flow: - -1. the CLI loads source and parses the contract AST -2. constructor args are parsed into typed AST expressions -3. the contract is compiled with debug recording enabled -4. call args are parsed against the selected callable -5. the CLI builds the sigscript for that callable -6. the CLI builds a transaction scenario around it -7. the session starts from bytecode plus `DebugInfo` -8. the session runs forward until the first real source statement - -That is the full debugger pipeline. - -In code, the start of that flow is mainly in: - -- `debugger/cli/src/main.rs` -- `debugger/session/src/args.rs` - -Conceptually it looks like this: - -```text -source text - -> parse contract AST - -> parse ctor args / call args - -> compile contract + DebugInfo - -> build sigscript - -> build debug tx context - -> create DebugSession - -> run to first executed source statement - -> vars / eval / stepping -``` - -## Constructor args, constants, and contract state - -There are 3 different categories of source-level values that the user sees very early: - -- constructor args -- contract fields -- call arguments - -They do not all come from the same place. - -### Constructor args - -Constructor args are parsed first by the CLI and compiled into the contract script. - -For debugger display, recorded constructor arg values are later inserted into scope from `DebugInfo`: - -- `record_debug_named_values(..., &self.debug_info.constructor_args, ...)` - -Those are not read from the live VM stack at inspection time. - -They are already known debug values. - -### Contract fields - -Contract fields are source-level state, but at runtime they are still just normal lowered bindings. - -The session reconstructs them from param metadata and then classifies them by source contract field name: - -- `param_origin(...)` - -That is why the CLI can separate: - -- `Contract State` -- `Call Arguments` - -instead of dumping everything into one flat variable list. - -### Call arguments - -Call arguments come from the selected function signature. - -For normal entrypoints, `parse_call_args(...)` parses them directly against the chosen function in the lowered AST. - -For covenant declarations, there is one extra translation layer, described below. - -## Why `run_to_first_executed_statement()` matters - -When the session is first created, the VM is still at the start of the compiled script. - -That does not necessarily mean the user is at the first meaningful source statement. - -There may be: - -- dispatch/setup opcodes -- selector handling -- covenant wrapper prelude bytecode -- other synthetic setup ranges - -So the CLI calls: - -- `run_to_first_executed_statement()` - -That method keeps stepping raw opcodes until: - -- the engine is executing -- the current byte offset falls inside a steppable `DebugStep` - -Only then does the REPL show the initial source location. - -That is why the first debugger screen already feels source-oriented instead of exposing compiler setup bytecode. - -## The covenant-specific flow - -Struct/state support and covenant support meet in one important place: - -- the debugger should show source-level covenant names and source-level prior state values - -not the generated wrapper names and hidden covenant temporaries. - -### Function selection - -For a normal function, the CLI path is simple: - -- parse args against that function -- call `build_sig_script(...)` - -For a source-level covenant declaration like: - -```sil -#[covenant(binding = cov, from = 2, to = 2, mode = verification)] -function rebalance(State[] prev_states, State[] new_states) { ... } -``` - -the CLI does something more careful: - -1. parse the original contract AST -2. analyze covenant declarations with `analyze_covenant_declarations(...)` -3. resolve `rebalance` plus role into a generated lowered entrypoint with `resolve_covenant_decl_call_target(...)` -4. parse call args against that generated lowered entrypoint name -5. build the sigscript through `build_sig_script_for_covenant_decl(...)` - -That distinction matters because: - -- source-level covenant params include implicit prior-state params like `prev_state` or `prev_states` -- the lowered callable signature is what the current argument parser understands -- the user-facing function name should still stay source-level - -For `binding = cov`: - -- leader is the default -- `--delegate` opts into the delegate path -- in `.test.json`, `"delegate": true` does the same thing - -### Prior state injection - -For covenant debugging, source-level prior state is not read from normal local updates. - -Instead, the CLI builds a debug-only shadow transaction context: - -- it resolves constructor args into source-level state values -- it attaches those values to `ShadowTxContext` -- the session receives that context via `with_shadow_tx_context(...)` - -Then `session.rs` injects source-level bindings with: - -- `inject_covenant_prev_state_bindings(...)` - -That means: - -- auth covenants get `prev_state` -- cov covenants get `prev_states` - -as real debugger-visible names. - -So commands like: - -- `vars` -- `p prev_states` -- `eval prev_states[0].value` - -work through the same structured binding machinery as any other `State` or `State[]` value. - -If the shadow tx context does not contain those values, the bindings still exist conceptually, but resolve as unavailable instead of silently disappearing. - -### Source-level covenant names - -The session also parses the source AST at startup and analyzes covenant declarations again. - -That allows it to normalize lowered names like: - -- `__leader_rebalance` -- `__delegate_rebalance` -- `__covenant_policy_rebalance` - -back into source-oriented labels like: - -- `rebalance [leader]` -- `rebalance [delegate]` -- `rebalance` - -That normalization is used in: - -- current function display -- call stack display -- failure reports - -So the debugger stays focused on source intent, not lowering internals. - -## One compressed end-to-end pipeline - -If you want the whole thing in one pass, this is the shortest accurate version: - -1. The CLI parses source into a contract AST. -2. It parses constructor args into typed expressions with `parse_ctor_args(...)`. -3. It compiles the contract with debug recording enabled, producing bytecode plus `DebugInfo`. -4. It resolves the selected function. -5. For covenant declarations, it resolves source name plus role to a lowered target for arg parsing, but still builds the action script from the source-level declaration name. -6. It parses call args into typed expressions with `parse_call_args(...)`. -7. It builds the sigscript with `build_sig_script(...)` or `build_sig_script_for_covenant_decl(...)`. -8. It builds a debug transaction scenario and stores prior state values in `ShadowTxContext`. -9. It creates `DebugSession::full(...)` and calls `run_to_first_executed_statement()`. -10. While stepping, the session maps bytecode offsets to `DebugStep`s, reconstructs visible scope from param mappings plus variable updates plus inline snapshots plus covenant prior-state injection, and then serves `vars` / `print` / `eval`. - -That is the entire flow. - -## Arrays and nested structured values - -One subtle point is that arrays of structured values are still flattened leaf-first. - -For: - -```sil -State[] next_states -``` - -the runtime/debug leaf model is conceptually: - -```text -__struct_next_states_amount : int[] -__struct_next_states_active : bool[] -__struct_next_states_tag : byte[1][] -``` - -So: - -```text -eval next_states[1].amount -``` - -becomes a leaf-array access: - -```text -__struct_next_states_amount[1] -``` - -This is why the same design works for: - -- `State` -- `State[]` -- custom `struct` -- custom `struct[]` - -The debugger never needs a separate object runtime. - -It just keeps rebuilding source-level shapes from flattened leaves. - -## If you want to read the code - -Use this map: - -- CLI entry + tx setup: `main.rs` -- CLI arg parsing: `parse_ctor_args(...)`, `parse_call_args(...)` -- covenant source-level resolution: `analyze_covenant_declarations(...)`, `resolve_covenant_decl_call_target(...)` -- covenant sigscript building: `build_sig_script_for_covenant_decl(...)` -- state reconstruction from ctor args: `resolve_contract_state_values(...)` -- compiler lowering: `lower_expr(...)`, `lower_runtime_struct_expr(...)` -- debug recording: `DebugRecorder` -- session startup: `DebugSession::full(...)`, `run_to_first_executed_statement(...)` -- session scope reconstruction: `scope_state(...)`, `scope_state_from_visible(...)`, `collect_variables_map(...)` -- covenant binding injection: `inject_covenant_prev_state_bindings(...)` -- inline scope freezing: `freeze_inline_snapshot_bindings(...)` -- session eval lowering: `lower_structured_field_access_for_eval(...)`, `lower_structured_length_for_eval(...)`, `lower_expr_for_eval(...)` -- session eval execution: `evaluate_expr_in_scope(...)`, `scope_state_eval_context(...)` - -## If you want to explore it live - -```bash -cli-debugger examples/debug_struct_state_matrix.sil --function inspect_state \ - --ctor-arg '{"amount":3,"code":"0x1234"}' \ - --arg '{"amount":5,"active":true,"tag":"0xaa"}' -``` - -Then try: - -- `vars` -- `eval next_state` -- `eval next_state.amount` -- `eval next_state.amount + amount` - -For inline behavior: - -```bash -cli-debugger examples/debug_struct_state_matrix.sil --function inspect_inline \ - --ctor-arg '{"amount":3,"code":"0x1234"}' \ - --arg '{"amount":5,"active":true,"tag":"0xaa"}' \ - --arg '{"amount":9,"code":"0x1234"}' -``` - -Then: - -- `si` -- `si` -- `vars` -- `eval inner_state.amount` diff --git a/examples/debug_small_inline.sil b/examples/debug_small_inline.sil deleted file mode 100644 index 07560c75..00000000 --- a/examples/debug_small_inline.sil +++ /dev/null @@ -1,15 +0,0 @@ -pragma silverscript ^0.1.0; - -contract DebugSmallInline() { - int amount = 1; - bool active = true; - byte[1] tag = 0xaa; - - - entrypoint function inspect(State[] next_states) { - console.log("total sum of amounts: ", next_states[0].amount + next_states[1].amount); - require(next_states[0].active == active); - require(next_states[0].tag == tag); - require(next_states[1].tag == 0xbb); - } -} diff --git a/examples/debug_struct_state_matrix.ctor.json b/examples/debug_struct_state_matrix.ctor.json deleted file mode 100644 index a1a30386..00000000 --- a/examples/debug_struct_state_matrix.ctor.json +++ /dev/null @@ -1,30 +0,0 @@ -[ - { - "kind": "state_object", - "data": [ - { - "name": "amount", - "expr": { - "kind": "int", - "data": 3 - } - }, - { - "name": "code", - "expr": { - "kind": "array", - "data": [ - { - "kind": "byte", - "data": 18 - }, - { - "kind": "byte", - "data": 52 - } - ] - } - } - ] - } -] diff --git a/examples/debug_struct_state_matrix.sil b/examples/debug_struct_state_matrix.sil deleted file mode 100644 index eae2ea6e..00000000 --- a/examples/debug_struct_state_matrix.sil +++ /dev/null @@ -1,110 +0,0 @@ -pragma silverscript ^0.1.0; - -// Structured debugger feature matrix. -// -// Suggested CLI sessions: -// -// 1. Top-level State -// silverc examples/debug_struct_state_matrix.sil \ -// --constructor-args examples/debug_struct_state_matrix.ctor.json -c -// cli-debugger examples/debug_struct_state_matrix.sil --function inspect_state \ -// --ctor-arg '{"amount":3,"code":"0x1234"}' \ -// --arg '{"amount":5,"active":true,"tag":"0xaa"}' -// eval next_state -// eval next_state.amount -// eval next_state.amount + amount -// -// 2. State[] -// cli-debugger examples/debug_struct_state_matrix.sil --function inspect_state_array \ -// --ctor-arg '{"amount":3,"code":"0x1234"}' \ -// --arg '[{"amount":5,"active":true,"tag":"0xaa"},{"amount":7,"active":true,"tag":"0xaa"}]' -// eval next_states -// eval next_states.length -// eval next_states[1].amount - next_states[0].amount -// -// 3. Custom struct and custom struct[] -// cli-debugger examples/debug_struct_state_matrix.sil --function inspect_pair \ -// --ctor-arg '{"amount":3,"code":"0x1234"}' \ -// --arg '{"amount":9,"code":"0x1234"}' -// eval next_pair -// eval next_pair.code -// -// cli-debugger examples/debug_struct_state_matrix.sil --function inspect_pair_array \ -// --ctor-arg '{"amount":3,"code":"0x1234"}' \ -// --arg '[{"amount":9,"code":"0x1234"},{"amount":11,"code":"0x1234"}]' -// eval next_pairs.length -// eval next_pairs[1].amount - next_pairs[0].amount -// -// 4. Inline structured scope -// cli-debugger examples/debug_struct_state_matrix.sil --function inspect_inline \ -// --ctor-arg '{"amount":3,"code":"0x1234"}' \ -// --arg '{"amount":5,"active":true,"tag":"0xaa"}' \ -// --arg '{"amount":9,"code":"0x1234"}' -// si -// si -// vars -// eval inner_state -// eval inner_pair.code -// eval seed_pair -// eval seed_pair.code - -contract DebugStructStateMatrix(Pair seed_pair) { - struct Pair { - int amount; - byte[2] code; - } - - int amount = 1; - bool active = true; - byte[1] tag = 0xaa; - - function inspect_deeper(State deeper_state, Pair deeper_pair) { - int state_plus_amount = deeper_state.amount + amount; - require(state_plus_amount > 0); - require(deeper_pair.amount > 0); - } - - function inspect_inner(State inner_state, Pair inner_pair) { - int bumped = inner_state.amount + amount; - require(bumped > 0); - inspect_deeper(inner_state, inner_pair); - } - - entrypoint function inspect_state(State next_state) { - int bumped = next_state.amount + amount; - require(bumped > 0); - require(next_state.active == active); - require(next_state.tag == tag); - } - - entrypoint function inspect_state_array(State[] next_states) { - int delta = next_states[1].amount - next_states[0].amount; - require(next_states.length == 2); - require(delta > 0); - require(next_states[0].tag == tag); - } - - entrypoint function inspect_pair(Pair next_pair) { - int bumped = next_pair.amount + 1; - byte[2] pair_code = next_pair.code; - require(bumped > 0); - require(pair_code == next_pair.code); - } - - entrypoint function inspect_pair_array(Pair[] next_pairs) { - int delta = next_pairs[1].amount - next_pairs[0].amount; - require(next_pairs.length == 2); - require(delta > 0); - require(next_pairs[0].code == next_pairs[1].code); - } - - function lol(State inner_state) { - int bumped = inner_state.amount + 1; - return bumped - } - - entrypoint function inspect_inline(State next_state, Pair next_pair) { - inspect_inner(next_state, next_pair); - require(next_state.active == active); - } -} From bf124baf7d036bfaa5529e3b20620ff7d0ad15f8 Mon Sep 17 00:00:00 2001 From: Manyfestation <240733973+Manyfestation@users.noreply.github.com> Date: Sun, 29 Mar 2026 15:30:50 +0300 Subject: [PATCH 04/11] wip --- debugger/cli/README.md | 34 ++ debugger/cli/src/main.rs | 307 +++++++++++------ debugger/cli/tests/cli_tests.rs | 324 ++++++++++++++++++ debugger/session/src/session.rs | 197 +++++++---- debugger/session/src/test_runner.rs | 6 +- debugger/session/tests/debug_session_tests.rs | 111 +++++- silverscript-lang/src/compiler.rs | 200 ++++++----- .../src/compiler/covenant_declarations.rs | 133 +++---- .../src/compiler/debug_recording.rs | 83 ++++- silverscript-lang/src/debug_info.rs | 9 + silverscript-lang/tests/compiler_tests.rs | 57 ++- 11 files changed, 1127 insertions(+), 334 deletions(-) diff --git a/debugger/cli/README.md b/debugger/cli/README.md index c0c8e255..6a01653c 100644 --- a/debugger/cli/README.md +++ b/debugger/cli/README.md @@ -20,6 +20,13 @@ cli-debugger ./vault.sil -f inspect --arg '{"amount":7,"tag":"0xbeef"}' cli-debugger ./vault.sil -f inspect_many --arg '[{"amount":7},{"amount":9}]' ``` +Contracts with source-level covenant declarations use the same debugger entrypoints. Pass the source function name to `--function`; the CLI resolves it to the generated wrapper and, when the fixture includes covenant transaction context, exposes `prev_state` and `prev_states` in scope while you step through the transition. + +```bash +cli-debugger ./counter.sil --function step --test-file ./counter.test.json --test-name source_leader +cli-debugger ./counter.sil --function rebalance --delegate --test-file ./counter.test.json --test-name source_delegate +``` + --- ## Interactive Debugging @@ -151,6 +158,33 @@ Structured args use the same JSON object and object-array form inside `.test.jso } ``` +For covenant flows, the `.test.json` file can describe the full state transition: the covenant states being spent on the input side, and the covenant states the transaction is expected to create on the output side. That gives the debugger enough context to populate `prev_state` / `prev_states` and makes the test data read like the transition under inspection. + +```json +{ + "tests": [ + { + "name": "source_leader", + "function": "step", + "expect": "pass", + "tx": { + "active_input_index": 0, + "inputs": [ + { "utxo_value": 1000, "covenant_id": 1, "state": { "value": 7 } }, + { "utxo_value": 1000, "covenant_id": 1, "state": { "value": 9 } } + ], + "outputs": [ + { "value": 1000, "covenant_id": 1, "state": { "value": 11 } }, + { "value": 1000, "covenant_id": 1, "state": { "value": 13 } } + ] + } + } + ] +} +``` + +Here the inputs describe the prior covenant state, and the outputs describe the next one. If `args` is omitted, `State` / `State[]` call args are inferred from matching `tx.outputs[*].state`. + ### Test Commands ```bash diff --git a/debugger/cli/src/main.rs b/debugger/cli/src/main.rs index 314f9878..95b00006 100644 --- a/debugger/cli/src/main.rs +++ b/debugger/cli/src/main.rs @@ -23,9 +23,10 @@ use kaspa_txscript::script_builder::ScriptBuilder; use kaspa_txscript::{EngineCtx, EngineFlags, pay_to_script_hash_script}; use silverscript_lang::ast::{ContractAst, Expr, ExprKind, parse_contract_ast}; use silverscript_lang::compiler::{ - CompileOptions, CovenantDeclBinding, CovenantDeclCallOptions, compile_contract, compile_contract_ast, - resolve_contract_state_values, + CompileOptions, CovenantDeclBinding, CovenantDeclCallOptions, ResolvedCovenantCallTarget, compile_contract, + materialize_state_script, }; +use silverscript_lang::debug_info::DebugInfo; const PROMPT: &str = "(sdb) "; @@ -101,6 +102,7 @@ fn expr_to_debug_value(expr: &Expr<'_>) -> Result, raw_ctor_args: &[String], cache: &mut HashMap, debugger_session::session::DebugValue>, @@ -110,17 +112,31 @@ fn resolve_state_for_ctor_args( } let ctor_args = parse_ctor_args(parsed_contract, raw_ctor_args)?; - let state_fields = resolve_contract_state_values(parsed_contract, &ctor_args)?; - let value = debugger_session::session::DebugValue::Object( - state_fields - .iter() - .map(|field| Ok((field.name.clone(), expr_to_debug_value(&field.value)?))) - .collect::, String>>()?, - ); + let compile_opts = CompileOptions { record_debug_infos: true, ..Default::default() }; + let compiled = compile_contract(source, &ctor_args, compile_opts)?; + let value = contract_state_from_debug_info( + compiled + .debug_info + .as_ref() + .ok_or_else(|| "state resolution requires debug-enabled compilation".to_string()) + .map_err(|err| -> Box { err.into() })?, + )?; cache.insert(raw_ctor_args.to_vec(), value.clone()); Ok(value) } +fn contract_state_from_debug_info( + debug_info: &DebugInfo<'_>, +) -> Result> { + let fields = debug_info + .contract_state + .iter() + .map(|field| Ok((field.name.clone(), expr_to_debug_value(&field.value)?))) + .collect::, String>>() + .map_err(|err| -> Box { err.into() })?; + Ok(debugger_session::session::DebugValue::Object(fields)) +} + fn resolve_state_from_raw( parsed_contract: &ContractAst<'_>, raw_state: &str, @@ -136,66 +152,125 @@ fn resolve_state_from_raw( Ok(value) } -fn materialize_script_for_explicit_state( - source: &str, - parsed_contract: &ContractAst<'_>, - raw_instance_args: &[String], - raw_state: &str, -) -> Result, Box> { - let instance_args = parse_ctor_args(parsed_contract, raw_instance_args)?; - let state = parse_state_value(parsed_contract, raw_state)?; - let base_compiled = compile_contract(source, &instance_args, CompileOptions::default())?; - let materialized_contract = contract_with_explicit_state(parsed_contract, &state)?; - let materialized = compile_contract_ast(&materialized_contract, &instance_args, CompileOptions::default())?; - let base_layout = base_compiled.state_layout; - let materialized_layout = materialized.state_layout; - if base_layout.len == 0 { - return Err("contract does not expose a materializable state segment".into()); - } - if materialized_layout.len == 0 { - return Err("materialized contract did not expose a state segment".into()); - } - let base_start = base_layout.start; - let base_end = base_layout.start + base_layout.len; - let materialized_start = materialized_layout.start; - let materialized_end = materialized_layout.start + materialized_layout.len; - let base_len = base_layout.len; - let materialized_len = materialized_layout.len; - if base_len != materialized_len { - return Err("explicit state changes encoded script size; provide raw script_hex instead".into()); - } - if base_compiled.script.len() < base_end || materialized.script.len() < materialized_end { - return Err("state template range exceeds compiled script length".into()); - } - if base_compiled.script[..base_start] != materialized.script[..materialized_start] - || base_compiled.script[base_end..] != materialized.script[materialized_end..] - { - return Err("explicit state changed non-state bytecode; provide raw script_hex instead".into()); - } - - let mut script = base_compiled.script; - script[base_start..base_end].copy_from_slice(&materialized.script[materialized_start..materialized_end]); - Ok(script) +fn infer_omitted_covenant_args( + contract: &ContractAst<'_>, + target: &ResolvedCovenantCallTarget, + tx: &TestTxScenarioResolved, +) -> Result, String> { + if tx.active_input_index >= tx.inputs.len() { + return Err(format!("tx.active_input_index {} out of range for {} inputs", tx.active_input_index, tx.inputs.len())); + } + + let generated_entrypoint_name = target.generated_entrypoint_name(); + let function = contract + .functions + .iter() + .find(|function| function.name == generated_entrypoint_name) + .ok_or_else(|| format!("generated covenant entrypoint '{}' not found", generated_entrypoint_name))?; + let output_states = matching_covenant_output_states(target, tx)?; + + let mut inferred = Vec::with_capacity(function.params.len()); + let mut unresolved = Vec::new(); + for param in &function.params { + let type_name = param.type_ref.type_name(); + if type_name == "State" { + if output_states.len() == 1 { + inferred.push(output_states[0].to_string()); + } else { + unresolved.push(format!( + "{} ({}) requires exactly 1 matching tx.outputs[*].state, found {}", + param.name, + type_name, + output_states.len() + )); + } + continue; + } + + if type_name.starts_with("State[") { + inferred.push(encode_state_array_arg(&output_states)?); + continue; + } + + unresolved.push(format!("{} ({})", param.name, type_name)); + } + + if unresolved.is_empty() { + Ok(inferred) + } else { + Err(format!( + "cannot infer omitted args for covenant '{}'; provide explicit args for {}", + target.info.source_name, + unresolved.join(", ") + )) + } } -fn contract_with_explicit_state<'i>(contract: &ContractAst<'i>, state: &Expr<'i>) -> Result, String> { - let ExprKind::StateObject(entries) = &state.kind else { - return Err("State value must be an object literal".to_string()); +fn matching_covenant_output_states<'a>( + target: &ResolvedCovenantCallTarget, + tx: &'a TestTxScenarioResolved, +) -> Result, String> { + let active_input = u16::try_from(tx.active_input_index) + .map_err(|_| format!("tx.active_input_index {} exceeds supported range", tx.active_input_index))?; + + let matching_outputs = match target.info.binding { + CovenantDeclBinding::Auth => tx + .outputs + .iter() + .enumerate() + .filter(|(_, output)| output.covenant_id.is_some() && output.authorizing_input.unwrap_or(active_input) == active_input) + .collect::>(), + CovenantDeclBinding::Cov => { + let active_covenant_id = tx.inputs[tx.active_input_index].covenant_id.as_ref().ok_or_else(|| { + format!( + "cannot infer omitted args for covenant '{}'; tx.inputs[{}].covenant_id is required", + target.info.source_name, tx.active_input_index + ) + })?; + tx.outputs + .iter() + .enumerate() + .filter(|(_, output)| output.covenant_id.as_ref() == Some(active_covenant_id)) + .collect::>() + } }; - let mut provided = entries.iter().map(|entry| (entry.name.as_str(), entry.expr.clone())).collect::>(); - if provided.len() != contract.fields.len() { - return Err("State value must include all contract fields exactly once".to_string()); + let mut states = Vec::with_capacity(matching_outputs.len()); + let mut missing_state_indexes = Vec::new(); + for (index, output) in matching_outputs { + if let Some(state) = output.state.as_deref() { + states.push(state); + } else { + missing_state_indexes.push(index); + } } - let mut materialized = contract.clone(); - for field in &mut materialized.fields { - field.expr = provided.remove(field.name.as_str()).ok_or_else(|| format!("missing state field '{}'", field.name))?; - } - if let Some(extra) = provided.keys().next() { - return Err(format!("unknown state field '{}'", extra)); + if missing_state_indexes.is_empty() { + Ok(states) + } else { + Err(format!( + "cannot infer omitted args for covenant '{}'; add tx.outputs[*].state for output indexes {}", + target.info.source_name, + missing_state_indexes.iter().map(|index| index.to_string()).collect::>().join(", ") + )) } - Ok(materialized) +} + +fn encode_state_array_arg(output_states: &[&str]) -> Result { + Ok(format!("[{}]", output_states.join(","))) +} + +fn materialize_script_for_explicit_state( + source: &str, + parsed_contract: &ContractAst<'_>, + raw_ctor_args: &[String], + raw_state: &str, +) -> Result, Box> { + let ctor_args = parse_ctor_args(parsed_contract, raw_ctor_args)?; + let state = parse_state_value(parsed_contract, raw_state)?; + let compile_opts = CompileOptions { record_debug_infos: true, ..Default::default() }; + let base_compiled = compile_contract(source, &ctor_args, compile_opts)?; + Ok(materialize_state_script(&base_compiled, &state)?) } fn parse_hex_32(raw: &str, name: &str) -> Result<[u8; 32], Box> { @@ -331,8 +406,10 @@ fn print_non_status_stdout(stdout: &str) { fn show_step_view(session: &DebugSession<'_, '_>, console_lines: &[String]) { show_source_context(session); show_vars(session); - println!("Console:"); - print_console_messages(console_lines); + if !console_lines.is_empty() { + println!("Console:"); + print_console_messages(console_lines); + } } fn print_failure(session: &DebugSession<'_, '_>, err: kaspa_txscript_errors::TxScriptError) { @@ -573,32 +650,48 @@ fn main() -> Result<(), Box> { } else { None }; - let (script_path, raw_constructor_args, selected_name, raw_args, delegate, tx_scenario, expect) = if let Some(test_file) = - inferred_test_file.as_deref() - { - let test_name = cli.test_name.as_deref().ok_or("--test-name requires --test-file or SCRIPT_PATH")?; - let script_override = cli.script_path.as_deref().map(Path::new); - let resolved = - resolve_contract_test(test_file, test_name, script_override).map_err(|e| -> Box { e.into() })?; - let constructor_args = if !cli.raw_ctor_args.is_empty() { cli.raw_ctor_args.clone() } else { resolved.test.constructor_args }; - let fname = cli.function_name.clone().unwrap_or(resolved.test.function); - let args = if !cli.raw_args.is_empty() { cli.raw_args.clone() } else { resolved.test.args }; - let expect = Some(resolved.test.expect); - (resolved.script_path, constructor_args, fname, args, cli.delegate || resolved.test.delegate, resolved.test.tx, expect) - } else { - let path = cli.script_path.as_deref().ok_or("missing script path: pass SCRIPT_PATH or --test-file")?; - let constructor_args = cli.raw_ctor_args.clone(); - let entrypoint_args = cli.raw_args.clone(); - ( - PathBuf::from(path), - constructor_args, - cli.function_name.clone().unwrap_or_default(), - entrypoint_args, - cli.delegate, - None, - None, - ) - }; + let (script_path, raw_constructor_args, selected_name, raw_args, allow_omitted_test_args_inference, delegate, tx_scenario, expect) = + if let Some(test_file) = inferred_test_file.as_deref() { + let test_name = cli.test_name.as_deref().ok_or("--test-name requires --test-file or SCRIPT_PATH")?; + let script_override = cli.script_path.as_deref().map(Path::new); + let resolved = resolve_contract_test(test_file, test_name, script_override) + .map_err(|e| -> Box { e.into() })?; + let constructor_args = + if !cli.raw_ctor_args.is_empty() { cli.raw_ctor_args.clone() } else { resolved.test.constructor_args }; + let fname = cli.function_name.clone().unwrap_or(resolved.test.function); + let (args, allow_inference) = if !cli.raw_args.is_empty() { + (cli.raw_args.clone(), false) + } else if let Some(args) = resolved.test.args { + (args, false) + } else { + (Vec::new(), true) + }; + let expect = Some(resolved.test.expect); + ( + resolved.script_path, + constructor_args, + fname, + args, + allow_inference, + cli.delegate || resolved.test.delegate, + resolved.test.tx, + expect, + ) + } else { + let path = cli.script_path.as_deref().ok_or("missing script path: pass SCRIPT_PATH or --test-file")?; + let constructor_args = cli.raw_ctor_args.clone(); + let entrypoint_args = cli.raw_args.clone(); + ( + PathBuf::from(path), + constructor_args, + cli.function_name.clone().unwrap_or_default(), + entrypoint_args, + false, + cli.delegate, + None, + None, + ) + }; let source = fs::read_to_string(&script_path)?; let parsed_contract = parse_contract_ast(&source)?; @@ -612,7 +705,11 @@ fn main() -> Result<(), Box> { let mut explicit_state_cache = HashMap::::new(); ctor_script_cache.insert(raw_constructor_args.clone(), compiled.script.clone()); if !parsed_contract.fields.is_empty() { - let root_state = resolve_state_for_ctor_args(&parsed_contract, &raw_constructor_args, &mut ctor_state_cache)?; + let root_state = if let Some(debug_info) = debug_info.as_ref() { + contract_state_from_debug_info(debug_info)? + } else { + resolve_state_for_ctor_args(&source, &parsed_contract, &raw_constructor_args, &mut ctor_state_cache)? + }; ctor_state_cache.insert(raw_constructor_args.clone(), root_state); } @@ -624,12 +721,21 @@ fn main() -> Result<(), Box> { let covenant_target = compiled.resolve_covenant_call_target(&selected_name, CovenantDeclCallOptions { is_leader: !delegate }); let covenant_binding = covenant_target.as_ref().map(|target| target.info.binding); - let enable_covenant_session_mode = covenant_target.is_some(); + let raw_args = if allow_omitted_test_args_inference { + if let (Some(target), Some(tx)) = (covenant_target.as_ref(), tx_scenario.as_ref()) { + infer_omitted_covenant_args(&compiled.ast, target, tx).map_err(|err| -> Box { err.into() })? + } else { + raw_args + } + } else { + raw_args + }; let sigscript = if let Some(target) = covenant_target.as_ref() { if delegate && target.info.binding != CovenantDeclBinding::Cov { return Err("--delegate only applies to binding=cov covenant declarations".into()); } - let typed_args = parse_call_args(&compiled.ast, &target.generated_entrypoint_name, &raw_args)?; + let generated_entrypoint_name = target.generated_entrypoint_name(); + let typed_args = parse_call_args(&compiled.ast, &generated_entrypoint_name, &raw_args)?; compiled.build_sig_script_for_covenant_decl(&selected_name, typed_args, CovenantDeclCallOptions { is_leader: !delegate })? } else { if delegate { @@ -691,7 +797,7 @@ fn main() -> Result<(), Box> { let input_covenant_state = if let Some(raw_state) = input.state.as_deref() { Some(resolve_state_from_raw(&parsed_contract, raw_state, &mut explicit_state_cache)?) } else if input.utxo_script_hex.is_none() || input.constructor_args.is_some() { - Some(resolve_state_for_ctor_args(&parsed_contract, &input_constructor_args, &mut ctor_state_cache)?) + Some(resolve_state_for_ctor_args(&source, &parsed_contract, &input_constructor_args, &mut ctor_state_cache)?) } else { None }; @@ -783,7 +889,7 @@ fn main() -> Result<(), Box> { kas_tx.inputs.get(tx.active_input_index).ok_or_else(|| format!("missing tx input at index {}", tx.active_input_index))?; let active_utxo = populated_tx.utxo(tx.active_input_index).ok_or_else(|| format!("missing utxo entry for input {}", tx.active_input_index))?; - let active_covenant_input_state = input_covenant_states.get(tx.active_input_index).cloned().flatten(); + let active_input_state = input_covenant_states.get(tx.active_input_index).cloned().flatten(); let active_lockscript = input_redeem_scripts.get(tx.active_input_index).cloned().flatten().unwrap_or_else(|| compiled.script.clone()); let covenant_input_states = active_utxo.covenant_id.and_then(|covenant_id| { @@ -797,17 +903,18 @@ fn main() -> Result<(), Box> { Some(values) }); let covenant_param_value = match covenant_binding { - Some(CovenantDeclBinding::Auth) => active_covenant_input_state.clone(), + Some(CovenantDeclBinding::Auth) => active_input_state.clone(), Some(CovenantDeclBinding::Cov) => covenant_input_states.clone().map(DebugValue::Array), None => None, }; let engine = DebugEngine::from_transaction_input(&populated_tx, active_input, tx.active_input_index, active_utxo, ctx, flags); let shadow_tx_context = ShadowTxContext { tx: &populated_tx, input: active_input, input_index: tx.active_input_index, utxo_entry: active_utxo }; - let mut session = - DebugSession::full(&sigscript, &active_lockscript, &source, debug_info, engine)?.with_shadow_tx_context(shadow_tx_context); - if enable_covenant_session_mode { - session = session.with_covenant_mode(compiled.covenant_infos.clone(), covenant_param_value, covenant_target); + let mut session = DebugSession::full(&sigscript, &active_lockscript, &source, debug_info, engine)? + .with_shadow_tx_context(shadow_tx_context) + .with_active_contract_state(active_input_state.clone()); + if let Some(covenant_target) = covenant_target { + session = session.with_covenant_mode(covenant_param_value, covenant_target); } if cli.run { diff --git a/debugger/cli/tests/cli_tests.rs b/debugger/cli/tests/cli_tests.rs index b40094fa..08ebcc5d 100644 --- a/debugger/cli/tests/cli_tests.rs +++ b/debugger/cli/tests/cli_tests.rs @@ -265,6 +265,197 @@ contract StructuredCtor(Pair seed) { ) } +fn write_covenant_omitted_args_fixture() -> (std::path::PathBuf, std::path::PathBuf) { + write_fixture_files( + "cov_debug_demo.sil", + "cov_debug_demo.test.json", + r#"pragma silverscript ^0.1.0; + +contract CovDebugDemo(int bump) { + int value = 0; + + #[covenant(binding = cov, from = 2, to = 2, mode = verification)] + function step(State[] prev_states, State[] new_states) { + require(new_states[0].value == prev_states[0].value + bump); + require(new_states[1].value == prev_states[1].value); + } + + #[covenant(binding = cov, from = 2, to = 2, mode = verification)] + function step_with_nonce(State[] prev_states, State[] new_states, int nonce) { + require(nonce == bump); + require(new_states[0].value == prev_states[0].value + bump); + require(new_states[1].value == prev_states[1].value); + } +} +"#, + r#"{ + "tests": [ + { + "name": "source_leader_infers_args", + "function": "step", + "constructor_args": [2], + "expect": "pass", + "tx": { + "active_input_index": 0, + "inputs": [ + { + "utxo_value": 1000, + "covenant_id": 1, + "state": { "value": 7 } + }, + { + "utxo_value": 1000, + "covenant_id": 1, + "state": { "value": 7 } + } + ], + "outputs": [ + { + "value": 1000, + "covenant_id": 1, + "state": { "value": 9 } + }, + { + "value": 1000, + "covenant_id": 1, + "state": { "value": 7 } + } + ] + } + }, + { + "name": "source_leader_missing_nonce", + "function": "step_with_nonce", + "constructor_args": [2], + "expect": "pass", + "tx": { + "active_input_index": 0, + "inputs": [ + { + "utxo_value": 1000, + "covenant_id": 1, + "state": { "value": 7 } + }, + { + "utxo_value": 1000, + "covenant_id": 1, + "state": { "value": 7 } + } + ], + "outputs": [ + { + "value": 1000, + "covenant_id": 1, + "state": { "value": 9 } + }, + { + "value": 1000, + "covenant_id": 1, + "state": { "value": 7 } + } + ] + } + }, + { + "name": "source_leader_explicit_empty_args", + "function": "step", + "constructor_args": [2], + "args": [], + "expect": "pass", + "tx": { + "active_input_index": 0, + "inputs": [ + { + "utxo_value": 1000, + "covenant_id": 1, + "state": { "value": 7 } + }, + { + "utxo_value": 1000, + "covenant_id": 1, + "state": { "value": 7 } + } + ], + "outputs": [ + { + "value": 1000, + "covenant_id": 1, + "state": { "value": 9 } + }, + { + "value": 1000, + "covenant_id": 1, + "state": { "value": 7 } + } + ] + } + } + ] +} +"#, + ) +} + +fn write_covenant_local_fixture() -> (std::path::PathBuf, std::path::PathBuf) { + write_fixture_files( + "cov_debug_locals.sil", + "cov_debug_locals.test.json", + r#"pragma silverscript ^0.1.0; + +contract CovDebugDemo(int bump) { + int value = 0; + + #[covenant(binding = cov, from = 2, to = 2, mode = verification)] + function step(State[] prev_states, State[] new_states) { + int a = prev_states[0].value; + int b = a + bump + value; + require(new_states[0].value == prev_states[0].value + bump); + require(new_states[1].value == prev_states[1].value); + require(b == 16); + } +} +"#, + r#"{ + "tests": [ + { + "name": "source_leader_local", + "function": "step", + "constructor_args": [2], + "expect": "pass", + "tx": { + "active_input_index": 0, + "inputs": [ + { + "utxo_value": 1000, + "covenant_id": 1, + "state": { "value": 7 } + }, + { + "utxo_value": 1000, + "covenant_id": 1, + "state": { "value": 7 } + } + ], + "outputs": [ + { + "value": 1000, + "covenant_id": 1, + "state": { "value": 9 } + }, + { + "value": 1000, + "covenant_id": 1, + "state": { "value": 7 } + } + ] + } + } + ] +} +"#, + ) +} + #[test] fn cli_debugger_repl_all_commands_smoke() { let tmp = std::env::temp_dir().join("cli_test_if_statement.sil"); @@ -668,6 +859,139 @@ fn cli_debugger_run_all_supports_structured_constructor_args_from_test_file() { assert!(stdout.contains("1 tests: 1 passed, 0 failed"), "missing summary line: {stdout}"); } +#[test] +fn cli_debugger_run_test_file_infers_covenant_state_args_when_args_omitted() { + let (_script_path, test_file_path) = write_covenant_omitted_args_fixture(); + + let output = Command::new(env!("CARGO_BIN_EXE_cli-debugger")) + .arg("--run") + .arg("--test-file") + .arg(&test_file_path) + .arg("--test-name") + .arg("source_leader_infers_args") + .output() + .expect("run cli-debugger covenant inference test"); + + assert!( + output.status.success(), + "expected success, status={:?}, stderr={}", + output.status.code(), + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("PASS"), "expected PASS in stdout, got: {stdout}"); +} + +#[test] +fn cli_debugger_run_test_file_reports_missing_non_state_covenant_args_when_args_omitted() { + let (_script_path, test_file_path) = write_covenant_omitted_args_fixture(); + + let output = Command::new(env!("CARGO_BIN_EXE_cli-debugger")) + .arg("--run") + .arg("--test-file") + .arg(&test_file_path) + .arg("--test-name") + .arg("source_leader_missing_nonce") + .output() + .expect("run cli-debugger covenant nonce inference test"); + + assert!(!output.status.success(), "expected missing nonce inference to fail"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("cannot infer omitted args for covenant 'step_with_nonce'; provide explicit args for nonce (int)"), + "unexpected stderr: {stderr}" + ); +} + +#[test] +fn cli_debugger_run_test_file_preserves_explicit_empty_args_for_covenant_calls() { + let (_script_path, test_file_path) = write_covenant_omitted_args_fixture(); + + let output = Command::new(env!("CARGO_BIN_EXE_cli-debugger")) + .arg("--run") + .arg("--test-file") + .arg(&test_file_path) + .arg("--test-name") + .arg("source_leader_explicit_empty_args") + .output() + .expect("run cli-debugger explicit empty covenant args test"); + + assert!(!output.status.success(), "expected explicit empty args to remain strict"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("function expects 1 arguments, got 0"), "unexpected stderr: {stderr}"); +} + +#[test] +fn cli_debugger_covenant_vars_render_source_level_args_and_active_state() { + let (script_path, test_file_path) = write_covenant_omitted_args_fixture(); + + let mut child = Command::new(env!("CARGO_BIN_EXE_cli-debugger")) + .arg(&script_path) + .arg("--function") + .arg("step") + .arg("--test-file") + .arg(&test_file_path) + .arg("--test-name") + .arg("source_leader_infers_args") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn cli-debugger"); + + let input = b"vars\nq\n"; + child.stdin.as_mut().expect("stdin available").write_all(input).expect("write stdin"); + + let output = child.wait_with_output().expect("wait for cli-debugger"); + assert!(output.status.success(), "cli-debugger exited with status {:?}", output.status.code()); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.is_empty(), "unexpected stderr: {stderr}"); + assert!(stdout.contains("Contract State:\n value (int) = 7"), "missing active contract state: {stdout}"); + assert!(stdout.contains("prev_states (State[]) = [{value: 7}, {value: 7}]"), "missing prev_states call arg: {stdout}"); + assert!(stdout.contains("new_states (State[]) = [{value: 9}, {value: 7}]"), "missing new_states call arg: {stdout}"); + + let call_args_index = stdout.find("Call Arguments:").expect("missing Call Arguments section"); + let new_states_index = stdout.find("new_states (State[]) = [{value: 9}, {value: 7}]").expect("missing new_states render"); + let locals_index = stdout.find("Locals:"); + assert!(call_args_index < new_states_index, "new_states should appear under Call Arguments: {stdout}"); + assert!(locals_index.is_none_or(|index| new_states_index < index), "new_states should not render as a local: {stdout}"); +} + +#[test] +fn cli_debugger_resolves_covenant_local_from_prev_states_array() { + let (script_path, test_file_path) = write_covenant_local_fixture(); + + let mut child = Command::new(env!("CARGO_BIN_EXE_cli-debugger")) + .arg(&script_path) + .arg("--function") + .arg("step") + .arg("--test-file") + .arg(&test_file_path) + .arg("--test-name") + .arg("source_leader_local") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn cli-debugger"); + + let input = b"n\nvars\ne a\nq\n"; + child.stdin.as_mut().expect("stdin available").write_all(input).expect("write stdin"); + + let output = child.wait_with_output().expect("wait for cli-debugger"); + assert!(output.status.success(), "cli-debugger exited with status {:?}", output.status.code()); + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.is_empty(), "unexpected stderr: {stderr}"); + assert!(stdout.contains("a (int) = 7"), "missing resolved local a: {stdout}"); + assert!(stdout.contains("a = (int) 7"), "missing resolved eval for a: {stdout}"); + assert!(!stdout.contains("a (int) = { #[derive(Clone)] struct CovenantSessionContext { - bindings: Vec, + injected_param_value: Option, + call_target: ResolvedCovenantCallTarget, } impl CovenantSessionContext { fn binding_for_function(&self, function_name: &str) -> Option<&CovenantDeclInfo> { - self.bindings.iter().find(|binding| binding.matches_generated_name(function_name)) + self.call_target.info.matches_generated_name(function_name).then_some(&self.call_target.info) } fn display_name_for_function(&self, function_name: &str) -> Option { - self.binding_for_function(function_name).and_then(|binding| binding.display_name_for_function(function_name)) + if self.call_target.info.policy_function_name() == function_name { + return Some(self.call_target.display_name()); + } + self.call_target.info.display_name_for_function(function_name) } fn hides_name(&self, name: &str) -> bool { @@ -159,9 +163,7 @@ pub struct DebugSession<'a, 'i> { debug_info: DebugInfo<'i>, contract_ast: Option>, covenant_ctx: Option, - covenant_param_value: Option, - active_covenant_policy_name: Option, - active_covenant_display_name: Option, + active_contract_state: Option, step_order: Vec, current_step_index: Option, source_lines: Vec, @@ -262,9 +264,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { debug_info, contract_ast, covenant_ctx: None, - covenant_param_value: None, - active_covenant_policy_name: None, - active_covenant_display_name: None, + active_contract_state: None, step_order, current_step_index: None, source_lines, @@ -293,16 +293,13 @@ impl<'a, 'i> DebugSession<'a, 'i> { self } - pub fn with_covenant_mode( - mut self, - infos: Vec, - param_value: Option, - active_call: Option, - ) -> Self { - self.covenant_ctx = (!infos.is_empty()).then_some(CovenantSessionContext { bindings: infos }); - self.covenant_param_value = param_value; - self.active_covenant_policy_name = active_call.as_ref().map(|call| call.info.lowered.policy_function.clone()); - self.active_covenant_display_name = active_call.as_ref().map(display_name_for_active_covenant_call); + pub fn with_active_contract_state(mut self, active_contract_state: Option) -> Self { + self.active_contract_state = active_contract_state; + self + } + + pub fn with_covenant_mode(mut self, param_value: Option, active_call: ResolvedCovenantCallTarget) -> Self { + self.covenant_ctx = Some(CovenantSessionContext { injected_param_value: param_value, call_target: active_call }); self } @@ -650,19 +647,46 @@ impl<'a, 'i> DebugSession<'a, 'i> { } fn param_origin(&self, name: &str) -> VariableOrigin { + self.binding_origin_for_function(None, name) + } + + fn binding_origin_for_function(&self, function_name: Option<&str>, name: &str) -> VariableOrigin { if self.contract_ast.as_ref().is_some_and(|contract| contract.fields.iter().any(|field| field.name == name)) { - VariableOrigin::ContractField - } else { + return VariableOrigin::ContractField; + } + + if function_name.is_none() { + return VariableOrigin::Param; + } + + let Some(contract) = self.contract_ast.as_ref() else { + return VariableOrigin::Local; + }; + + let source_function_name = function_name.and_then(|function_name| { + contract.functions.iter().find(|function| function.name == function_name).map(|function| function.name.as_str()).or_else( + || { + self.covenant_ctx() + .and_then(|ctx| ctx.binding_for_function(function_name)) + .map(|binding| binding.source_name.as_str()) + }, + ) + }); + + if source_function_name.is_some_and(|function_name| { + contract + .functions + .iter() + .find(|function| function.name == function_name) + .is_some_and(|function| function.params.iter().any(|param| param.name == name)) + }) { VariableOrigin::Param + } else { + VariableOrigin::Local } } fn display_function_name(&self, function_name: &str) -> String { - if self.active_covenant_policy_name.as_deref() == Some(function_name) { - if let Some(name) = &self.active_covenant_display_name { - return name.clone(); - } - } self.covenant_ctx().and_then(|ctx| ctx.display_name_for_function(function_name)).unwrap_or_else(|| function_name.to_string()) } @@ -749,6 +773,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { record_debug_named_values(&mut bindings, &self.debug_info.constructor_args, VariableOrigin::ConstructorArg); record_debug_named_values(&mut bindings, &self.debug_info.constants, VariableOrigin::Constant); + self.inject_contract_state_bindings(&mut bindings); let frozen_inline_names = if scope.context.step_id.frame_id == 0 { HashSet::new() @@ -783,7 +808,7 @@ impl<'a, 'i> DebugSession<'a, 'i> { .or_insert_with(|| ScopeBinding { type_name: update.type_name.clone(), source, - origin: VariableOrigin::Local, + origin: self.binding_origin_for_function(Some(&scope.context.function_name), name), hidden: self.is_hidden_debug_name(name), }); } @@ -796,35 +821,12 @@ impl<'a, 'i> DebugSession<'a, 'i> { let Some(binding_spec) = self.covenant_ctx().and_then(|ctx| ctx.binding_for_function(&scope.context.function_name)) else { return; }; - let state_param_type = match parse_type_ref(&binding_spec.source_binding.param_type_name) { - Ok(type_ref) => type_ref, - Err(_) => { - bindings.insert( - binding_spec.source_binding.param_name.clone(), - ScopeBinding { - type_name: binding_spec.source_binding.param_type_name.clone(), - source: ScopeValueSource::Unavailable { - message: format!( - "failed to parse covenant state parameter type '{}'", - binding_spec.source_binding.param_type_name - ), - }, - origin: VariableOrigin::Param, - hidden: false, - }, - ); - return; - } + let Some((source_param_name, source_param_type)) = binding_spec.source_param() else { + return; }; - let injected = self.covenant_param_value.as_ref().and_then(|value| { - self.inject_debug_value_binding( - bindings, - &binding_spec.source_binding.param_name, - &state_param_type, - value, - VariableOrigin::Param, - ) + let injected = self.covenant_ctx().and_then(|ctx| ctx.injected_param_value.as_ref()).and_then(|value| { + self.inject_debug_value_binding(bindings, source_param_name, source_param_type, value, VariableOrigin::Param) }); if injected.is_some() { return; @@ -835,9 +837,9 @@ impl<'a, 'i> DebugSession<'a, 'i> { CovenantDeclBinding::Cov => "prev_states is unavailable".to_string(), }; bindings.insert( - binding_spec.source_binding.param_name.clone(), + source_param_name.to_string(), ScopeBinding { - type_name: binding_spec.source_binding.param_type_name.clone(), + type_name: source_param_type.type_name(), source: ScopeValueSource::Unavailable { message }, origin: VariableOrigin::Param, hidden: false, @@ -845,6 +847,26 @@ impl<'a, 'i> DebugSession<'a, 'i> { ); } + fn inject_contract_state_bindings(&self, bindings: &mut ScopeState<'i>) { + let Some(contract) = self.contract_ast.as_ref() else { + return; + }; + + let Some(state_value) = self.active_contract_state.as_ref() else { + record_debug_named_values(bindings, &self.debug_info.contract_state, VariableOrigin::ContractField); + return; + }; + + for field in &contract.fields { + let field_path = vec![field.name.clone()]; + let Some(field_value) = structured_leaf_value(state_value, &field_path) else { + continue; + }; + let _ = + self.inject_debug_value_binding(bindings, &field.name, &field.type_ref, &field_value, VariableOrigin::ContractField); + } + } + fn inject_debug_value_binding( &self, bindings: &mut ScopeState<'i>, @@ -1771,19 +1793,6 @@ fn is_synthetic_default_span(span: SourceSpan) -> bool { span.line == 1 && span.col == 1 && span.end_line == 1 && span.end_col == 1 } -fn display_name_for_active_covenant_call(target: &ResolvedCovenantCallTarget) -> String { - match target.info.binding { - CovenantDeclBinding::Auth => target.info.source_name.clone(), - CovenantDeclBinding::Cov => { - if target.is_leader { - format!("{} [leader]", target.info.source_name) - } else { - format!("{} [delegate]", target.info.source_name) - } - } - } -} - fn map_expr_children_for_eval<'i, F>(expr: &'i Expr<'i>, map_child: &mut F) -> Result, String> where F: FnMut(&'i Expr<'i>) -> Result, String>, @@ -2082,6 +2091,7 @@ mod tests { functions: vec![DebugFunctionRange { name: "f".to_string(), bytecode_start: 0, bytecode_end: 1 }], constructor_args: vec![], constants: vec![DebugNamedValue { name: "K".to_string(), type_name: "int".to_string(), value: Expr::int(7) }], + contract_state: vec![], }; DebugSession::full(sigscript, &[], "", Some(debug_info), engine) } @@ -2299,6 +2309,7 @@ mod tests { span::Span::default(), ), }], + contract_state: vec![], }; let session = DebugSession::full(&[], &[], "", Some(debug_info), engine).unwrap(); let scope_state = session.scope_state(StepId::ROOT).unwrap(); @@ -2314,6 +2325,54 @@ mod tests { } } + #[test] + fn active_contract_state_preserves_top_level_struct_fields() { + let sig_cache = Box::leak(Box::new(Cache::new(10_000))); + let reused_values: &'static SigHashReusedValuesUnsync = Box::leak(Box::new(SigHashReusedValuesUnsync::new())); + let engine: DebugEngine<'static> = + TxScriptEngine::new(EngineCtx::new(sig_cache).with_reused(reused_values), EngineFlags { covenants_enabled: true }); + let source = r#" + contract Sample() { + struct Pair { + int left; + int right; + } + + Pair pair = { left: 1, right: 2 }; + + entrypoint function main() { + require(true); + } + } + "#; + let debug_info = DebugInfo { + source: source.to_string(), + steps: vec![], + params: vec![], + functions: vec![DebugFunctionRange { name: "main".to_string(), bytecode_start: 0, bytecode_end: 1 }], + constructor_args: vec![], + constants: vec![], + contract_state: vec![], + }; + let session = DebugSession::full(&[], &[], source, Some(debug_info), engine).unwrap().with_active_contract_state(Some( + DebugValue::Object(vec![( + "pair".to_string(), + DebugValue::Object(vec![("left".to_string(), DebugValue::Int(7)), ("right".to_string(), DebugValue::Int(9))]), + )]), + )); + + let scope_state = session.scope_state(StepId::ROOT).unwrap(); + let vars = session.collect_variables_map(&scope_state); + let pair = vars.get("pair").expect("pair variable"); + match &pair.value { + DebugValue::Object(fields) => { + assert!(matches!(fields[0], (ref name, DebugValue::Int(7)) if name == "left")); + assert!(matches!(fields[1], (ref name, DebugValue::Int(9)) if name == "right")); + } + other => panic!("expected object debug value, got {other:?}"), + } + } + #[test] fn shadow_eval_resolves_nested_inline_synthetic_chain() { let mut sig_builder = ScriptBuilder::new(); diff --git a/debugger/session/src/test_runner.rs b/debugger/session/src/test_runner.rs index 6843de45..276fa8aa 100644 --- a/debugger/session/src/test_runner.rs +++ b/debugger/session/src/test_runner.rs @@ -17,7 +17,7 @@ pub struct ContractTestCase { #[serde(default)] pub constructor_args: Vec, #[serde(default)] - pub args: Vec, + pub args: Option>, pub expect: TestExpectation, #[serde(default)] pub tx: Option, @@ -95,7 +95,7 @@ pub struct ContractTestCaseResolved { pub function: String, pub delegate: bool, pub constructor_args: Vec, - pub args: Vec, + pub args: Option>, pub expect: TestExpectation, pub tx: Option, } @@ -186,7 +186,7 @@ pub fn resolve_contract_test( function: test.function, delegate: test.delegate, constructor_args: values_to_args(&test.constructor_args)?, - args: values_to_args(&test.args)?, + args: test.args.as_ref().map(|values| values_to_args(values)).transpose()?, expect: test.expect, tx: test.tx.map(resolve_tx_scenario).transpose()?, }; diff --git a/debugger/session/tests/debug_session_tests.rs b/debugger/session/tests/debug_session_tests.rs index 6af6d4d5..4b382bdd 100644 --- a/debugger/session/tests/debug_session_tests.rs +++ b/debugger/session/tests/debug_session_tests.rs @@ -1354,11 +1354,8 @@ contract Counter(int init_value) { let shadow_ctx = ShadowTxContext { tx: &populated_tx, input: input_ref, input_index: 0, utxo_entry: utxo_ref }; let mut session = DebugSession::full(&sigscript, &compiled.script, source, debug_info, engine)? .with_shadow_tx_context(shadow_ctx) - .with_covenant_mode( - compiled.covenant_infos.clone(), - Some(DebugValue::Object(vec![("value".to_string(), DebugValue::Int(7))])), - Some(covenant_target), - ); + .with_active_contract_state(Some(DebugValue::Object(vec![("value".to_string(), DebugValue::Int(7))]))) + .with_covenant_mode(Some(DebugValue::Object(vec![("value".to_string(), DebugValue::Int(7))])), covenant_target); session.run_to_first_executed_statement()?; @@ -1371,6 +1368,13 @@ contract Counter(int init_value) { assert_eq!(prev_state.type_name, "State"); assert_eq!(format_value(&prev_state.type_name, &prev_state.value), "{value: 7}"); + let new_states = session.variable_by_name("new_states")?; + assert_eq!(new_states.origin.label(), "arg"); + + let value = session.variable_by_name("value")?; + assert_eq!(value.origin.label(), "state"); + assert_eq!(format_value(&value.type_name, &value.value), "7"); + let (type_name, value) = session.evaluate_expression("prev_state.value")?; assert_eq!(type_name, "int"); assert_eq!(format_value(&type_name, &value), "7"); @@ -1384,3 +1388,100 @@ contract Counter(int init_value) { Err("expected to step into source-level covenant policy frame".into()) } + +#[test] +fn debug_session_resolves_covenant_locals_derived_from_prev_state_arrays() -> Result<(), Box> { + let source = r#"pragma silverscript ^0.1.0; + +contract CovDebugDemo(int bump) { + int value = 0; + + #[covenant(binding = cov, from = 2, to = 2, mode = verification)] + function step(State[] prev_states, State[] new_states) { + int a = prev_states[0].value; + int b = a + bump + value; + require(new_states[0].value == prev_states[0].value + bump); + require(new_states[1].value == prev_states[1].value); + require(b == 16); + } +} +"#; + + let compile_opts = CompileOptions { record_debug_infos: true, ..Default::default() }; + let compiled = compile_contract(source, &[Expr::int(2)], compile_opts)?; + let debug_info = compiled.debug_info.clone(); + let covenant_target = compiled + .resolve_covenant_call_target("step", CovenantDeclCallOptions { is_leader: true }) + .ok_or("missing covenant call target")?; + let sigscript = compiled.build_sig_script_for_covenant_decl( + "step", + vec![vec![struct_object(vec![("value", Expr::int(9))]), struct_object(vec![("value", Expr::int(7))])].into()], + CovenantDeclCallOptions { is_leader: true }, + )?; + + let input0 = TransactionInput { + previous_outpoint: TransactionOutpoint { transaction_id: TransactionId::from_bytes([0x66u8; 32]), index: 0 }, + signature_script: sigscript.clone(), + sequence: 0, + sig_op_count: 0, + }; + let input1 = TransactionInput { + previous_outpoint: TransactionOutpoint { transaction_id: TransactionId::from_bytes([0x77u8; 32]), index: 0 }, + signature_script: vec![OpTrue], + sequence: 0, + sig_op_count: 0, + }; + let output0 = TransactionOutput { value: 1000, script_public_key: ScriptPublicKey::new(0, vec![OpTrue].into()), covenant: None }; + let output1 = TransactionOutput { value: 1000, script_public_key: ScriptPublicKey::new(0, vec![OpTrue].into()), covenant: None }; + let tx = Transaction::new(1, vec![input0, input1], vec![output0, output1], 0, Default::default(), 0, vec![]); + + let covenant_id = Hash::from_bytes([0x44u8; 32]); + let utxo_entry0 = + UtxoEntry::new(1000, ScriptPublicKey::new(0, compiled.script.clone().into()), 0, tx.is_coinbase(), Some(covenant_id)); + let utxo_entry1 = + UtxoEntry::new(1000, ScriptPublicKey::new(0, compiled.script.clone().into()), 0, tx.is_coinbase(), Some(covenant_id)); + let populated_tx = PopulatedTransaction::new(&tx, vec![utxo_entry0, utxo_entry1]); + let cov_ctx = CovenantsContext::from_tx(&populated_tx)?; + + let sig_cache = Cache::new(10_000); + let reused_values = SigHashReusedValuesUnsync::new(); + let ctx = EngineCtx::new(&sig_cache).with_reused(&reused_values).with_covenants_ctx(&cov_ctx); + let input_ref = &tx.inputs[0]; + let utxo_ref = populated_tx.utxo(0).ok_or("missing utxo for input 0")?; + let engine = debugger_session::session::DebugEngine::from_transaction_input( + &populated_tx, + input_ref, + 0, + utxo_ref, + ctx, + EngineFlags { covenants_enabled: true }, + ); + + let shadow_ctx = ShadowTxContext { tx: &populated_tx, input: input_ref, input_index: 0, utxo_entry: utxo_ref }; + let mut session = DebugSession::full(&sigscript, &compiled.script, source, debug_info, engine)? + .with_shadow_tx_context(shadow_ctx) + .with_active_contract_state(Some(DebugValue::Object(vec![("value".to_string(), DebugValue::Int(7))]))) + .with_covenant_mode( + Some(DebugValue::Array(vec![ + DebugValue::Object(vec![("value".to_string(), DebugValue::Int(7))]), + DebugValue::Object(vec![("value".to_string(), DebugValue::Int(7))]), + ])), + covenant_target, + ); + + session.run_to_first_executed_statement()?; + session.step_over()?; + + let a = session.variable_by_name("a")?; + assert_eq!(a.type_name, "int"); + assert_eq!(format_value(&a.type_name, &a.value), "7"); + + let (type_name, value) = session.evaluate_expression("a")?; + assert_eq!(type_name, "int"); + assert_eq!(format_value(&type_name, &value), "7"); + + let (type_name, value) = session.evaluate_expression("prev_states[0].value")?; + assert_eq!(type_name, "int"); + assert_eq!(format_value(&type_name, &value), "7"); + Ok(()) +} diff --git a/silverscript-lang/src/compiler.rs b/silverscript-lang/src/compiler.rs index d96eb460..56fd78de 100644 --- a/silverscript-lang/src/compiler.rs +++ b/silverscript-lang/src/compiler.rs @@ -10,15 +10,12 @@ use crate::ast::{ ParamAst, SplitPart, StateBindingAst, StateFieldExpr, Statement, TimeVar, TypeBase, TypeRef, UnaryOp, UnarySuffixKind, parse_contract_ast, parse_type_ref, }; -use crate::debug_info::{DebugInfo, DebugNamedValue, RuntimeBinding, SourceSpan}; +use crate::debug_info::{DebugInfo, RuntimeBinding, SourceSpan}; pub use crate::errors::{CompilerError, ErrorSpan}; use crate::span; mod covenant_declarations; use covenant_declarations::lower_covenant_declarations; -pub use covenant_declarations::{ - CovenantDeclBinding, CovenantDeclInfo, CovenantDeclMode, CovenantLoweredNames, CovenantSourceBindingInfo, - ResolvedCovenantCallTarget, -}; +pub use covenant_declarations::{CovenantDeclBinding, CovenantDeclInfo, ResolvedCovenantCallTarget}; mod debug_recording; mod debug_value_types; @@ -59,45 +56,6 @@ pub struct CompileOptions { pub record_debug_infos: bool, } -pub fn resolve_contract_state_values<'i>( - contract: &ContractAst<'i>, - constructor_args: &[Expr<'i>], -) -> Result>, CompilerError> { - if contract.params.len() != constructor_args.len() { - return Err(CompilerError::Unsupported("constructor argument count mismatch".to_string())); - } - - let structs = build_struct_registry(contract)?; - let mut env: HashMap> = - contract.constants.iter().map(|constant| (constant.name.clone(), constant.expr.clone())).collect(); - - for (param, value) in contract.params.iter().zip(constructor_args.iter()) { - let param_type_name = type_name_from_ref(¶m.type_ref); - if !expr_matches_declared_type_ref(value, ¶m.type_ref, &structs) { - return Err(CompilerError::Unsupported(format!("constructor argument '{}' expects {}", param.name, param_type_name))); - } - env.insert(param.name.clone(), value.clone()); - } - - let mut resolved_fields = Vec::with_capacity(contract.fields.len()); - for field in &contract.fields { - if env.contains_key(&field.name) { - return Err(CompilerError::Unsupported(format!("duplicate contract field name: {}", field.name))); - } - - let type_name = field.type_ref.type_name(); - let resolved = resolve_expr(field.expr.clone(), &env, &mut HashSet::new())?; - if !expr_matches_declared_type_ref(&resolved, &field.type_ref, &structs) { - return Err(CompilerError::Unsupported(format!("contract field '{}' expects {}", field.name, type_name))); - } - - env.insert(field.name.clone(), resolved.clone()); - resolved_fields.push(DebugNamedValue { name: field.name.clone(), type_name, value: resolved }); - } - - Ok(resolved_fields) -} - #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct FunctionInputAbi { pub name: String, @@ -129,18 +87,121 @@ pub struct CompiledContract<'i> { pub debug_info: Option>, } +pub fn materialize_state_script<'i>(base_compiled: &CompiledContract<'i>, state: &Expr<'i>) -> Result, CompilerError> { + if base_compiled.state_layout.len == 0 { + return Err(CompilerError::Unsupported("contract does not expose a materializable state segment".to_string())); + } + + let debug_info = base_compiled + .debug_info + .as_ref() + .ok_or_else(|| CompilerError::Unsupported("state materialization requires debug-enabled compilation".to_string()))?; + let structs = build_struct_registry(&base_compiled.ast)?; + let mut constants: HashMap> = + debug_info.constants.iter().map(|constant| (constant.name.clone(), constant.value.clone())).collect(); + for param in &debug_info.constructor_args { + constants.insert(param.name.clone(), param.value.clone()); + } + let encoded_state = encode_contract_state_segment(&base_compiled.ast.fields, state, &structs, &constants)?; + if encoded_state.len() != base_compiled.state_layout.len { + return Err(CompilerError::Unsupported( + "explicit state changes encoded script size; provide raw script_hex instead".to_string(), + )); + } + + let state_start = base_compiled.state_layout.start; + let state_end = base_compiled.state_layout.start + base_compiled.state_layout.len; + if base_compiled.script.len() < state_end { + return Err(CompilerError::Unsupported("state template range exceeds compiled script length".to_string())); + } + + let mut script = base_compiled.script.clone(); + script[state_start..state_end].copy_from_slice(&encoded_state); + Ok(script) +} + +fn encode_contract_state_segment<'i>( + contract_fields: &[ContractFieldAst<'i>], + state: &Expr<'i>, + structs: &StructRegistry, + constants: &HashMap>, +) -> Result, CompilerError> { + let mut provided = collect_state_object_entries(state, "State value")?; + if provided.len() != contract_fields.len() { + return Err(CompilerError::Unsupported("State value must include all contract fields exactly once".to_string())); + } + + let mut out = Vec::new(); + for field in contract_fields { + let value = provided + .remove(field.name.as_str()) + .ok_or_else(|| CompilerError::Unsupported(format!("missing state field '{}'", field.name)))?; + let type_name = type_name_from_ref(&field.type_ref); + if !expr_matches_declared_type_ref(value, &field.type_ref, structs) { + return Err(CompilerError::Unsupported(format!("contract field '{}' expects {}", field.name, type_name))); + } + + let encoded = if struct_name_from_type_ref(&field.type_ref, structs).is_some() { + encode_struct_value(value, &field.type_ref, structs)? + } else if fixed_type_size_with_constants_ref(&field.type_ref, constants).is_some() { + encode_fixed_size_value(value, &type_name)? + } else { + match &value.kind { + ExprKind::Array(values) => { + if is_byte_array(value) { + values + .iter() + .filter_map(|entry| if let ExprKind::Byte(byte) = &entry.kind { Some(*byte) } else { None }) + .collect() + } else { + encode_array_literal(values, &type_name)? + } + } + ExprKind::String(string) => string.as_bytes().to_vec(), + ExprKind::Byte(byte) => vec![*byte], + _ => { + return Err(CompilerError::Unsupported(format!("contract field '{}' expects {}", field.name, type_name))); + } + } + }; + + out.extend_from_slice(&data_prefix(encoded.len())); + out.extend(encoded); + } + if let Some(extra) = provided.keys().next() { + return Err(CompilerError::Unsupported(format!("unknown state field '{}'", extra))); + } + Ok(out) +} + +fn collect_state_object_entries<'a, 'i>( + state_expr: &'a Expr<'i>, + object_name: &str, +) -> Result>, CompilerError> { + let ExprKind::StateObject(entries) = &state_expr.kind else { + return Err(CompilerError::Unsupported(format!("{object_name} must be an object literal"))); + }; + + let mut provided = HashMap::new(); + for entry in entries { + if provided.insert(entry.name.as_str(), &entry.expr).is_some() { + return Err(CompilerError::Unsupported(format!("duplicate state field '{}'", entry.name))); + } + } + Ok(provided) +} #[derive(Clone, Default)] struct LoweringScope { vars: HashMap, } -#[derive(Clone)] +#[derive(Debug, Clone)] struct StructFieldSpec { name: String, type_ref: TypeRef, } -#[derive(Clone)] +#[derive(Debug, Clone)] struct StructSpec { fields: Vec, } @@ -996,11 +1057,11 @@ fn compile_contract_impl<'i>( let mut script_size = if uses_script_size { Some(100i64) } else { None }; for _ in 0..32 { - let (_contract_fields, field_prolog_script) = + let (contract_fields, field_prolog_script) = compile_contract_fields(&lowered_contract.fields, &constants, options, script_size, &structs)?; let mut recorder = DebugRecorder::new(options.record_debug_infos); - recorder.record_contract_scope(&contract.params, constructor_args, &contract.constants); + recorder.record_contract_scope(&contract.params, constructor_args, &contract.constants, &lowered_contract, &contract_fields); let selector_prefix_len = if without_selector { 0 } else { 1 }; let contract_field_prefix_len = selector_prefix_len + field_prolog_script.len(); let state_layout = CompiledStateLayout { start: selector_prefix_len, len: field_prolog_script.len() }; @@ -1830,14 +1891,10 @@ fn array_size_ref(type_ref: &TypeRef) -> Option { fn array_size_with_constants_ref<'i>(type_ref: &TypeRef, constants: &HashMap>) -> Option { match type_ref.array_size()? { ArrayDim::Fixed(size) => Some(*size), - ArrayDim::Constant(name) => { - if let Some(Expr { kind: ExprKind::Int(value), .. }) = constants.get(name) { - if *value >= 0 { - return Some(*value as usize); - } - } - None - } + ArrayDim::Constant(name) => constants + .get(name) + .and_then(|value| eval_const_int(value, constants).ok()) + .and_then(|value| (value >= 0).then_some(value as usize)), ArrayDim::Dynamic | ArrayDim::Inferred => None, } } @@ -2266,10 +2323,11 @@ impl<'i> CompiledContract<'i> { function_name: &str, options: CovenantDeclCallOptions, ) -> Option { - self.covenant_infos.iter().find(|info| info.source_name == function_name).cloned().and_then(|info| { - let generated_entrypoint_name = info.generated_entrypoint_name(options.is_leader)?.to_string(); - Some(ResolvedCovenantCallTarget { generated_entrypoint_name, info, is_leader: options.is_leader }) - }) + self.covenant_infos + .iter() + .find(|info| info.source_name == function_name) + .cloned() + .map(|info| ResolvedCovenantCallTarget { info, is_leader: options.is_leader }) } pub fn build_sig_script(&self, function_name: &str, args: Vec>) -> Result, CompilerError> { @@ -2311,7 +2369,8 @@ impl<'i> CompiledContract<'i> { let target = self .resolve_covenant_call_target(function_name, options) .ok_or_else(|| CompilerError::Unsupported(format!("covenant declaration '{}' not found", function_name)))?; - self.build_sig_script(&target.generated_entrypoint_name, args) + let generated_entrypoint_name = target.generated_entrypoint_name(); + self.build_sig_script(&generated_entrypoint_name, args) } } @@ -4484,16 +4543,7 @@ fn compile_encoded_object_with_layout( contract_constants: &HashMap>, builtin_name: &str, ) -> Result { - let ExprKind::StateObject(state_entries) = &state_expr.kind else { - return Err(CompilerError::Unsupported(format!("{builtin_name} second argument must be an object literal"))); - }; - - let mut provided = HashMap::new(); - for entry in state_entries { - if provided.insert(entry.name.as_str(), &entry.expr).is_some() { - return Err(CompilerError::Unsupported(format!("duplicate state field '{}'", entry.name))); - } - } + let mut provided = collect_state_object_entries(state_expr, &format!("{builtin_name} second argument"))?; if provided.len() != layout_fields.len() { return Err(CompilerError::Unsupported("new_state must include all contract fields exactly once".to_string())); } @@ -7619,14 +7669,6 @@ pub fn compile_debug_expr<'i>( Ok((builder.drain(), type_name)) } -pub(super) fn resolve_expr_for_debug<'i>( - expr: Expr<'i>, - env: &HashMap>, - visiting: &mut HashSet, -) -> Result, CompilerError> { - resolve_expr(expr, env, visiting) -} - #[cfg(test)] mod tests { use std::collections::HashMap; diff --git a/silverscript-lang/src/compiler/covenant_declarations.rs b/silverscript-lang/src/compiler/covenant_declarations.rs index 0c27ef44..5be44643 100644 --- a/silverscript-lang/src/compiler/covenant_declarations.rs +++ b/silverscript-lang/src/compiler/covenant_declarations.rs @@ -7,85 +7,110 @@ pub enum CovenantDeclBinding { Cov, } -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -pub enum CovenantDeclMode { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CovenantDeclMode { Verification, Transition, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CovenantLoweredNames { - pub policy_function: String, - pub auth_entrypoint: Option, - pub leader_entrypoint: Option, - pub delegate_entrypoint: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct CovenantSourceBindingInfo { - pub param_name: String, - pub param_type_name: String, +struct CovenantSourceParam { + name: String, + type_ref: TypeRef, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct CovenantDeclInfo { pub source_name: String, pub binding: CovenantDeclBinding, - pub mode: CovenantDeclMode, - pub lowered: CovenantLoweredNames, - pub source_binding: CovenantSourceBindingInfo, + #[serde(default, skip_serializing_if = "Option::is_none")] + source_param: Option, } impl CovenantDeclInfo { - pub fn generated_function_names(&self) -> impl Iterator { - [ - Some(self.lowered.policy_function.as_str()), - self.lowered.auth_entrypoint.as_deref(), - self.lowered.leader_entrypoint.as_deref(), - self.lowered.delegate_entrypoint.as_deref(), - ] - .into_iter() - .flatten() + pub fn policy_function_name(&self) -> String { + generated_covenant_policy_name(&self.source_name) } - pub fn matches_generated_name(&self, function_name: &str) -> bool { - self.generated_function_names().any(|name| name == function_name) - } - - pub fn generated_entrypoint_name(&self, is_leader: bool) -> Option<&str> { + pub fn generated_entrypoint_name(&self, is_leader: bool) -> String { match self.binding { - CovenantDeclBinding::Auth => self.lowered.auth_entrypoint.as_deref(), + CovenantDeclBinding::Auth => generated_covenant_entrypoint_name(&self.source_name), CovenantDeclBinding::Cov => { if is_leader { - self.lowered.leader_entrypoint.as_deref() + generated_covenant_leader_entrypoint_name(&self.source_name) } else { - self.lowered.delegate_entrypoint.as_deref() + generated_covenant_delegate_entrypoint_name(&self.source_name) } } } } + pub fn matches_generated_name(&self, function_name: &str) -> bool { + if self.policy_function_name() == function_name { + return true; + } + match self.binding { + CovenantDeclBinding::Auth => self.generated_entrypoint_name(true) == function_name, + CovenantDeclBinding::Cov => { + self.generated_entrypoint_name(true) == function_name || self.generated_entrypoint_name(false) == function_name + } + } + } + pub fn display_name_for_function(&self, function_name: &str) -> Option { - if self.lowered.policy_function == function_name || self.lowered.auth_entrypoint.as_deref() == Some(function_name) { + if self.policy_function_name() == function_name { return Some(self.source_name.clone()); } - if self.lowered.leader_entrypoint.as_deref() == Some(function_name) { - return Some(format!("{} [leader]", self.source_name)); - } - if self.lowered.delegate_entrypoint.as_deref() == Some(function_name) { - return Some(format!("{} [delegate]", self.source_name)); + match self.binding { + CovenantDeclBinding::Auth => { + if self.generated_entrypoint_name(true) == function_name { + Some(self.source_name.clone()) + } else { + None + } + } + CovenantDeclBinding::Cov => { + if self.generated_entrypoint_name(true) == function_name { + Some(format!("{} [leader]", self.source_name)) + } else if self.generated_entrypoint_name(false) == function_name { + Some(format!("{} [delegate]", self.source_name)) + } else { + None + } + } } - None + } + + pub fn source_param(&self) -> Option<(&str, &TypeRef)> { + self.source_param.as_ref().map(|param| (param.name.as_str(), ¶m.type_ref)) } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct ResolvedCovenantCallTarget { pub info: CovenantDeclInfo, - pub generated_entrypoint_name: String, pub is_leader: bool, } +impl ResolvedCovenantCallTarget { + pub fn generated_entrypoint_name(&self) -> String { + self.info.generated_entrypoint_name(self.is_leader) + } + + pub fn display_name(&self) -> String { + match self.info.binding { + CovenantDeclBinding::Auth => self.info.source_name.clone(), + CovenantDeclBinding::Cov => { + if self.is_leader { + format!("{} [leader]", self.info.source_name) + } else { + format!("{} [delegate]", self.info.source_name) + } + } + } + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CovenantGroups { Single, @@ -135,7 +160,7 @@ pub(super) fn lower_covenant_declarations<'i>( let declaration = parse_covenant_declaration(function, constants, true)?; validate_covenant_policy_state_shape(function, &declaration, &contract.fields)?; - infos.push(build_covenant_decl_info(function, declaration.binding, declaration.mode)); + infos.push(build_covenant_decl_info(function, declaration.binding)); let policy_name = generated_covenant_policy_name(&function.name); @@ -174,26 +199,10 @@ pub(super) fn lower_covenant_declarations<'i>( Ok((lowered_contract, infos)) } -fn build_covenant_decl_info<'i>(function: &FunctionAst<'i>, binding: CovenantDeclBinding, mode: CovenantDeclMode) -> CovenantDeclInfo { - let source_binding = function - .params - .first() - .map(|param| CovenantSourceBindingInfo { param_name: param.name.clone(), param_type_name: param.type_ref.type_name() }) - .unwrap_or_else(|| CovenantSourceBindingInfo { param_name: String::new(), param_type_name: String::new() }); - CovenantDeclInfo { - source_name: function.name.clone(), - binding, - mode, - lowered: CovenantLoweredNames { - policy_function: generated_covenant_policy_name(&function.name), - auth_entrypoint: (binding == CovenantDeclBinding::Auth).then(|| generated_covenant_entrypoint_name(&function.name)), - leader_entrypoint: (binding == CovenantDeclBinding::Cov) - .then(|| generated_covenant_leader_entrypoint_name(&function.name)), - delegate_entrypoint: (binding == CovenantDeclBinding::Cov) - .then(|| generated_covenant_delegate_entrypoint_name(&function.name)), - }, - source_binding, - } +fn build_covenant_decl_info<'i>(function: &FunctionAst<'i>, binding: CovenantDeclBinding) -> CovenantDeclInfo { + let source_param = + function.params.first().map(|param| CovenantSourceParam { name: param.name.clone(), type_ref: param.type_ref.clone() }); + CovenantDeclInfo { source_name: function.name.clone(), binding, source_param } } fn parse_covenant_declaration<'i>( diff --git a/silverscript-lang/src/compiler/debug_recording.rs b/silverscript-lang/src/compiler/debug_recording.rs index 6d948051..d5622e08 100644 --- a/silverscript-lang/src/compiler/debug_recording.rs +++ b/silverscript-lang/src/compiler/debug_recording.rs @@ -1,13 +1,13 @@ use std::collections::{HashMap, HashSet}; use std::fmt; -use crate::ast::{ConstantAst, ContractFieldAst, Expr, ExprKind, FunctionAst, ParamAst, Statement, parse_type_ref}; +use crate::ast::{ConstantAst, ContractAst, ContractFieldAst, Expr, ExprKind, FunctionAst, ParamAst, Statement, parse_type_ref}; use crate::debug_info::{ DebugFunctionRange, DebugInfo, DebugInfoRecorder, DebugLeafBinding, DebugNamedValue, DebugParamBinding, DebugParamMapping, DebugStep, DebugVariableUpdate, RuntimeBinding, SourceSpan, StepKind, }; -use super::{CompilerError, StackBindings, resolve_expr_for_debug}; +use super::{CompilerError, StackBindings}; /// Contract-level debug recorder used by the compiler. /// @@ -30,8 +30,15 @@ impl<'i> DebugRecorder<'i> { } /// Records contract-scoped debugger bindings (constructor args and constant declarations). - pub fn record_contract_scope(&mut self, params: &[ParamAst<'i>], values: &[Expr<'i>], constants: &[ConstantAst<'i>]) { - self.inner.record_contract_scope(params, values, constants); + pub fn record_contract_scope( + &mut self, + params: &[ParamAst<'i>], + values: &[Expr<'i>], + constants: &[ConstantAst<'i>], + contract: &ContractAst<'i>, + field_values: &HashMap>, + ) { + self.inner.record_contract_scope(params, values, constants, contract, field_values); } /// Starts staging debug metadata for one entrypoint compilation. @@ -112,7 +119,14 @@ impl<'i> DebugRecorder<'i> { } trait DebugRecorderImpl<'i>: fmt::Debug { - fn record_contract_scope(&mut self, params: &[ParamAst<'i>], values: &[Expr<'i>], constants: &[ConstantAst<'i>]); + fn record_contract_scope( + &mut self, + params: &[ParamAst<'i>], + values: &[Expr<'i>], + constants: &[ConstantAst<'i>], + contract: &ContractAst<'i>, + field_values: &HashMap>, + ); fn begin_entrypoint( &mut self, name: &str, @@ -159,7 +173,15 @@ trait DebugRecorderImpl<'i>: fmt::Debug { struct NoopDebugRecorder; impl<'i> DebugRecorderImpl<'i> for NoopDebugRecorder { - fn record_contract_scope(&mut self, _params: &[ParamAst<'i>], _values: &[Expr<'i>], _constants: &[ConstantAst<'i>]) {} + fn record_contract_scope( + &mut self, + _params: &[ParamAst<'i>], + _values: &[Expr<'i>], + _constants: &[ConstantAst<'i>], + _contract: &ContractAst<'i>, + _field_values: &HashMap>, + ) { + } fn begin_entrypoint( &mut self, _name: &str, @@ -229,8 +251,29 @@ impl<'i> ActiveDebugRecorder<'i> { } } +fn debug_named_contract_state<'i>(contract: &ContractAst<'i>, field_values: &HashMap>) -> Vec> { + contract + .fields + .iter() + .filter_map(|field| { + field_values.get(&field.name).cloned().map(|value| DebugNamedValue { + name: field.name.clone(), + type_name: field.type_ref.type_name(), + value, + }) + }) + .collect() +} + impl<'i> DebugRecorderImpl<'i> for ActiveDebugRecorder<'i> { - fn record_contract_scope(&mut self, params: &[ParamAst<'i>], values: &[Expr<'i>], constants: &[ConstantAst<'i>]) { + fn record_contract_scope( + &mut self, + params: &[ParamAst<'i>], + values: &[Expr<'i>], + constants: &[ConstantAst<'i>], + contract: &ContractAst<'i>, + field_values: &HashMap>, + ) { for (param, value) in params.iter().zip(values.iter()) { self.recorder.record_constructor_arg(DebugNamedValue { name: param.name.clone(), @@ -245,6 +288,7 @@ impl<'i> DebugRecorderImpl<'i> for ActiveDebugRecorder<'i> { value: constant.expr.clone(), }); } + self.recorder.record_contract_state(debug_named_contract_state(contract, field_values)); } fn begin_entrypoint( @@ -307,7 +351,7 @@ impl<'i> DebugRecorderImpl<'i> for ActiveDebugRecorder<'i> { }; let updates = collect_variable_updates(&frame.env_before, &frame.stack_bindings_before, env, types, stack_bindings, structs)?; - let console_args = collect_console_args(stmt, env)?; + let console_args = collect_console_args(stmt, env, types)?; let span = SourceSpan::from(stmt.span()); let bytecode_len = bytecode_end.saturating_sub(frame.start); let step_index = entrypoint.push_step(frame.start, frame.start + bytecode_len, span, StepKind::Source {}); @@ -351,6 +395,7 @@ impl<'i> DebugRecorderImpl<'i> for ActiveDebugRecorder<'i> { let has_structured_binding = structured_leaf_bindings.is_some(); resolve_variable_update( env, + types, &mut updates, ¶m.name, ¶m.type_ref.type_name(), @@ -359,7 +404,7 @@ impl<'i> DebugRecorderImpl<'i> for ActiveDebugRecorder<'i> { structured_leaf_bindings, )?; if has_structured_binding { - collect_inline_struct_leaf_updates(env, &mut updates, param, &expr, stack_bindings, structs)?; + collect_inline_struct_leaf_updates(env, types, &mut updates, param, &expr, stack_bindings, structs)?; } } @@ -664,7 +709,7 @@ fn collect_variable_updates<'i>( continue; } - resolve_variable_update(after_env, &mut updates, &name, type_name, after_expr, after_runtime_binding, None)?; + resolve_variable_update(after_env, types, &mut updates, &name, type_name, after_expr, after_runtime_binding, None)?; } for (name, type_name) in types { @@ -690,6 +735,7 @@ fn collect_variable_updates<'i>( resolve_variable_update( after_env, + types, &mut updates, name, type_name, @@ -704,6 +750,7 @@ fn collect_variable_updates<'i>( fn resolve_variable_update<'i>( env: &HashMap>, + types: &HashMap, updates: &mut Vec>, name: &str, type_name: &str, @@ -711,7 +758,7 @@ fn resolve_variable_update<'i>( runtime_binding: Option, structured_leaf_bindings: Option>, ) -> Result<(), CompilerError> { - let resolved = resolve_expr_for_debug(expr, env, &mut HashSet::new())?; + let resolved = super::resolve_expr_for_runtime(expr, env, types, &mut HashSet::new())?; updates.push(DebugVariableUpdate { name: name.to_string(), type_name: type_name.to_string(), @@ -722,12 +769,16 @@ fn resolve_variable_update<'i>( Ok(()) } -fn collect_console_args<'i>(stmt: &Statement<'i>, env: &HashMap>) -> Result>, CompilerError> { +fn collect_console_args<'i>( + stmt: &Statement<'i>, + env: &HashMap>, + types: &HashMap, +) -> Result>, CompilerError> { let Statement::Console { args, .. } = stmt else { return Ok(Vec::new()); }; - args.iter().cloned().map(|expr| resolve_expr_for_debug(expr, env, &mut HashSet::new())).collect() + args.iter().cloned().map(|expr| super::resolve_expr_for_runtime(expr, env, types, &mut HashSet::new())).collect() } fn static_binding_for_stack_name(name: &str, stack_bindings: &HashMap) -> Option { @@ -771,13 +822,14 @@ fn collect_inline_runtime_updates<'i>( let expr = env.get(&name).cloned().unwrap_or_else(|| Expr::identifier(name.clone())); let runtime_binding = runtime_binding_for_stack_name(&name, stack_bindings) .or_else(|| runtime_binding_for_inline_binding(&expr, stack_bindings)); - resolve_variable_update(env, &mut updates, &name, type_name, expr, runtime_binding, None)?; + resolve_variable_update(env, types, &mut updates, &name, type_name, expr, runtime_binding, None)?; } Ok(updates) } fn collect_inline_struct_leaf_updates<'i>( env: &HashMap>, + types: &HashMap, updates: &mut Vec>, param: &ParamAst<'i>, param_expr: &Expr<'i>, @@ -794,6 +846,7 @@ fn collect_inline_struct_leaf_updates<'i>( let runtime_binding = runtime_binding_for_stack_name(&source_leaf_name, stack_bindings); resolve_variable_update( env, + types, updates, &target_leaf_name, &super::type_name_from_ref(&field_type), @@ -832,7 +885,7 @@ mod tests { let structs = super::super::build_struct_registry(&contract).expect("build struct registry"); let mut recorder = DebugRecorder::new(false); - recorder.record_contract_scope(&contract.params, &[], &contract.constants); + recorder.record_contract_scope(&contract.params, &[], &contract.constants, &contract, &HashMap::new()); recorder.begin_entrypoint("spend", function, &contract.fields, &structs).expect("noop begin entrypoint"); let span = SourceSpan::from(stmt.span()); diff --git a/silverscript-lang/src/debug_info.rs b/silverscript-lang/src/debug_info.rs index 3e1a12b0..db8f88f5 100644 --- a/silverscript-lang/src/debug_info.rs +++ b/silverscript-lang/src/debug_info.rs @@ -29,6 +29,7 @@ pub struct DebugInfoRecorder<'i> { entry_points: Vec, constructor_args: Vec>, constants: Vec>, + contract_state: Vec>, next_sequence: u32, } @@ -56,6 +57,10 @@ impl<'i> DebugInfoRecorder<'i> { self.constants.push(binding); } + pub fn record_contract_state(&mut self, bindings: Vec>) { + self.contract_state = bindings; + } + /// Returns the next global sequence id for one emitted debug event. pub fn next_sequence(&mut self) -> u32 { let sequence = self.next_sequence; @@ -81,6 +86,7 @@ impl<'i> DebugInfoRecorder<'i> { functions: self.entry_points, constructor_args: self.constructor_args, constants: self.constants, + contract_state: self.contract_state, } } } @@ -99,6 +105,8 @@ pub struct DebugInfo<'i> { pub constructor_args: Vec>, #[serde(default)] pub constants: Vec>, + #[serde(default)] + pub contract_state: Vec>, } impl<'i> DebugInfo<'i> { @@ -110,6 +118,7 @@ impl<'i> DebugInfo<'i> { functions: Vec::new(), constructor_args: Vec::new(), constants: Vec::new(), + contract_state: Vec::new(), } } } diff --git a/silverscript-lang/tests/compiler_tests.rs b/silverscript-lang/tests/compiler_tests.rs index d2db2cbb..e3a95d08 100644 --- a/silverscript-lang/tests/compiler_tests.rs +++ b/silverscript-lang/tests/compiler_tests.rs @@ -18,7 +18,7 @@ use kaspa_txscript::{ use silverscript_lang::ast::{Expr, ExprKind, format_contract_ast, parse_contract_ast}; use silverscript_lang::compiler::{ CompileOptions, CompiledContract, CovenantDeclCallOptions, FunctionAbiEntry, FunctionInputAbi, compile_contract, - compile_contract_ast, function_branch_index, struct_object, + compile_contract_ast, function_branch_index, materialize_state_script, struct_object, }; use silverscript_lang::debug_info::{DebugParamBinding, RuntimeBinding, StepKind}; @@ -4431,6 +4431,61 @@ fn compiled_template_parts_and_hash(compiled: &CompiledContract) -> (Vec, Ve (prefix, suffix, template_hash) } +#[test] +fn materialize_state_script_replaces_only_state_segment() { + let source = r#" + contract A(int initX, byte[2] initY) { + int x = initX; + byte[2] y = initY; + + entrypoint function main() { + require(true); + } + } + "#; + + let compile_opts = CompileOptions { record_debug_infos: true, ..Default::default() }; + let compiled = compile_contract(source, &[5.into(), vec![1u8, 2u8].into()], compile_opts).expect("compile succeeds"); + let state = struct_object(vec![("x", Expr::int(6)), ("y", vec![0x34u8, 0x12u8].into())]); + + let materialized_script = materialize_state_script(&compiled, &state).expect("materialize succeeds"); + + let mut contract = parse_contract_ast(source).expect("parse succeeds"); + let ExprKind::StateObject(entries) = &state.kind else { + panic!("expected state object"); + }; + for field in &mut contract.fields { + field.expr = + entries.iter().find(|entry| entry.name == field.name).map(|entry| entry.expr.clone()).expect("state field exists"); + } + let fully_materialized = + compile_contract_ast(&contract, &[5.into(), vec![1u8, 2u8].into()], CompileOptions::default()).expect("compile succeeds"); + + let layout = compiled.state_layout; + assert_eq!(materialized_script, fully_materialized.script); + assert_eq!(compiled.script[..layout.start], materialized_script[..layout.start]); + assert_eq!(compiled.script[layout.start + layout.len..], materialized_script[layout.start + layout.len..]); +} + +#[test] +fn materialize_state_script_requires_debug_info() { + let source = r#" + contract A(int initX, byte[2] initY) { + int x = initX; + byte[2] y = initY; + + entrypoint function main() { + require(true); + } + } + "#; + + let compiled = compile_contract(source, &[5.into(), vec![1u8, 2u8].into()], CompileOptions::default()).expect("compile succeeds"); + let state = struct_object(vec![("x", Expr::int(6)), ("y", vec![0x34u8, 0x12u8].into())]); + let err = materialize_state_script(&compiled, &state).expect_err("materialize should require debug info"); + assert!(err.to_string().contains("debug-enabled compilation")); +} + fn run_read_input_state_with_template_case( reader_source: &str, reader_constructor_args: &[Expr<'static>], From 16dca6ce864c28845b7989ee23ab72da15f73fec Mon Sep 17 00:00:00 2001 From: Ori Newman Date: Sun, 29 Mar 2026 16:58:06 +0300 Subject: [PATCH 05/11] Remove reudndant uses of bin2num (#87) * Remove OpNum2bin when accessing an element inside [] * Remove bin2num from read_input_state * Cast the return value of read_input_state_field_expr_symbolic * Add comment * fmt --- Cargo.lock | 176 +++++------ silverscript-lang/src/compiler.rs | 56 ++-- silverscript-lang/tests/chess_apps_tests.rs | 60 ++-- silverscript-lang/tests/compiler_tests.rs | 330 +++++++++++++++++++- 4 files changed, 468 insertions(+), 154 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 80194c56..cf1d0787 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,9 +47,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -62,15 +62,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -103,9 +103,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "arc-swap" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f3647c145568cec02c42054e07bdf9a5a698e15b466fb2341bfc393cd24aa5" +checksum = "a07d1f37ff60921c83bdfc7407723bdefe89b44b98a9b772f225c8f9d67141a6" dependencies = [ "rustversion", ] @@ -586,19 +586,20 @@ dependencies = [ [[package]] name = "borsh" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a" dependencies = [ "borsh-derive", + "bytes", "cfg_aliases", ] [[package]] name = "borsh-derive" -version = "1.6.0" +version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" +checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59" dependencies = [ "once_cell", "proc-macro-crate", @@ -688,9 +689,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.56" +version = "1.2.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1" dependencies = [ "find-msvc-tools", "shlex", @@ -734,9 +735,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", @@ -744,9 +745,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -756,9 +757,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ "heck", "proc-macro2", @@ -768,9 +769,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "cli-debugger" @@ -795,9 +796,9 @@ dependencies = [ [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "concurrent-queue" @@ -1616,15 +1617,15 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.91" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" dependencies = [ "once_cell", "wasm-bindgen", @@ -1633,13 +1634,12 @@ dependencies = [ [[package]] name = "kaspa-addresses" version = "1.1.0" -source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#2833bd65e334e035c84d7b3d2402f6c760635403" dependencies = [ "borsh", "js-sys", "serde", "smallvec", - "thiserror 1.0.69", + "thiserror 2.0.18", "wasm-bindgen", "workflow-log", "workflow-wasm", @@ -1648,7 +1648,6 @@ dependencies = [ [[package]] name = "kaspa-consensus-core" version = "1.1.0" -source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#2833bd65e334e035c84d7b3d2402f6c760635403" dependencies = [ "arc-swap", "async-trait", @@ -1685,7 +1684,6 @@ dependencies = [ [[package]] name = "kaspa-core" version = "1.1.0" -source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#2833bd65e334e035c84d7b3d2402f6c760635403" dependencies = [ "anyhow", "cfg-if", @@ -1705,7 +1703,6 @@ dependencies = [ [[package]] name = "kaspa-hashes" version = "1.1.0" -source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#2833bd65e334e035c84d7b3d2402f6c760635403" dependencies = [ "blake2b_simd", "blake3", @@ -1725,7 +1722,6 @@ dependencies = [ [[package]] name = "kaspa-math" version = "1.1.0" -source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#2833bd65e334e035c84d7b3d2402f6c760635403" dependencies = [ "borsh", "faster-hex 0.9.0", @@ -1745,7 +1741,6 @@ dependencies = [ [[package]] name = "kaspa-merkle" version = "1.1.0" -source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#2833bd65e334e035c84d7b3d2402f6c760635403" dependencies = [ "kaspa-hashes", ] @@ -1753,7 +1748,6 @@ dependencies = [ [[package]] name = "kaspa-muhash" version = "1.1.0" -source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#2833bd65e334e035c84d7b3d2402f6c760635403" dependencies = [ "kaspa-hashes", "kaspa-math", @@ -1764,7 +1758,6 @@ dependencies = [ [[package]] name = "kaspa-txscript" version = "1.1.0" -source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#2833bd65e334e035c84d7b3d2402f6c760635403" dependencies = [ "ark-bn254", "ark-ec", @@ -1810,7 +1803,6 @@ dependencies = [ [[package]] name = "kaspa-txscript-errors" version = "1.1.0" -source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#2833bd65e334e035c84d7b3d2402f6c760635403" dependencies = [ "borsh", "kaspa-hashes", @@ -1821,7 +1813,6 @@ dependencies = [ [[package]] name = "kaspa-utils" version = "1.1.0" -source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#2833bd65e334e035c84d7b3d2402f6c760635403" dependencies = [ "arc-swap", "async-channel 2.5.0", @@ -1844,14 +1835,13 @@ dependencies = [ "sysinfo", "thiserror 1.0.69", "triggered", - "uuid 1.22.0", + "uuid 1.23.0", "wasm-bindgen", ] [[package]] name = "kaspa-wasm-core" version = "1.1.0" -source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#2833bd65e334e035c84d7b3d2402f6c760635403" dependencies = [ "faster-hex 0.9.0", "hexplay", @@ -1907,9 +1897,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +checksum = "7ddbf48fd451246b1f8c2610bd3b4ac0cc6e149d89832867093ab69a17194f08" dependencies = [ "libc", ] @@ -2141,9 +2131,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" [[package]] name = "num-integer" @@ -2176,9 +2166,9 @@ dependencies = [ [[package]] name = "num_enum" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" dependencies = [ "num_enum_derive", "rustversion", @@ -2186,9 +2176,9 @@ dependencies = [ [[package]] name = "num_enum_derive" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ "proc-macro2", "quote", @@ -2230,9 +2220,9 @@ checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -2480,9 +2470,9 @@ dependencies = [ [[package]] name = "proptest" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bitflags 2.11.0", "num-traits", @@ -3032,9 +3022,9 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" [[package]] name = "slab" @@ -3215,9 +3205,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" dependencies = [ "tinyvec_macros", ] @@ -3252,18 +3242,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.0.0+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" +checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.4+spec-1.1.0" +version = "0.25.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" +checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" dependencies = [ "indexmap", "toml_datetime", @@ -3273,9 +3263,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.9+spec-1.1.0" +version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" +checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" dependencies = [ "winnow", ] @@ -3363,9 +3353,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" [[package]] name = "unicode-width" @@ -3411,9 +3401,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "5ac8b6f42ead25368cf5b098aeb3dc8a1a2c05a3eee8a9a1a68c640edbfc79d9" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -3481,9 +3471,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.114" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ "cfg-if", "once_cell", @@ -3491,17 +3481,29 @@ dependencies = [ "serde", "serde_json", "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" +dependencies = [ + "bumpalo", + "log", + "proc-macro2", + "quote", + "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.64" +version = "0.4.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" dependencies = [ "cfg-if", - "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -3510,9 +3512,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.114" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -3520,22 +3522,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.114" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" dependencies = [ - "bumpalo", "proc-macro2", "quote", "syn 2.0.117", + "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.114" +version = "0.2.100" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" dependencies = [ "unicode-ident", ] @@ -3576,9 +3578,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.91" +version = "0.3.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" dependencies = [ "js-sys", "wasm-bindgen", @@ -3951,9 +3953,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.15" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" dependencies = [ "memchr", ] @@ -4184,18 +4186,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.42" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" +checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.42" +version = "0.8.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" dependencies = [ "proc-macro2", "quote", diff --git a/silverscript-lang/src/compiler.rs b/silverscript-lang/src/compiler.rs index b5038f18..3c412883 100644 --- a/silverscript-lang/src/compiler.rs +++ b/silverscript-lang/src/compiler.rs @@ -486,7 +486,7 @@ fn read_input_state_field_expr_symbolic<'i>( ) -> Result, CompilerError> { let state_start_offset = state_start_offset(contract_field_prefix_len, contract_fields, contract_constants)?; let script_size_expr = Expr::new(ExprKind::Nullary(NullaryOp::ThisScriptSize), span::Span::default()); - let (field_payload_len, decode_numeric) = fixed_state_field_payload_len(field, contract_constants)?; + let field_payload_len = fixed_state_field_payload_len(field, contract_constants)?; let field_payload_offset = state_start_offset + field_chunk_offset + data_prefix(field_payload_len).len(); let sig_len = Expr::call("OpTxInputScriptSigLen", vec![input_idx.clone()]); @@ -507,7 +507,7 @@ fn read_input_state_field_expr_symbolic<'i>( ); let substr = Expr::call("OpTxInputScriptSigSubstr", vec![input_idx.clone(), start, end]); - if decode_numeric { Ok(Expr::call("OpBin2Num", vec![substr])) } else { Ok(substr) } + cast_read_input_state_expr(substr, &field.type_ref) } fn read_input_state_with_template_values<'i>( @@ -1834,18 +1834,16 @@ fn fixed_type_size_with_constants_ref<'i>(type_ref: &TypeRef, constants: &HashMa fn fixed_state_field_payload_len_for_type_ref<'i>( type_ref: &TypeRef, contract_constants: &HashMap>, -) -> Result<(usize, bool), CompilerError> { - let payload_len = fixed_type_size_with_constants_ref(type_ref, contract_constants).ok_or_else(|| { +) -> Result { + fixed_type_size_with_constants_ref(type_ref, contract_constants).ok_or_else(|| { CompilerError::Unsupported(format!("readInputState does not support field type {}", type_name_from_ref(type_ref))) - })?; - let decode_numeric = type_ref.array_dims.is_empty() && matches!(type_ref.base, TypeBase::Int | TypeBase::Bool); - Ok((payload_len, decode_numeric)) + }) } fn fixed_state_field_payload_len<'i>( field: &ContractFieldAst<'i>, contract_constants: &HashMap>, -) -> Result<(usize, bool), CompilerError> { +) -> Result { fixed_state_field_payload_len_for_type_ref(&field.type_ref, contract_constants) } @@ -3710,7 +3708,7 @@ fn encoded_field_chunk_size<'i>( field: &ContractFieldAst<'i>, contract_constants: &HashMap>, ) -> Result { - let (payload_size, _) = fixed_state_field_payload_len(field, contract_constants)?; + let payload_size = fixed_state_field_payload_len(field, contract_constants)?; Ok(data_prefix(payload_size).len() + payload_size) } @@ -3718,7 +3716,7 @@ fn encoded_field_chunk_size_for_type_ref<'i>( type_ref: &TypeRef, contract_constants: &HashMap>, ) -> Result { - let (payload_size, _) = fixed_state_field_payload_len_for_type_ref(type_ref, contract_constants)?; + let payload_size = fixed_state_field_payload_len_for_type_ref(type_ref, contract_constants)?; Ok(data_prefix(payload_size).len() + payload_size) } @@ -3771,7 +3769,7 @@ fn read_input_state_binding_expr<'i>( script_size_value: i64, contract_constants: &HashMap>, ) -> Result, CompilerError> { - let (field_payload_len, decode_numeric) = fixed_state_field_payload_len(field, contract_constants)?; + let field_payload_len = fixed_state_field_payload_len(field, contract_constants)?; let field_payload_offset = state_start_offset + field_chunk_offset + data_prefix(field_payload_len).len(); let sig_len = Expr::call("OpTxInputScriptSigLen", vec![input_idx.clone()]); @@ -3792,7 +3790,7 @@ fn read_input_state_binding_expr<'i>( ); let substr = Expr::call("OpTxInputScriptSigSubstr", vec![input_idx.clone(), start, end]); - if decode_numeric { Ok(Expr::call("OpBin2Num", vec![substr])) } else { Ok(substr) } + cast_read_input_state_expr(substr, &field.type_ref) } fn read_input_state_field_expr_with_type<'i>( @@ -3804,10 +3802,9 @@ fn read_input_state_field_expr_with_type<'i>( contract_constants: &HashMap>, builtin_name: &str, ) -> Result, CompilerError> { - let (field_payload_len, decode_numeric) = - fixed_state_field_payload_len_for_type_ref(field_type, contract_constants).map_err(|_| { - CompilerError::Unsupported(format!("{builtin_name} does not support field type {}", type_name_from_ref(field_type))) - })?; + let field_payload_len = fixed_state_field_payload_len_for_type_ref(field_type, contract_constants).map_err(|_| { + CompilerError::Unsupported(format!("{builtin_name} does not support field type {}", type_name_from_ref(field_type))) + })?; let field_payload_offset = binary_expr( BinaryOp::Add, state_start_offset_expr, @@ -3817,7 +3814,15 @@ fn read_input_state_field_expr_with_type<'i>( let end = binary_expr(BinaryOp::Add, start.clone(), Expr::int(field_payload_len as i64)); let substr = input_sigscript_substr_expr(input_idx, start, end); - if decode_numeric { Ok(Expr::call("OpBin2Num", vec![substr])) } else { Ok(substr) } + cast_read_input_state_expr(substr, field_type) +} + +fn cast_read_input_state_expr<'i>(substr: Expr<'i>, type_ref: &TypeRef) -> Result, CompilerError> { + let type_name = type_name_from_ref(type_ref); + match type_ref.base { + TypeBase::Custom(_) => Err(CompilerError::Unsupported(format!("readInputState does not support field type {type_name}"))), + _ => Ok(Expr::call(type_name.as_str(), vec![substr])), + } } #[allow(clippy::too_many_arguments)] @@ -4458,15 +4463,11 @@ fn compile_encoded_object_with_layout( return Err(CompilerError::Unsupported(format!("missing state field '{}'", field.name))); }; - let (field_size, encode_numeric) = - fixed_state_field_payload_len_for_type_ref(&field.type_ref, contract_constants).map_err(|_| { - CompilerError::Unsupported(format!( - "{builtin_name} does not support field type {}", - type_name_from_ref(&field.type_ref) - )) - })?; + let field_size = fixed_state_field_payload_len_for_type_ref(&field.type_ref, contract_constants).map_err(|_| { + CompilerError::Unsupported(format!("{builtin_name} does not support field type {}", type_name_from_ref(&field.type_ref))) + })?; - if encode_numeric { + if field.type_ref.array_dims.is_empty() && matches!(field.type_ref.base, TypeBase::Int | TypeBase::Bool) { compile_expr( new_value, env, @@ -6373,9 +6374,6 @@ fn compile_expr<'i>( *stack_depth -= 1; builder.add_op(OpSubstr)?; *stack_depth -= 2; - if element_type == "int" { - builder.add_op(OpBin2Num)?; - } Ok(()) } ExprKind::Slice { source, start, end, .. } => { @@ -7178,7 +7176,7 @@ fn compile_call_expr<'i>( )?; Ok(()) } - "bool" | "string" => { + "byte" | "bool" | "string" => { if args.len() != 1 { return Err(CompilerError::Unsupported(format!("{name}() expects a single argument"))); } diff --git a/silverscript-lang/tests/chess_apps_tests.rs b/silverscript-lang/tests/chess_apps_tests.rs index 23ddee33..766af461 100644 --- a/silverscript-lang/tests/chess_apps_tests.rs +++ b/silverscript-lang/tests/chess_apps_tests.rs @@ -657,9 +657,9 @@ fn size_snapshots() -> Vec { SizeSnapshot { name: "player.sil", ctor: player_constructor_args, - expected_script_len: 2922, - expected_instruction_count: 2146, - expected_charged_op_count: 1496, + expected_script_len: 2915, + expected_instruction_count: 2139, + expected_charged_op_count: 1489, }, SizeSnapshot { name: "chess_mux.sil", @@ -671,65 +671,65 @@ fn size_snapshots() -> Vec { SizeSnapshot { name: "chess_settle.sil", ctor: settle_constructor_args, - expected_script_len: 2666, - expected_instruction_count: 2058, - expected_charged_op_count: 1347, + expected_script_len: 2654, + expected_instruction_count: 2046, + expected_charged_op_count: 1335, }, SizeSnapshot { name: "chess_pawn.sil", ctor: pawn_constructor_args, - expected_script_len: 1833, - expected_instruction_count: 1208, - expected_charged_op_count: 794, + expected_script_len: 1834, + expected_instruction_count: 1207, + expected_charged_op_count: 788, }, SizeSnapshot { name: "chess_knight.sil", ctor: pawn_constructor_args, - expected_script_len: 1383, - expected_instruction_count: 794, - expected_charged_op_count: 527, + expected_script_len: 1384, + expected_instruction_count: 793, + expected_charged_op_count: 525, }, SizeSnapshot { name: "chess_vert.sil", ctor: pawn_constructor_args, - expected_script_len: 2035, - expected_instruction_count: 1393, - expected_charged_op_count: 915, + expected_script_len: 2036, + expected_instruction_count: 1392, + expected_charged_op_count: 913, }, SizeSnapshot { name: "chess_horiz.sil", ctor: pawn_constructor_args, - expected_script_len: 2035, - expected_instruction_count: 1393, - expected_charged_op_count: 915, + expected_script_len: 2036, + expected_instruction_count: 1392, + expected_charged_op_count: 913, }, SizeSnapshot { name: "chess_diag.sil", ctor: pawn_constructor_args, - expected_script_len: 1814, - expected_instruction_count: 1210, - expected_charged_op_count: 793, + expected_script_len: 1815, + expected_instruction_count: 1209, + expected_charged_op_count: 791, }, SizeSnapshot { name: "chess_king.sil", ctor: pawn_constructor_args, - expected_script_len: 1512, - expected_instruction_count: 921, - expected_charged_op_count: 611, + expected_script_len: 1513, + expected_instruction_count: 920, + expected_charged_op_count: 609, }, SizeSnapshot { name: "chess_castle.sil", ctor: pawn_constructor_args, - expected_script_len: 1523, - expected_instruction_count: 928, - expected_charged_op_count: 608, + expected_script_len: 1522, + expected_instruction_count: 925, + expected_charged_op_count: 604, }, SizeSnapshot { name: "chess_castle_challenge.sil", ctor: pawn_constructor_args, - expected_script_len: 1735, - expected_instruction_count: 1124, - expected_charged_op_count: 733, + expected_script_len: 1736, + expected_instruction_count: 1123, + expected_charged_op_count: 731, }, ] } diff --git a/silverscript-lang/tests/compiler_tests.rs b/silverscript-lang/tests/compiler_tests.rs index d2db2cbb..cabeee1f 100644 --- a/silverscript-lang/tests/compiler_tests.rs +++ b/silverscript-lang/tests/compiler_tests.rs @@ -505,15 +505,15 @@ fn sorting_network_over_fixed_array_matches_rust_model_across_cases() { let (instruction_count, charged_op_count) = script_op_counts(&compiled.script); println!("sorting_network {script_len} / {instruction_count} / {charged_op_count}"); assert_eq!( - script_len, 780, + script_len, 772, "sorting_network metrics: script_len={script_len} instruction_count={instruction_count} charged_op_count={charged_op_count}" ); assert_eq!( - instruction_count, 780, + instruction_count, 772, "sorting_network metrics: script_len={script_len} instruction_count={instruction_count} charged_op_count={charged_op_count}" ); assert_eq!( - charged_op_count, 607, + charged_op_count, 599, "sorting_network metrics: script_len={script_len} instruction_count={instruction_count} charged_op_count={charged_op_count}" ); @@ -3246,8 +3246,6 @@ fn compiles_int_array_index_to_expected_script() { .unwrap() .add_op(OpSubstr) .unwrap() - .add_op(OpBin2Num) - .unwrap() .add_i64(7) .unwrap() .add_op(OpNumEqual) @@ -4965,6 +4963,33 @@ fn read_input_state_accepts_self_state_under_selector_dispatch() { assert!(result.is_ok(), "readInputState should read the current state under selector dispatch: {result:?}"); } +#[test] +fn read_input_state_int_addition_uses_numeric_semantics() { + let source = r#" + contract C(int initX) { + int x = initX; + + entrypoint function main() { + State s = readInputState(this.activeInputIndex); + int y = s.x + 5; + require(y == 10); + } + } + "#; + + let compiled = compile_contract(source, &[5.into()], CompileOptions::default()).expect("compile succeeds"); + let sigscript = compiled.build_sig_script("main", vec![]).expect("sigscript builds"); + let sigscript = pay_to_script_hash_signature_script(compiled.script.clone(), sigscript).expect("p2sh sigscript wraps"); + let input = test_input(0, sigscript); + let input_spk = pay_to_script_hash_script(&compiled.script); + let output = TransactionOutput { value: 1000, script_public_key: input_spk.clone(), covenant: None }; + let tx = Transaction::new(1, vec![input], vec![output.clone()], 0, Default::default(), 0, vec![]); + let utxo_entry = UtxoEntry::new(output.value, input_spk, 0, tx.is_coinbase(), None); + + let result = execute_input(tx, vec![utxo_entry], 0); + assert!(result.is_ok(), "readInputState int arithmetic should use numeric semantics: {result:?}"); +} + #[test] fn read_input_state_accepts_three_field_state_under_selector_dispatch() { let source = r#" @@ -5041,6 +5066,298 @@ fn read_input_state_accepts_pubkey_and_bool_fields_under_selector_dispatch() { assert!(result.is_ok(), "readInputState should read pubkey and bool state under selector dispatch: {result:?}"); } +#[test] +fn read_input_state_runtime_preserves_supported_field_types_across_contract_shapes() { + let run_case = |source: &str, args: Vec>, label: &str| { + let compiled = compile_contract(source, &args, CompileOptions::default()).unwrap_or_else(|err| panic!("{label}: {err:?}")); + let sigscript = compiled.build_sig_script("main", vec![]).expect("sigscript builds"); + let sigscript = pay_to_script_hash_signature_script(compiled.script.clone(), sigscript).expect("p2sh sigscript wraps"); + let input = test_input(0, sigscript); + let input_spk = pay_to_script_hash_script(&compiled.script); + let output = TransactionOutput { value: 1000, script_public_key: input_spk.clone(), covenant: None }; + let tx = Transaction::new(1, vec![input], vec![output.clone()], 0, Default::default(), 0, vec![]); + let utxo_entry = UtxoEntry::new(output.value, input_spk, 0, tx.is_coinbase(), None); + + let result = execute_input(tx, vec![utxo_entry], 0); + assert!(result.is_ok(), "{label}: {result:?}"); + }; + + run_case( + r#" + contract C(int initInt) { + int someInt = initInt; + + entrypoint function noop() { + require(true); + } + + entrypoint function main() { + State x = readInputState(this.activeInputIndex); + require(x.someInt + 5 == 15); + } + } + "#, + vec![10.into()], + "int fields should preserve numeric semantics", + ); + + run_case( + r#" + contract C(int[2] initInts) { + int[2] someInts = initInts; + + entrypoint function noop() { + require(true); + } + + entrypoint function main() { + State x = readInputState(this.activeInputIndex); + require(x.someInts.length == 2); + require(x.someInts[0] == 1); + require(x.someInts[1] + 5 == 7); + } + } + "#, + vec![vec![Expr::int(1), Expr::int(2)].into()], + "int[2] fields should preserve array indexing semantics", + ); + + run_case( + r#" + contract C(bool initBool) { + bool someBool = initBool; + + entrypoint function noop() { + require(true); + } + + entrypoint function main() { + State x = readInputState(this.activeInputIndex); + require(x.someBool); + } + } + "#, + vec![true.into()], + "bool fields should preserve boolean semantics", + ); + + run_case( + r#" + contract C(bool[2] initBools) { + bool[2] someBools = initBools; + + entrypoint function noop() { + require(true); + } + + entrypoint function main() { + State x = readInputState(this.activeInputIndex); + require(x.someBools.length == 2); + require(x.someBools[0]); + require(!x.someBools[1]); + } + } + "#, + vec![vec![Expr::bool(true), Expr::bool(false)].into()], + "bool[2] fields should preserve array indexing semantics", + ); + + run_case( + r#" + contract C(byte[2] initBytes2) { + byte[2] someBytes2 = initBytes2; + + entrypoint function noop() { + require(true); + } + + entrypoint function main() { + State x = readInputState(this.activeInputIndex); + require(x.someBytes2.length == 2); + require(x.someBytes2 == 0x3412); + } + } + "#, + vec![vec![0x34u8, 0x12u8].into()], + "byte[2] fields should preserve fixed-byte-array semantics", + ); + + run_case( + r#" + contract C(pubkey initPubkey) { + pubkey somePubkey = initPubkey; + + entrypoint function noop() { + require(true); + } + + entrypoint function main() { + State x = readInputState(this.activeInputIndex); + require(x.somePubkey == pubkey(0x0202020202020202020202020202020202020202020202020202020202020202)); + + byte[] owner = byte[](x.somePubkey); + owner.push(byte(3)); + require(owner.length == 33); + } + } + "#, + vec![vec![2u8; 32].into()], + "pubkey fields should preserve fixed-size byte semantics", + ); + + run_case( + r#" + contract C(sig initSig) { + sig someSig = initSig; + + entrypoint function noop() { + require(true); + } + + entrypoint function main() { + State x = readInputState(this.activeInputIndex); + require(x.someSig == sig(0x1111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111111)); + + byte[] sigBytes = byte[](x.someSig); + sigBytes.push(byte(0x42)); + require(sigBytes.length == 66); + } + } + "#, + vec![vec![0x11u8; 65].into()], + "sig fields should preserve fixed-size byte semantics", + ); + + run_case( + r#" + contract C(datasig initDatasig) { + datasig someDatasig = initDatasig; + + entrypoint function noop() { + require(true); + } + + entrypoint function main() { + State x = readInputState(this.activeInputIndex); + require(x.someDatasig == datasig(0x22222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222222)); + + byte[] datasigBytes = byte[](x.someDatasig); + datasigBytes.push(byte(0x24)); + require(datasigBytes.length == 65); + } + } + "#, + vec![vec![0x22u8; 64].into()], + "datasig fields should preserve fixed-size byte semantics", + ); +} + +#[test] +fn read_input_state_runtime_preserves_supported_field_types_without_selector_dispatch() { + let run_case = |source: &str, args: Vec>, label: &str| { + let compiled = compile_contract(source, &args, CompileOptions::default()).unwrap_or_else(|err| panic!("{label}: {err:?}")); + let sigscript = compiled.build_sig_script("main", vec![]).expect("sigscript builds"); + let sigscript = pay_to_script_hash_signature_script(compiled.script.clone(), sigscript).expect("p2sh sigscript wraps"); + let input = test_input(0, sigscript); + let input_spk = pay_to_script_hash_script(&compiled.script); + let output = TransactionOutput { value: 1000, script_public_key: input_spk.clone(), covenant: None }; + let tx = Transaction::new(1, vec![input], vec![output.clone()], 0, Default::default(), 0, vec![]); + let utxo_entry = UtxoEntry::new(output.value, input_spk, 0, tx.is_coinbase(), None); + + let result = execute_input(tx, vec![utxo_entry], 0); + assert!(result.is_ok(), "{label}: {result:?}"); + }; + + run_case( + r#" + contract C(int initInt) { + int someInt = initInt; + + entrypoint function main() { + State x = readInputState(this.activeInputIndex); + require(x.someInt + 5 == 15); + } + } + "#, + vec![10.into()], + "single-entrypoint int fields should preserve numeric semantics", + ); + + run_case( + r#" + contract C(byte[2] initBytes2) { + byte[2] someBytes2 = initBytes2; + + entrypoint function main() { + State x = readInputState(this.activeInputIndex); + require(x.someBytes2.length == 2); + require(x.someBytes2 == 0x3412); + } + } + "#, + vec![vec![0x34u8, 0x12u8].into()], + "single-entrypoint byte[2] fields should preserve fixed-byte-array semantics", + ); + + run_case( + r#" + contract C(pubkey initPubkey) { + pubkey somePubkey = initPubkey; + + entrypoint function main() { + State x = readInputState(this.activeInputIndex); + require(x.somePubkey == pubkey(0x0202020202020202020202020202020202020202020202020202020202020202)); + + byte[] owner = byte[](x.somePubkey); + owner.push(byte(3)); + require(owner.length == 33); + } + } + "#, + vec![vec![2u8; 32].into()], + "single-entrypoint pubkey fields should preserve fixed-size byte semantics", + ); +} + +// TODO: Fix this bug by using builder.add_data_with_push_opcode instead of builder.add_data after covpp-reset2 is finalized. +#[test] +fn read_input_state_scalar_byte_regression_repros_runtime_mismatch() { + let source = r#" + contract C(byte initByte, pubkey initOwner) { + byte someByte = initByte; + pubkey someOwner = initOwner; + + entrypoint function noop() { + require(true); + } + + entrypoint function main() { + State x = readInputState(this.activeInputIndex); + + // The companion pubkey field proves the state offsets are otherwise correct for this layout. + require(x.someOwner == pubkey(0x0202020202020202020202020202020202020202020202020202020202020202)); + + // This should succeed once scalar byte fields round-trip through readInputState with + // the same semantics as ordinary byte values. Today it still fails at runtime. + require(x.someByte == 7); + } + } + "#; + + let compiled = + compile_contract(source, &[Expr::byte(7), vec![2u8; 32].into()], CompileOptions::default()).expect("compile succeeds"); + let sigscript = compiled.build_sig_script("main", vec![]).expect("sigscript builds"); + let sigscript = pay_to_script_hash_signature_script(compiled.script.clone(), sigscript).expect("p2sh sigscript wraps"); + let input = test_input(0, sigscript); + let input_spk = pay_to_script_hash_script(&compiled.script); + let output = TransactionOutput { value: 1000, script_public_key: input_spk.clone(), covenant: None }; + let tx = Transaction::new(1, vec![input], vec![output.clone()], 0, Default::default(), 0, vec![]); + let utxo_entry = UtxoEntry::new(output.value, input_spk, 0, tx.is_coinbase(), None); + + let result = execute_input(tx, vec![utxo_entry], 0); + assert!(result.is_err(), "scalar byte readInputState regression should currently fail at runtime"); +} + #[test] fn validate_output_state_accepts_state_under_selector_dispatch() { let source = r#" @@ -5359,9 +5676,6 @@ fn compiles_read_input_state_to_expected_script() { // bytes = sigScriptSubstr(input=1, start_x, end_x) .add_op(OpTxInputScriptSigSubstr) .unwrap() - // decode bytes -> int - .add_op(OpBin2Num) - .unwrap() // literal threshold .add_i64(7) .unwrap() From 47394e3c49211b63e4ddb1067e9b319bf758a01f Mon Sep 17 00:00:00 2001 From: Manyfestation <240733973+Manyfestation@users.noreply.github.com> Date: Sun, 29 Mar 2026 17:03:12 +0300 Subject: [PATCH 06/11] bugfix --- debugger/session/src/session.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/debugger/session/src/session.rs b/debugger/session/src/session.rs index 2cdce813..e2b76cac 100644 --- a/debugger/session/src/session.rs +++ b/debugger/session/src/session.rs @@ -876,6 +876,12 @@ impl<'a, 'i> DebugSession<'a, 'i> { origin: VariableOrigin, ) -> Option<()> { let type_name = type_ref.type_name(); + if !is_structured_type_ref(type_ref) { + let expr = debug_value_to_expr(value)?; + bindings.insert(name.to_string(), ScopeBinding { type_name, source: ScopeValueSource::Expr(expr), origin, hidden: false }); + return Some(()); + } + let leaf_specs = flatten_contract_type_leaves(self.contract_ast.as_ref()?, type_ref).ok()?; if leaf_specs.is_empty() { let expr = debug_value_to_expr(value)?; From d8132d206f3275e4f58dc9bc30cd368297205fcb Mon Sep 17 00:00:00 2001 From: Manyfestation <240733973+Manyfestation@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:09:59 +0300 Subject: [PATCH 07/11] wip --- debugger/cli/src/main.rs | 34 ++--- debugger/session/src/session.rs | 49 ++++++- silverscript-lang/src/compiler.rs | 124 +++++++----------- .../src/compiler/covenant_declarations.rs | 5 +- .../src/compiler/debug_recording.rs | 58 +------- silverscript-lang/src/debug_info.rs | 9 -- 6 files changed, 108 insertions(+), 171 deletions(-) diff --git a/debugger/cli/src/main.rs b/debugger/cli/src/main.rs index 95b00006..f359d08b 100644 --- a/debugger/cli/src/main.rs +++ b/debugger/cli/src/main.rs @@ -24,9 +24,8 @@ use kaspa_txscript::{EngineCtx, EngineFlags, pay_to_script_hash_script}; use silverscript_lang::ast::{ContractAst, Expr, ExprKind, parse_contract_ast}; use silverscript_lang::compiler::{ CompileOptions, CovenantDeclBinding, CovenantDeclCallOptions, ResolvedCovenantCallTarget, compile_contract, - materialize_state_script, + materialize_state_script, resolve_contract_state_expr, }; -use silverscript_lang::debug_info::DebugInfo; const PROMPT: &str = "(sdb) "; @@ -114,29 +113,18 @@ fn resolve_state_for_ctor_args( let ctor_args = parse_ctor_args(parsed_contract, raw_ctor_args)?; let compile_opts = CompileOptions { record_debug_infos: true, ..Default::default() }; let compiled = compile_contract(source, &ctor_args, compile_opts)?; - let value = contract_state_from_debug_info( - compiled - .debug_info - .as_ref() - .ok_or_else(|| "state resolution requires debug-enabled compilation".to_string()) - .map_err(|err| -> Box { err.into() })?, - )?; + let debug_info = compiled + .debug_info + .as_ref() + .ok_or_else(|| "state resolution requires debug-enabled compilation".to_string()) + .map_err(|err| -> Box { err.into() })?; + let expr = resolve_contract_state_expr(&compiled.ast, &debug_info.constructor_args, &debug_info.constants) + .map_err(|err| -> Box { err.into() })?; + let value = expr_to_debug_value(&expr).map_err(|err| -> Box { err.into() })?; cache.insert(raw_ctor_args.to_vec(), value.clone()); Ok(value) } -fn contract_state_from_debug_info( - debug_info: &DebugInfo<'_>, -) -> Result> { - let fields = debug_info - .contract_state - .iter() - .map(|field| Ok((field.name.clone(), expr_to_debug_value(&field.value)?))) - .collect::, String>>() - .map_err(|err| -> Box { err.into() })?; - Ok(debugger_session::session::DebugValue::Object(fields)) -} - fn resolve_state_from_raw( parsed_contract: &ContractAst<'_>, raw_state: &str, @@ -706,7 +694,9 @@ fn main() -> Result<(), Box> { ctor_script_cache.insert(raw_constructor_args.clone(), compiled.script.clone()); if !parsed_contract.fields.is_empty() { let root_state = if let Some(debug_info) = debug_info.as_ref() { - contract_state_from_debug_info(debug_info)? + let expr = resolve_contract_state_expr(&compiled.ast, &debug_info.constructor_args, &debug_info.constants) + .map_err(|err| -> Box { err.into() })?; + expr_to_debug_value(&expr).map_err(|err| -> Box { err.into() })? } else { resolve_state_for_ctor_args(&source, &parsed_contract, &raw_constructor_args, &mut ctor_state_cache)? }; diff --git a/debugger/session/src/session.rs b/debugger/session/src/session.rs index e2b76cac..1e596425 100644 --- a/debugger/session/src/session.rs +++ b/debugger/session/src/session.rs @@ -14,6 +14,7 @@ use silverscript_lang::ast::{ }; use silverscript_lang::compiler::{ CovenantDeclBinding, CovenantDeclInfo, ResolvedCovenantCallTarget, compile_debug_expr, flattened_struct_name, + resolve_contract_state_expr, }; use silverscript_lang::debug_info::{ DebugFunctionRange, DebugInfo, DebugLeafBinding, DebugNamedValue, DebugParamBinding, DebugStep, DebugVariableUpdate, @@ -852,14 +853,17 @@ impl<'a, 'i> DebugSession<'a, 'i> { return; }; - let Some(state_value) = self.active_contract_state.as_ref() else { - record_debug_named_values(bindings, &self.debug_info.contract_state, VariableOrigin::ContractField); + let state_value = if let Some(state_value) = self.active_contract_state.as_ref() { + state_value.clone() + } else if let Some(state_value) = self.resolved_contract_state_value() { + state_value + } else { return; }; for field in &contract.fields { let field_path = vec![field.name.clone()]; - let Some(field_value) = structured_leaf_value(state_value, &field_path) else { + let Some(field_value) = structured_leaf_value(&state_value, &field_path) else { continue; }; let _ = @@ -919,6 +923,12 @@ impl<'a, 'i> DebugSession<'a, 'i> { Some(()) } + fn resolved_contract_state_value(&self) -> Option { + let contract = self.contract_ast.as_ref()?; + let expr = resolve_contract_state_expr(contract, &self.debug_info.constructor_args, &self.debug_info.constants).ok()?; + expr_to_debug_value(&expr).ok() + } + fn freeze_inline_snapshot_bindings(&self, bindings: &mut ScopeState<'i>, frame_id: u32) -> HashSet { let Some(parent_vars) = self.inline_scope_snapshots.get(&frame_id) else { return HashSet::new(); @@ -1763,6 +1773,36 @@ fn debug_value_to_expr<'i>(value: &DebugValue) -> Option> { } } +fn expr_to_debug_value(expr: &Expr<'_>) -> Result { + match &expr.kind { + ExprKind::Int(value) => Ok(DebugValue::Int(*value)), + ExprKind::Bool(value) => Ok(DebugValue::Bool(*value)), + ExprKind::Byte(value) => Ok(DebugValue::Bytes(vec![*value])), + ExprKind::String(value) => Ok(DebugValue::String(value.clone())), + ExprKind::Array(values) => { + if values.iter().all(|value| matches!(value.kind, ExprKind::Byte(_))) { + return Ok(DebugValue::Bytes( + values + .iter() + .map(|value| match value.kind { + ExprKind::Byte(byte) => byte, + _ => unreachable!("checked"), + }) + .collect(), + )); + } + Ok(DebugValue::Array(values.iter().map(expr_to_debug_value).collect::, _>>()?)) + } + ExprKind::StateObject(fields) => Ok(DebugValue::Object( + fields + .iter() + .map(|field| Ok((field.name.clone(), expr_to_debug_value(&field.expr)?))) + .collect::, String>>()?, + )), + other => Err(format!("unsupported resolved state expression in debugger: {other:?}")), + } +} + /// Executes sigscript to seed the stack before debugging lockscript. fn seed_engine_with_sigscript(engine: &mut DebugEngine<'_>, sigscript: &[u8]) -> Result<(), kaspa_txscript_errors::TxScriptError> { for opcode in parse_script::, DebugReused>(sigscript) { @@ -2097,7 +2137,6 @@ mod tests { functions: vec![DebugFunctionRange { name: "f".to_string(), bytecode_start: 0, bytecode_end: 1 }], constructor_args: vec![], constants: vec![DebugNamedValue { name: "K".to_string(), type_name: "int".to_string(), value: Expr::int(7) }], - contract_state: vec![], }; DebugSession::full(sigscript, &[], "", Some(debug_info), engine) } @@ -2315,7 +2354,6 @@ mod tests { span::Span::default(), ), }], - contract_state: vec![], }; let session = DebugSession::full(&[], &[], "", Some(debug_info), engine).unwrap(); let scope_state = session.scope_state(StepId::ROOT).unwrap(); @@ -2358,7 +2396,6 @@ mod tests { functions: vec![DebugFunctionRange { name: "main".to_string(), bytecode_start: 0, bytecode_end: 1 }], constructor_args: vec![], constants: vec![], - contract_state: vec![], }; let session = DebugSession::full(&[], &[], source, Some(debug_info), engine).unwrap().with_active_contract_state(Some( DebugValue::Object(vec![( diff --git a/silverscript-lang/src/compiler.rs b/silverscript-lang/src/compiler.rs index 56fd78de..d3eeaaf1 100644 --- a/silverscript-lang/src/compiler.rs +++ b/silverscript-lang/src/compiler.rs @@ -10,7 +10,7 @@ use crate::ast::{ ParamAst, SplitPart, StateBindingAst, StateFieldExpr, Statement, TimeVar, TypeBase, TypeRef, UnaryOp, UnarySuffixKind, parse_contract_ast, parse_type_ref, }; -use crate::debug_info::{DebugInfo, RuntimeBinding, SourceSpan}; +use crate::debug_info::{DebugInfo, DebugNamedValue, RuntimeBinding, SourceSpan}; pub use crate::errors::{CompilerError, ErrorSpan}; use crate::span; mod covenant_declarations; @@ -88,90 +88,50 @@ pub struct CompiledContract<'i> { } pub fn materialize_state_script<'i>(base_compiled: &CompiledContract<'i>, state: &Expr<'i>) -> Result, CompilerError> { - if base_compiled.state_layout.len == 0 { - return Err(CompilerError::Unsupported("contract does not expose a materializable state segment".to_string())); - } - let debug_info = base_compiled .debug_info .as_ref() .ok_or_else(|| CompilerError::Unsupported("state materialization requires debug-enabled compilation".to_string()))?; - let structs = build_struct_registry(&base_compiled.ast)?; - let mut constants: HashMap> = - debug_info.constants.iter().map(|constant| (constant.name.clone(), constant.value.clone())).collect(); - for param in &debug_info.constructor_args { - constants.insert(param.name.clone(), param.value.clone()); - } - let encoded_state = encode_contract_state_segment(&base_compiled.ast.fields, state, &structs, &constants)?; - if encoded_state.len() != base_compiled.state_layout.len { - return Err(CompilerError::Unsupported( - "explicit state changes encoded script size; provide raw script_hex instead".to_string(), - )); - } - - let state_start = base_compiled.state_layout.start; - let state_end = base_compiled.state_layout.start + base_compiled.state_layout.len; - if base_compiled.script.len() < state_end { - return Err(CompilerError::Unsupported("state template range exceeds compiled script length".to_string())); - } - - let mut script = base_compiled.script.clone(); - script[state_start..state_end].copy_from_slice(&encoded_state); - Ok(script) -} - -fn encode_contract_state_segment<'i>( - contract_fields: &[ContractFieldAst<'i>], - state: &Expr<'i>, - structs: &StructRegistry, - constants: &HashMap>, -) -> Result, CompilerError> { let mut provided = collect_state_object_entries(state, "State value")?; - if provided.len() != contract_fields.len() { + if provided.len() != base_compiled.ast.fields.len() { return Err(CompilerError::Unsupported("State value must include all contract fields exactly once".to_string())); } - let mut out = Vec::new(); - for field in contract_fields { - let value = provided - .remove(field.name.as_str()) + let mut contract = base_compiled.ast.clone(); + for field in &mut contract.fields { + field.expr = provided + .remove(field.name.as_str()).cloned() .ok_or_else(|| CompilerError::Unsupported(format!("missing state field '{}'", field.name)))?; - let type_name = type_name_from_ref(&field.type_ref); - if !expr_matches_declared_type_ref(value, &field.type_ref, structs) { - return Err(CompilerError::Unsupported(format!("contract field '{}' expects {}", field.name, type_name))); - } - - let encoded = if struct_name_from_type_ref(&field.type_ref, structs).is_some() { - encode_struct_value(value, &field.type_ref, structs)? - } else if fixed_type_size_with_constants_ref(&field.type_ref, constants).is_some() { - encode_fixed_size_value(value, &type_name)? - } else { - match &value.kind { - ExprKind::Array(values) => { - if is_byte_array(value) { - values - .iter() - .filter_map(|entry| if let ExprKind::Byte(byte) = &entry.kind { Some(*byte) } else { None }) - .collect() - } else { - encode_array_literal(values, &type_name)? - } - } - ExprKind::String(string) => string.as_bytes().to_vec(), - ExprKind::Byte(byte) => vec![*byte], - _ => { - return Err(CompilerError::Unsupported(format!("contract field '{}' expects {}", field.name, type_name))); - } - } - }; - - out.extend_from_slice(&data_prefix(encoded.len())); - out.extend(encoded); } if let Some(extra) = provided.keys().next() { return Err(CompilerError::Unsupported(format!("unknown state field '{}'", extra))); } - Ok(out) + + let constructor_args = debug_info.constructor_args.iter().map(|arg| arg.value.clone()).collect::>(); + Ok(compile_contract_ast(&contract, &constructor_args, CompileOptions::default())?.script) +} + +pub fn resolve_contract_state_expr<'i>( + contract: &ContractAst<'i>, + constructor_args: &[DebugNamedValue<'i>], + constants: &[DebugNamedValue<'i>], +) -> Result, CompilerError> { + let mut env = HashMap::new(); + for constant in constants { + env.insert(constant.name.clone(), constant.value.clone()); + } + for arg in constructor_args { + env.insert(arg.name.clone(), arg.value.clone()); + } + + let mut fields = Vec::with_capacity(contract.fields.len()); + for field in &contract.fields { + let resolved = resolve_expr(field.expr.clone(), &env, &mut HashSet::new())?; + env.insert(field.name.clone(), resolved.clone()); + fields.push(StateFieldExpr { name: field.name.clone(), expr: resolved, span: field.span, name_span: field.name_span }); + } + + Ok(Expr::new(ExprKind::StateObject(fields), span::Span::default())) } fn collect_state_object_entries<'a, 'i>( @@ -195,13 +155,13 @@ struct LoweringScope { vars: HashMap, } -#[derive(Debug, Clone)] +#[derive(Clone)] struct StructFieldSpec { name: String, type_ref: TypeRef, } -#[derive(Debug, Clone)] +#[derive(Clone)] struct StructSpec { fields: Vec, } @@ -1057,11 +1017,11 @@ fn compile_contract_impl<'i>( let mut script_size = if uses_script_size { Some(100i64) } else { None }; for _ in 0..32 { - let (contract_fields, field_prolog_script) = + let (_contract_fields, field_prolog_script) = compile_contract_fields(&lowered_contract.fields, &constants, options, script_size, &structs)?; let mut recorder = DebugRecorder::new(options.record_debug_infos); - recorder.record_contract_scope(&contract.params, constructor_args, &contract.constants, &lowered_contract, &contract_fields); + recorder.record_contract_scope(&contract.params, constructor_args, &contract.constants); let selector_prefix_len = if without_selector { 0 } else { 1 }; let contract_field_prefix_len = selector_prefix_len + field_prolog_script.len(); let state_layout = CompiledStateLayout { start: selector_prefix_len, len: field_prolog_script.len() }; @@ -1891,10 +1851,14 @@ fn array_size_ref(type_ref: &TypeRef) -> Option { fn array_size_with_constants_ref<'i>(type_ref: &TypeRef, constants: &HashMap>) -> Option { match type_ref.array_size()? { ArrayDim::Fixed(size) => Some(*size), - ArrayDim::Constant(name) => constants - .get(name) - .and_then(|value| eval_const_int(value, constants).ok()) - .and_then(|value| (value >= 0).then_some(value as usize)), + ArrayDim::Constant(name) => { + if let Some(Expr { kind: ExprKind::Int(value), .. }) = constants.get(name) { + if *value >= 0 { + return Some(*value as usize); + } + } + None + } ArrayDim::Dynamic | ArrayDim::Inferred => None, } } diff --git a/silverscript-lang/src/compiler/covenant_declarations.rs b/silverscript-lang/src/compiler/covenant_declarations.rs index 5be44643..7830104e 100644 --- a/silverscript-lang/src/compiler/covenant_declarations.rs +++ b/silverscript-lang/src/compiler/covenant_declarations.rs @@ -157,7 +157,7 @@ pub(super) fn lower_covenant_declarations<'i>( continue; } - let declaration = parse_covenant_declaration(function, constants, true)?; + let declaration = parse_covenant_declaration(function, constants)?; validate_covenant_policy_state_shape(function, &declaration, &contract.fields)?; infos.push(build_covenant_decl_info(function, declaration.binding)); @@ -208,7 +208,6 @@ fn build_covenant_decl_info<'i>(function: &FunctionAst<'i>, binding: CovenantDec fn parse_covenant_declaration<'i>( function: &FunctionAst<'i>, constants: &HashMap>, - emit_warnings: bool, ) -> Result, CompilerError> { #[derive(Clone, Copy, PartialEq, Eq)] enum CovenantSyntax { @@ -374,7 +373,7 @@ fn parse_covenant_declaration<'i>( if binding == CovenantDeclBinding::Auth && from_value != 1 { return Err(CompilerError::Unsupported("binding=auth requires from = 1".to_string())); } - if emit_warnings && binding == CovenantDeclBinding::Cov && from_value == 1 && args_by_name.contains_key("binding") { + if binding == CovenantDeclBinding::Cov && from_value == 1 && args_by_name.contains_key("binding") { eprintln!( "warning: #[covenant(...)] on function '{}' uses binding=cov with from=1; binding=auth is usually a better default", function.name diff --git a/silverscript-lang/src/compiler/debug_recording.rs b/silverscript-lang/src/compiler/debug_recording.rs index d5622e08..022d6b97 100644 --- a/silverscript-lang/src/compiler/debug_recording.rs +++ b/silverscript-lang/src/compiler/debug_recording.rs @@ -1,7 +1,7 @@ use std::collections::{HashMap, HashSet}; use std::fmt; -use crate::ast::{ConstantAst, ContractAst, ContractFieldAst, Expr, ExprKind, FunctionAst, ParamAst, Statement, parse_type_ref}; +use crate::ast::{ConstantAst, ContractFieldAst, Expr, ExprKind, FunctionAst, ParamAst, Statement, parse_type_ref}; use crate::debug_info::{ DebugFunctionRange, DebugInfo, DebugInfoRecorder, DebugLeafBinding, DebugNamedValue, DebugParamBinding, DebugParamMapping, DebugStep, DebugVariableUpdate, RuntimeBinding, SourceSpan, StepKind, @@ -30,15 +30,8 @@ impl<'i> DebugRecorder<'i> { } /// Records contract-scoped debugger bindings (constructor args and constant declarations). - pub fn record_contract_scope( - &mut self, - params: &[ParamAst<'i>], - values: &[Expr<'i>], - constants: &[ConstantAst<'i>], - contract: &ContractAst<'i>, - field_values: &HashMap>, - ) { - self.inner.record_contract_scope(params, values, constants, contract, field_values); + pub fn record_contract_scope(&mut self, params: &[ParamAst<'i>], values: &[Expr<'i>], constants: &[ConstantAst<'i>]) { + self.inner.record_contract_scope(params, values, constants); } /// Starts staging debug metadata for one entrypoint compilation. @@ -119,14 +112,7 @@ impl<'i> DebugRecorder<'i> { } trait DebugRecorderImpl<'i>: fmt::Debug { - fn record_contract_scope( - &mut self, - params: &[ParamAst<'i>], - values: &[Expr<'i>], - constants: &[ConstantAst<'i>], - contract: &ContractAst<'i>, - field_values: &HashMap>, - ); + fn record_contract_scope(&mut self, params: &[ParamAst<'i>], values: &[Expr<'i>], constants: &[ConstantAst<'i>]); fn begin_entrypoint( &mut self, name: &str, @@ -173,15 +159,7 @@ trait DebugRecorderImpl<'i>: fmt::Debug { struct NoopDebugRecorder; impl<'i> DebugRecorderImpl<'i> for NoopDebugRecorder { - fn record_contract_scope( - &mut self, - _params: &[ParamAst<'i>], - _values: &[Expr<'i>], - _constants: &[ConstantAst<'i>], - _contract: &ContractAst<'i>, - _field_values: &HashMap>, - ) { - } + fn record_contract_scope(&mut self, _params: &[ParamAst<'i>], _values: &[Expr<'i>], _constants: &[ConstantAst<'i>]) {} fn begin_entrypoint( &mut self, _name: &str, @@ -251,29 +229,8 @@ impl<'i> ActiveDebugRecorder<'i> { } } -fn debug_named_contract_state<'i>(contract: &ContractAst<'i>, field_values: &HashMap>) -> Vec> { - contract - .fields - .iter() - .filter_map(|field| { - field_values.get(&field.name).cloned().map(|value| DebugNamedValue { - name: field.name.clone(), - type_name: field.type_ref.type_name(), - value, - }) - }) - .collect() -} - impl<'i> DebugRecorderImpl<'i> for ActiveDebugRecorder<'i> { - fn record_contract_scope( - &mut self, - params: &[ParamAst<'i>], - values: &[Expr<'i>], - constants: &[ConstantAst<'i>], - contract: &ContractAst<'i>, - field_values: &HashMap>, - ) { + fn record_contract_scope(&mut self, params: &[ParamAst<'i>], values: &[Expr<'i>], constants: &[ConstantAst<'i>]) { for (param, value) in params.iter().zip(values.iter()) { self.recorder.record_constructor_arg(DebugNamedValue { name: param.name.clone(), @@ -288,7 +245,6 @@ impl<'i> DebugRecorderImpl<'i> for ActiveDebugRecorder<'i> { value: constant.expr.clone(), }); } - self.recorder.record_contract_state(debug_named_contract_state(contract, field_values)); } fn begin_entrypoint( @@ -885,7 +841,7 @@ mod tests { let structs = super::super::build_struct_registry(&contract).expect("build struct registry"); let mut recorder = DebugRecorder::new(false); - recorder.record_contract_scope(&contract.params, &[], &contract.constants, &contract, &HashMap::new()); + recorder.record_contract_scope(&contract.params, &[], &contract.constants); recorder.begin_entrypoint("spend", function, &contract.fields, &structs).expect("noop begin entrypoint"); let span = SourceSpan::from(stmt.span()); diff --git a/silverscript-lang/src/debug_info.rs b/silverscript-lang/src/debug_info.rs index db8f88f5..3e1a12b0 100644 --- a/silverscript-lang/src/debug_info.rs +++ b/silverscript-lang/src/debug_info.rs @@ -29,7 +29,6 @@ pub struct DebugInfoRecorder<'i> { entry_points: Vec, constructor_args: Vec>, constants: Vec>, - contract_state: Vec>, next_sequence: u32, } @@ -57,10 +56,6 @@ impl<'i> DebugInfoRecorder<'i> { self.constants.push(binding); } - pub fn record_contract_state(&mut self, bindings: Vec>) { - self.contract_state = bindings; - } - /// Returns the next global sequence id for one emitted debug event. pub fn next_sequence(&mut self) -> u32 { let sequence = self.next_sequence; @@ -86,7 +81,6 @@ impl<'i> DebugInfoRecorder<'i> { functions: self.entry_points, constructor_args: self.constructor_args, constants: self.constants, - contract_state: self.contract_state, } } } @@ -105,8 +99,6 @@ pub struct DebugInfo<'i> { pub constructor_args: Vec>, #[serde(default)] pub constants: Vec>, - #[serde(default)] - pub contract_state: Vec>, } impl<'i> DebugInfo<'i> { @@ -118,7 +110,6 @@ impl<'i> DebugInfo<'i> { functions: Vec::new(), constructor_args: Vec::new(), constants: Vec::new(), - contract_state: Vec::new(), } } } From d8b46035b3ccab792821668c081c5802e35c6657 Mon Sep 17 00:00:00 2001 From: Manyfestation <240733973+Manyfestation@users.noreply.github.com> Date: Sun, 29 Mar 2026 18:14:25 +0300 Subject: [PATCH 08/11] fmt --- silverscript-lang/src/compiler.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/silverscript-lang/src/compiler.rs b/silverscript-lang/src/compiler.rs index d3eeaaf1..73c8d237 100644 --- a/silverscript-lang/src/compiler.rs +++ b/silverscript-lang/src/compiler.rs @@ -100,7 +100,8 @@ pub fn materialize_state_script<'i>(base_compiled: &CompiledContract<'i>, state: let mut contract = base_compiled.ast.clone(); for field in &mut contract.fields { field.expr = provided - .remove(field.name.as_str()).cloned() + .remove(field.name.as_str()) + .cloned() .ok_or_else(|| CompilerError::Unsupported(format!("missing state field '{}'", field.name)))?; } if let Some(extra) = provided.keys().next() { From 4b38dee93cfbd7c012604b2d41e76bf9fe59e489 Mon Sep 17 00:00:00 2001 From: Ori Newman Date: Sun, 29 Mar 2026 18:42:17 +0300 Subject: [PATCH 09/11] Fix compile-time overflow behaviour (#88) * Fix compile-time overflow behaviour * fmt --- silverscript-lang/src/ast.rs | 5 +- silverscript-lang/src/compiler.rs | 89 ++++++++++++++++++++--- silverscript-lang/tests/compiler_tests.rs | 29 ++++++++ silverscript-lang/tests/parser_tests.rs | 14 ++++ 4 files changed, 127 insertions(+), 10 deletions(-) diff --git a/silverscript-lang/src/ast.rs b/silverscript-lang/src/ast.rs index ec6f3f84..d1f7dee3 100644 --- a/silverscript-lang/src/ast.rs +++ b/silverscript-lang/src/ast.rs @@ -2134,7 +2134,10 @@ fn apply_number_unit<'i>(expr: Expr<'i>, unit: &str) -> Result, Compile "kas" => 100_000_000, _ => return Err(CompilerError::Unsupported(format!("number unit '{unit}' not supported"))), }; - Ok(Expr::new(ExprKind::Int(value.saturating_mul(multiplier)), span)) + let scaled = value + .checked_mul(multiplier) + .ok_or_else(|| CompilerError::InvalidLiteral(format!("number literal overflow for unit '{unit}'")))?; + Ok(Expr::new(ExprKind::Int(scaled), span)) } fn parse_date_literal<'i>(pair: Pair<'i, Rule>) -> Result, CompilerError> { diff --git a/silverscript-lang/src/compiler.rs b/silverscript-lang/src/compiler.rs index 3c412883..18e836f1 100644 --- a/silverscript-lang/src/compiler.rs +++ b/silverscript-lang/src/compiler.rs @@ -5198,8 +5198,11 @@ fn compile_for_statement<'i>( script_size: Option, recorder: &mut DebugRecorder<'i>, ) -> Result<(), CompilerError> { - let max_iterations = eval_const_int(max_iterations_expr, contract_constants) - .map_err(|_| CompilerError::Unsupported("for loop max iterations must be a compile-time integer".to_string()))?; + let max_iterations = match eval_const_int(max_iterations_expr, contract_constants) { + Ok(value) => value, + Err(CompilerError::InvalidLiteral(message)) => return Err(CompilerError::InvalidLiteral(message)), + Err(_) => return Err(CompilerError::Unsupported("for loop max iterations must be a compile-time integer".to_string())), + }; if max_iterations < 0 { return Err(CompilerError::Unsupported("for loop max iterations must be a non-negative compile-time integer".to_string())); } @@ -5443,26 +5446,37 @@ fn eval_const_int<'i>(expr: &Expr<'i>, constants: &HashMap>) -> Some(value) => eval_const_int(value, constants), None => Err(CompilerError::Unsupported("for loop bounds must be constant integers".to_string())), }, - ExprKind::Unary { op: UnaryOp::Neg, expr } => Ok(-eval_const_int(expr, constants)?), + ExprKind::Unary { op: UnaryOp::Neg, expr } => { + let value = eval_const_int(expr, constants)?; + value.checked_neg().ok_or_else(|| CompilerError::InvalidLiteral(format!("constant integer overflow: -({value})"))) + } ExprKind::Unary { .. } => Err(CompilerError::Unsupported("for loop bounds must be constant integers".to_string())), ExprKind::Binary { op, left, right } => { let lhs = eval_const_int(left, constants)?; let rhs = eval_const_int(right, constants)?; match op { - BinaryOp::Add => Ok(lhs + rhs), - BinaryOp::Sub => Ok(lhs - rhs), - BinaryOp::Mul => Ok(lhs * rhs), + BinaryOp::Add => lhs + .checked_add(rhs) + .ok_or_else(|| CompilerError::InvalidLiteral(format!("constant integer overflow: {lhs} + {rhs}"))), + BinaryOp::Sub => lhs + .checked_sub(rhs) + .ok_or_else(|| CompilerError::InvalidLiteral(format!("constant integer overflow: {lhs} - {rhs}"))), + BinaryOp::Mul => lhs + .checked_mul(rhs) + .ok_or_else(|| CompilerError::InvalidLiteral(format!("constant integer overflow: {lhs} * {rhs}"))), BinaryOp::Div => { if rhs == 0 { return Err(CompilerError::InvalidLiteral("division by zero in for loop bounds".to_string())); } - Ok(lhs / rhs) + lhs.checked_div(rhs) + .ok_or_else(|| CompilerError::InvalidLiteral(format!("constant integer overflow: {lhs} / {rhs}"))) } BinaryOp::Mod => { if rhs == 0 { return Err(CompilerError::InvalidLiteral("modulo by zero in for loop bounds".to_string())); } - Ok(lhs % rhs) + lhs.checked_rem(rhs) + .ok_or_else(|| CompilerError::InvalidLiteral(format!("constant integer overflow: {lhs} % {rhs}"))) } _ => Err(CompilerError::Unsupported("for loop bounds must be constant integers".to_string())), } @@ -7585,7 +7599,9 @@ mod tests { use kaspa_txscript::opcodes::codes::OpData1; - use super::{Op0, OpPushData1, OpPushData2, StackBindings, data_prefix}; + use crate::ast::{BinaryOp, Expr, ExprKind, UnaryOp}; + + use super::{Op0, OpPushData1, OpPushData2, StackBindings, data_prefix, eval_const_int}; #[test] fn data_prefix_encodes_small_pushes() { @@ -7629,4 +7645,59 @@ mod tests { ["field_b", "field_a", "param_b", "param_a"].into_iter().map(str::to_string).collect::>() ); } + + #[test] + fn eval_const_int_rejects_checked_arithmetic_overflow() { + let constants = HashMap::new(); + let cases = [ + ( + Expr::new( + ExprKind::Binary { op: BinaryOp::Add, left: Box::new(Expr::int(i64::MAX)), right: Box::new(Expr::int(1)) }, + Default::default(), + ), + format!("constant integer overflow: {} + 1", i64::MAX), + ), + ( + Expr::new( + ExprKind::Binary { op: BinaryOp::Sub, left: Box::new(Expr::int(-i64::MAX)), right: Box::new(Expr::int(2)) }, + Default::default(), + ), + format!("constant integer overflow: {} - 2", -i64::MAX), + ), + ( + Expr::new( + ExprKind::Binary { + op: BinaryOp::Mul, + left: Box::new(Expr::int(3_037_000_500)), + right: Box::new(Expr::int(3_037_000_500)), + }, + Default::default(), + ), + "constant integer overflow: 3037000500 * 3037000500".to_string(), + ), + ( + Expr::new(ExprKind::Unary { op: UnaryOp::Neg, expr: Box::new(Expr::int(i64::MIN)) }, Default::default()), + format!("constant integer overflow: -({})", i64::MIN), + ), + ( + Expr::new( + ExprKind::Binary { op: BinaryOp::Div, left: Box::new(Expr::int(i64::MIN)), right: Box::new(Expr::int(-1)) }, + Default::default(), + ), + format!("constant integer overflow: {} / -1", i64::MIN), + ), + ( + Expr::new( + ExprKind::Binary { op: BinaryOp::Mod, left: Box::new(Expr::int(i64::MIN)), right: Box::new(Expr::int(-1)) }, + Default::default(), + ), + format!("constant integer overflow: {} % -1", i64::MIN), + ), + ]; + + for (expr, expected) in cases { + let err = eval_const_int(&expr, &constants).expect_err("overflow should be rejected"); + assert!(err.to_string().contains(&expected), "unexpected error: {err}"); + } + } } diff --git a/silverscript-lang/tests/compiler_tests.rs b/silverscript-lang/tests/compiler_tests.rs index cabeee1f..8e3e5d1e 100644 --- a/silverscript-lang/tests/compiler_tests.rs +++ b/silverscript-lang/tests/compiler_tests.rs @@ -3682,6 +3682,35 @@ fn rejects_non_constant_for_loop_max_iterations() { assert!(err.to_string().contains("for loop max iterations must be a compile-time integer")); } +#[test] +fn rejects_overflow_in_constant_for_loop_bounds() { + let cases = [ + ("9223372036854775807 + 1", "constant integer overflow: 9223372036854775807 + 1"), + ("(-9223372036854775807) - 2", "constant integer overflow: -9223372036854775807 - 2"), + ("3037000500 * 3037000500", "constant integer overflow: 3037000500 * 3037000500"), + ("-(-9223372036854775807 - 1)", "constant integer overflow: -(-9223372036854775808)"), + ("(-9223372036854775807 - 1) / -1", "constant integer overflow: -9223372036854775808 / -1"), + ("(-9223372036854775807 - 1) % -1", "constant integer overflow: -9223372036854775808 % -1"), + ]; + + for (expr, expected) in cases { + let source = format!( + r#" + contract Loops() {{ + entrypoint function main() {{ + for (i, 0, 1, {expr}) {{ + require(i >= 0); + }} + }} + }} + "# + ); + + let err = compile_contract(&source, &[], CompileOptions::default()).expect_err("compile should fail"); + assert!(err.to_string().contains(expected), "unexpected error: {err}"); + } +} + #[test] fn runs_runtime_bounded_for_loop_example() { let source = r#" diff --git a/silverscript-lang/tests/parser_tests.rs b/silverscript-lang/tests/parser_tests.rs index 49277390..a452a7f2 100644 --- a/silverscript-lang/tests/parser_tests.rs +++ b/silverscript-lang/tests/parser_tests.rs @@ -32,6 +32,20 @@ fn parses_timeops_and_console() { assert!(result.is_ok()); } +#[test] +fn rejects_number_unit_overflow() { + let input = r#" + contract TimeLock() { + entrypoint function main() { + require(this.age >= 9223372036854775807 weeks); + } + } + "#; + + let err = parse_contract_ast(input).expect_err("unit multiplication overflow should be rejected"); + assert!(err.to_string().contains("overflow"), "unexpected error: {err}"); +} + #[test] fn parses_arrays_and_introspection() { let input = r#" From 49f53d45d54e5901948e83af8b1f341a7560ecf1 Mon Sep 17 00:00:00 2001 From: Manyfestation <240733973+Manyfestation@users.noreply.github.com> Date: Sun, 29 Mar 2026 22:28:02 +0300 Subject: [PATCH 10/11] Refactor encode state logic --- debugger/cli/src/main.rs | 27 +++++++-------- silverscript-lang/src/compiler.rs | 40 ++++++++++------------- silverscript-lang/tests/compiler_tests.rs | 30 +++-------------- 3 files changed, 34 insertions(+), 63 deletions(-) diff --git a/debugger/cli/src/main.rs b/debugger/cli/src/main.rs index f359d08b..03840634 100644 --- a/debugger/cli/src/main.rs +++ b/debugger/cli/src/main.rs @@ -24,7 +24,7 @@ use kaspa_txscript::{EngineCtx, EngineFlags, pay_to_script_hash_script}; use silverscript_lang::ast::{ContractAst, Expr, ExprKind, parse_contract_ast}; use silverscript_lang::compiler::{ CompileOptions, CovenantDeclBinding, CovenantDeclCallOptions, ResolvedCovenantCallTarget, compile_contract, - materialize_state_script, resolve_contract_state_expr, + resolve_contract_state_expr, }; const PROMPT: &str = "(sdb) "; @@ -248,19 +248,6 @@ fn encode_state_array_arg(output_states: &[&str]) -> Result { Ok(format!("[{}]", output_states.join(","))) } -fn materialize_script_for_explicit_state( - source: &str, - parsed_contract: &ContractAst<'_>, - raw_ctor_args: &[String], - raw_state: &str, -) -> Result, Box> { - let ctor_args = parse_ctor_args(parsed_contract, raw_ctor_args)?; - let state = parse_state_value(parsed_contract, raw_state)?; - let compile_opts = CompileOptions { record_debug_infos: true, ..Default::default() }; - let base_compiled = compile_contract(source, &ctor_args, compile_opts)?; - Ok(materialize_state_script(&base_compiled, &state)?) -} - fn parse_hex_32(raw: &str, name: &str) -> Result<[u8; 32], Box> { let bytes = parse_hex_bytes(raw)?; if bytes.len() != 32 { @@ -793,7 +780,11 @@ fn main() -> Result<(), Box> { }; let redeem_script = if input.utxo_script_hex.is_none() { if let Some(raw_state) = input.state.as_deref() { - Some(materialize_script_for_explicit_state(&source, &parsed_contract, &input_constructor_args, raw_state)?) + let ctor_args = parse_ctor_args(&parsed_contract, &input_constructor_args)?; + let state = parse_state_value(&parsed_contract, raw_state)?; + let compile_opts = CompileOptions { record_debug_infos: true, ..Default::default() }; + let compiled = compile_contract(&source, &ctor_args, compile_opts)?; + Some(compiled.encode_state(&state)?) } else { Some(compile_script_for_ctor_args(&source, &parsed_contract, &input_constructor_args, &mut ctor_script_cache)?) } @@ -843,7 +834,11 @@ fn main() -> Result<(), Box> { } else { let output_constructor_args = output.constructor_args.clone().unwrap_or_else(|| raw_constructor_args.clone()); let output_script = if let Some(raw_state) = output.state.as_deref() { - materialize_script_for_explicit_state(&source, &parsed_contract, &output_constructor_args, raw_state)? + let ctor_args = parse_ctor_args(&parsed_contract, &output_constructor_args)?; + let state = parse_state_value(&parsed_contract, raw_state)?; + let compile_opts = CompileOptions { record_debug_infos: true, ..Default::default() }; + let compiled = compile_contract(&source, &ctor_args, compile_opts)?; + compiled.encode_state(&state)? } else { compile_script_for_ctor_args(&source, &parsed_contract, &output_constructor_args, &mut ctor_script_cache)? }; diff --git a/silverscript-lang/src/compiler.rs b/silverscript-lang/src/compiler.rs index 73c8d237..646c328b 100644 --- a/silverscript-lang/src/compiler.rs +++ b/silverscript-lang/src/compiler.rs @@ -87,29 +87,24 @@ pub struct CompiledContract<'i> { pub debug_info: Option>, } -pub fn materialize_state_script<'i>(base_compiled: &CompiledContract<'i>, state: &Expr<'i>) -> Result, CompilerError> { - let debug_info = base_compiled - .debug_info - .as_ref() - .ok_or_else(|| CompilerError::Unsupported("state materialization requires debug-enabled compilation".to_string()))?; - let mut provided = collect_state_object_entries(state, "State value")?; - if provided.len() != base_compiled.ast.fields.len() { - return Err(CompilerError::Unsupported("State value must include all contract fields exactly once".to_string())); - } - - let mut contract = base_compiled.ast.clone(); - for field in &mut contract.fields { - field.expr = provided - .remove(field.name.as_str()) - .cloned() - .ok_or_else(|| CompilerError::Unsupported(format!("missing state field '{}'", field.name)))?; - } - if let Some(extra) = provided.keys().next() { - return Err(CompilerError::Unsupported(format!("unknown state field '{}'", extra))); - } +impl<'i> CompiledContract<'i> { + pub fn encode_state(&self, state: &Expr<'i>) -> Result, CompilerError> { + let structs = build_struct_registry(&self.ast)?; + let state_type = TypeRef { base: TypeBase::Custom("State".to_string()), array_dims: Vec::new() }; + let encoded_state = encode_struct_value(state, &state_type, &structs)?; + if encoded_state.len() != self.state_layout.len { + return Err(CompilerError::Unsupported(format!( + "encoded state size mismatch: expected {} bytes, got {}", + self.state_layout.len, + encoded_state.len() + ))); + } - let constructor_args = debug_info.constructor_args.iter().map(|arg| arg.value.clone()).collect::>(); - Ok(compile_contract_ast(&contract, &constructor_args, CompileOptions::default())?.script) + let layout = self.state_layout; + let mut script = self.script.clone(); + script[layout.start..layout.start + layout.len].copy_from_slice(&encoded_state); + Ok(script) + } } pub fn resolve_contract_state_expr<'i>( @@ -151,6 +146,7 @@ fn collect_state_object_entries<'a, 'i>( } Ok(provided) } + #[derive(Clone, Default)] struct LoweringScope { vars: HashMap, diff --git a/silverscript-lang/tests/compiler_tests.rs b/silverscript-lang/tests/compiler_tests.rs index e3a95d08..519ffc0e 100644 --- a/silverscript-lang/tests/compiler_tests.rs +++ b/silverscript-lang/tests/compiler_tests.rs @@ -18,7 +18,7 @@ use kaspa_txscript::{ use silverscript_lang::ast::{Expr, ExprKind, format_contract_ast, parse_contract_ast}; use silverscript_lang::compiler::{ CompileOptions, CompiledContract, CovenantDeclCallOptions, FunctionAbiEntry, FunctionInputAbi, compile_contract, - compile_contract_ast, function_branch_index, materialize_state_script, struct_object, + compile_contract_ast, function_branch_index, struct_object, }; use silverscript_lang::debug_info::{DebugParamBinding, RuntimeBinding, StepKind}; @@ -4432,7 +4432,7 @@ fn compiled_template_parts_and_hash(compiled: &CompiledContract) -> (Vec, Ve } #[test] -fn materialize_state_script_replaces_only_state_segment() { +fn encode_state_matches_replaced_state_segment() { let source = r#" contract A(int initX, byte[2] initY) { int x = initX; @@ -4444,11 +4444,10 @@ fn materialize_state_script_replaces_only_state_segment() { } "#; - let compile_opts = CompileOptions { record_debug_infos: true, ..Default::default() }; - let compiled = compile_contract(source, &[5.into(), vec![1u8, 2u8].into()], compile_opts).expect("compile succeeds"); + let compiled = compile_contract(source, &[5.into(), vec![1u8, 2u8].into()], CompileOptions::default()).expect("compile succeeds"); let state = struct_object(vec![("x", Expr::int(6)), ("y", vec![0x34u8, 0x12u8].into())]); - let materialized_script = materialize_state_script(&compiled, &state).expect("materialize succeeds"); + let materialized_script = compiled.encode_state(&state).expect("encode succeeds"); let mut contract = parse_contract_ast(source).expect("parse succeeds"); let ExprKind::StateObject(entries) = &state.kind else { @@ -4461,31 +4460,12 @@ fn materialize_state_script_replaces_only_state_segment() { let fully_materialized = compile_contract_ast(&contract, &[5.into(), vec![1u8, 2u8].into()], CompileOptions::default()).expect("compile succeeds"); - let layout = compiled.state_layout; assert_eq!(materialized_script, fully_materialized.script); + let layout = compiled.state_layout; assert_eq!(compiled.script[..layout.start], materialized_script[..layout.start]); assert_eq!(compiled.script[layout.start + layout.len..], materialized_script[layout.start + layout.len..]); } -#[test] -fn materialize_state_script_requires_debug_info() { - let source = r#" - contract A(int initX, byte[2] initY) { - int x = initX; - byte[2] y = initY; - - entrypoint function main() { - require(true); - } - } - "#; - - let compiled = compile_contract(source, &[5.into(), vec![1u8, 2u8].into()], CompileOptions::default()).expect("compile succeeds"); - let state = struct_object(vec![("x", Expr::int(6)), ("y", vec![0x34u8, 0x12u8].into())]); - let err = materialize_state_script(&compiled, &state).expect_err("materialize should require debug info"); - assert!(err.to_string().contains("debug-enabled compilation")); -} - fn run_read_input_state_with_template_case( reader_source: &str, reader_constructor_args: &[Expr<'static>], From b6d405b1065c802d23753b413c4ff242fe65a295 Mon Sep 17 00:00:00 2001 From: Manyfestation <240733973+Manyfestation@users.noreply.github.com> Date: Sun, 29 Mar 2026 22:29:51 +0300 Subject: [PATCH 11/11] cargo lock --- Cargo.lock | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index cf1d0787..f33e4e81 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1634,6 +1634,7 @@ dependencies = [ [[package]] name = "kaspa-addresses" version = "1.1.0" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#9b185f80cd2a5f6fbb3ca6c7ae5c3bb6385352ef" dependencies = [ "borsh", "js-sys", @@ -1648,6 +1649,7 @@ dependencies = [ [[package]] name = "kaspa-consensus-core" version = "1.1.0" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#9b185f80cd2a5f6fbb3ca6c7ae5c3bb6385352ef" dependencies = [ "arc-swap", "async-trait", @@ -1684,6 +1686,7 @@ dependencies = [ [[package]] name = "kaspa-core" version = "1.1.0" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#9b185f80cd2a5f6fbb3ca6c7ae5c3bb6385352ef" dependencies = [ "anyhow", "cfg-if", @@ -1703,6 +1706,7 @@ dependencies = [ [[package]] name = "kaspa-hashes" version = "1.1.0" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#9b185f80cd2a5f6fbb3ca6c7ae5c3bb6385352ef" dependencies = [ "blake2b_simd", "blake3", @@ -1722,6 +1726,7 @@ dependencies = [ [[package]] name = "kaspa-math" version = "1.1.0" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#9b185f80cd2a5f6fbb3ca6c7ae5c3bb6385352ef" dependencies = [ "borsh", "faster-hex 0.9.0", @@ -1741,6 +1746,7 @@ dependencies = [ [[package]] name = "kaspa-merkle" version = "1.1.0" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#9b185f80cd2a5f6fbb3ca6c7ae5c3bb6385352ef" dependencies = [ "kaspa-hashes", ] @@ -1748,6 +1754,7 @@ dependencies = [ [[package]] name = "kaspa-muhash" version = "1.1.0" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#9b185f80cd2a5f6fbb3ca6c7ae5c3bb6385352ef" dependencies = [ "kaspa-hashes", "kaspa-math", @@ -1758,6 +1765,7 @@ dependencies = [ [[package]] name = "kaspa-txscript" version = "1.1.0" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#9b185f80cd2a5f6fbb3ca6c7ae5c3bb6385352ef" dependencies = [ "ark-bn254", "ark-ec", @@ -1803,6 +1811,7 @@ dependencies = [ [[package]] name = "kaspa-txscript-errors" version = "1.1.0" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#9b185f80cd2a5f6fbb3ca6c7ae5c3bb6385352ef" dependencies = [ "borsh", "kaspa-hashes", @@ -1813,6 +1822,7 @@ dependencies = [ [[package]] name = "kaspa-utils" version = "1.1.0" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#9b185f80cd2a5f6fbb3ca6c7ae5c3bb6385352ef" dependencies = [ "arc-swap", "async-channel 2.5.0", @@ -1842,6 +1852,7 @@ dependencies = [ [[package]] name = "kaspa-wasm-core" version = "1.1.0" +source = "git+https://github.com/kaspanet/rusty-kaspa?branch=tn12#9b185f80cd2a5f6fbb3ca6c7ae5c3bb6385352ef" dependencies = [ "faster-hex 0.9.0", "hexplay",