diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ce5b89ec0..4ba956c183 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,8 @@ ### Enhancements +* Added DAP-backed transaction script debugging support with `--start-debug-adapter` flag on the `exec` CLI command, `execute_program_with_dap` client method, and offline bootstrap mode for node-less execution ([#1959](https://github.com/0xMiden/miden-client/pull/1959)). + * Made `GrpcNoteTransportClient` connection lazy, deferring it to the first RPC call instead of connecting eagerly at client initialization ([#1970](https://github.com/0xMiden/miden-client/pull/1970)). * Updated the `GrpcClient` to fetch the RPC limits from the node ([#1724](https://github.com/0xMiden/miden-client/pull/1724)) ([#1737](https://github.com/0xMiden/miden-client/pull/1737), [#1809](https://github.com/0xMiden/miden-client/pull/1809)). * Added typed error parsing for node RPC endpoints, enabling programmatic error handling instead of string parsing ([#1734](https://github.com/0xMiden/miden-client/pull/1734)). diff --git a/Cargo.lock b/Cargo.lock index da95c2c456..86d6c7ff37 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2927,6 +2927,7 @@ dependencies = [ "getrandom 0.3.4", "gloo-timers", "hex", + "miden-debug", "miden-node-proto-build", "miden-note-transport-proto-build", "miden-protocol", @@ -2982,6 +2983,7 @@ dependencies = [ "figment", "miden-client", "miden-client-sqlite-store", + "miden-debug", "miette", "predicates", "rand 0.9.2", @@ -3163,6 +3165,77 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "miden-debug" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df04a684eeb96efabc63e2800a01945f7f6e19ffd00d3b49064e85f7e1fac444" +dependencies = [ + "clap", + "futures", + "glob", + "log", + "miden-assembly", + "miden-assembly-syntax", + "miden-core", + "miden-crypto", + "miden-debug-dap", + "miden-debug-engine", + "miden-debug-types", + "miden-mast-package", + "miden-processor", + "miden-protocol", + "miden-thiserror", + "miden-tx", + "num-traits", + "rustc-demangle", + "serde", + "serde_json", + "smallvec", + "socket2 0.5.10", + "tokio", + "tokio-util", + "toml 0.8.23", +] + +[[package]] +name = "miden-debug-dap" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38cd41176322df12836bb4deecd4b619f7cf8239ed7b2c4ac1da7b2830e5199c" +dependencies = [ + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "miden-debug-engine" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e03dd00bd4dab99dbfdec9fd07009811a7e3b3a988e74a28c6ccb735ac34e138" +dependencies = [ + "clap", + "glob", + "log", + "miden-assembly", + "miden-assembly-syntax", + "miden-core", + "miden-debug-dap", + "miden-debug-types", + "miden-mast-package", + "miden-processor", + "miden-thiserror", + "miden-tx", + "num-traits", + "rustc-demangle", + "serde", + "serde_json", + "smallvec", + "socket2 0.5.10", + "toml 0.8.23", +] + [[package]] name = "miden-debug-types" version = "0.22.1" @@ -3741,6 +3814,26 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "miden-thiserror" +version = "1.0.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "183ff8de338956ecfde3a38573241eb7a6f3d44d73866c210e5629c07fa00253" +dependencies = [ + "miden-thiserror-impl", +] + +[[package]] +name = "miden-thiserror-impl" +version = "1.0.59" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee4176a0f2e7d29d2a8ee7e60b6deb14ce67a20e94c3e2c7275cdb8804e1862" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "miden-tx" version = "0.14.3" @@ -6219,6 +6312,7 @@ version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ + "indexmap", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", diff --git a/Cargo.toml b/Cargo.toml index 0ee7c63402..2b12f96129 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,9 @@ miden-node-validator = { version = "0.14" } miden-note-transport-proto-build = { default-features = false, version = "0.2" } miden-remote-prover-client = { default-features = false, features = ["tx-prover"], version = "0.14" } +# Miden debug dependency +miden-debug = { default-features = false, features = ["dap", "std"], version = "0.6" } + # External dependencies anyhow = { default-features = false, version = "1.0" } async-trait = { version = "0.1" } diff --git a/bin/miden-cli/Cargo.toml b/bin/miden-cli/Cargo.toml index 6958911ede..f1fdb1e0c8 100644 --- a/bin/miden-cli/Cargo.toml +++ b/bin/miden-cli/Cargo.toml @@ -16,10 +16,16 @@ version.workspace = true name = "miden-client" path = "src/main.rs" +[features] +dap = ["dep:miden-debug", "miden-client/dap"] +default = ["dap"] +testing = ["miden-client/testing"] + [dependencies] # Workspace dependencies miden-client = { features = ["tonic"], workspace = true } miden-client-sqlite-store = { workspace = true } +miden-debug = { optional = true, workspace = true } # External dependencies clap = { features = ["derive"], version = "4.5" } diff --git a/bin/miden-cli/src/commands/exec.rs b/bin/miden-cli/src/commands/exec.rs index fdd57b9e4f..c1db82f3af 100644 --- a/bin/miden-cli/src/commands/exec.rs +++ b/bin/miden-cli/src/commands/exec.rs @@ -1,8 +1,13 @@ use std::collections::BTreeMap; +use std::fs; +#[cfg(feature = "dap")] +use std::net::SocketAddr; use std::path::PathBuf; use clap::Parser; +use miden_client::account::AccountId; use miden_client::keystore::Keystore; +use miden_client::transaction::{ForeignAccount, TransactionScript}; use miden_client::vm::AdviceInputs; use miden_client::{Client, Felt, Word}; use serde::{Deserialize, Deserializer, Serialize, de}; @@ -47,6 +52,12 @@ pub struct ExecCmd { /// Print the output stack grouped into words #[arg(long, default_value_t = false)] hex_words: bool, + + /// Start a DAP debug adapter server on the given address (e.g. "127.0.0.1:4711") + /// and wait for a DAP client to connect before executing. + #[cfg(feature = "dap")] + #[arg(long = "start-debug-adapter")] + start_debug_adapter: Option, } impl ExecCmd { @@ -62,7 +73,7 @@ impl ExecCmd { )); } - let program = std::fs::read_to_string(script_path)?; + let program = fs::read_to_string(script_path)?; let account_id = get_input_acc_id_by_prefix_or_default(&client, self.account_id.clone()).await?; @@ -77,7 +88,7 @@ impl ExecCmd { )); } - let input_data = std::fs::read_to_string(input_file)?; + let input_data = fs::read_to_string(input_file)?; deserialize_tx_inputs(&input_data)? }, None => vec![], @@ -87,19 +98,13 @@ impl ExecCmd { let tx_script = client.code_builder().compile_tx_script(&program)?; - let result = client - .execute_program(account_id, tx_script, advice_inputs, BTreeMap::new()) - .await; + let output_stack = + self.execute_program(&mut client, account_id, tx_script, advice_inputs).await?; - match result { - Ok(output_stack) => { - println!("Program executed successfully"); - println!("Output stack:"); - self.print_stack(output_stack); - Ok(()) - }, - Err(err) => Err(CliError::Exec(err.into(), "error executing the program".to_string())), - } + println!("Program executed successfully"); + println!("Output stack:"); + self.print_stack(output_stack); + Ok(()) } /// Print the output stack in a human-readable format @@ -130,6 +135,53 @@ impl ExecCmd { } } } + + async fn execute_program( + &self, + client: &mut Client, + account_id: AccountId, + tx_script: TransactionScript, + advice_inputs: AdviceInputs, + ) -> Result<[Felt; 16], CliError> { + let foreign_accounts = BTreeMap::::new(); + + #[cfg(feature = "dap")] + if let Some(addr) = self.start_debug_adapter.as_ref() { + let config = miden_debug::DapConfig::new(addr.to_string()); + let config_handle = config.clone(); + miden_debug::DapConfig::set_global(config); + + let script_path = PathBuf::from(&self.script_path); + loop { + let program = fs::read_to_string(&script_path)?; + let tx_script = client.code_builder().compile_tx_script(&program)?; + + let result = client + .execute_program_with_dap( + account_id, + tx_script, + advice_inputs.clone(), + foreign_accounts.clone(), + ) + .await; + + if config_handle.restart_requested() { + config_handle.reset_restart(); + println!("Recompiling from source and restarting debug session..."); + continue; + } + + return result.map_err(|err| { + CliError::Exec(err.into(), "error executing the program".to_string()) + }); + } + } + + client + .execute_program(account_id, tx_script, advice_inputs, foreign_accounts) + .await + .map_err(|err| CliError::Exec(err.into(), "error executing the program".to_string())) + } } // INPUT FILE PROCESSING diff --git a/bin/miden-cli/src/commands/new_account.rs b/bin/miden-cli/src/commands/new_account.rs index 53170eb40b..c0df4d0102 100644 --- a/bin/miden-cli/src/commands/new_account.rs +++ b/bin/miden-cli/src/commands/new_account.rs @@ -98,6 +98,14 @@ pub struct NewWalletCmd { /// authentication transaction. #[arg(long, default_value_t = false)] pub deploy: bool, + /// Seed local-only state so the wallet can be created and used for execution without a node. + /// Only available when built with the `testing` feature. + #[cfg_attr( + feature = "testing", + arg(long, default_value_t = false, conflicts_with = "deploy") + )] + #[cfg_attr(not(feature = "testing"), arg(skip = false))] + pub offline: bool, } impl NewWalletCmd { @@ -126,6 +134,7 @@ impl NewWalletCmd { &package_paths, self.init_storage_data_path.clone(), self.deploy, + self.offline, ) .await?; @@ -193,6 +202,14 @@ pub struct NewAccountCmd { /// authentication transaction. #[arg(long, default_value_t = false)] pub deploy: bool, + /// Seed local-only state so the account can be created and used for execution without a node. + /// Only available when built with the `testing` feature. + #[cfg_attr( + feature = "testing", + arg(long, default_value_t = false, conflicts_with = "deploy") + )] + #[cfg_attr(not(feature = "testing"), arg(skip = false))] + pub offline: bool, } impl NewAccountCmd { @@ -209,6 +226,7 @@ impl NewAccountCmd { &self.packages, self.init_storage_data_path.clone(), self.deploy, + self.offline, ) .await?; @@ -385,6 +403,7 @@ async fn create_client_account( package_paths: &[PathBuf], init_storage_data_path: Option, deploy: bool, + offline: bool, ) -> Result { if package_paths.is_empty() { return Err(CliError::InvalidArgument(format!( @@ -455,6 +474,14 @@ async fn create_client_account( println!("Using custom authentication component from package (no key generated)."); } + let _ = offline; + + #[cfg(feature = "testing")] + if offline { + client.prepare_offline_bootstrap().await?; + println!("Offline mode seeded default RPC limits and a synthetic genesis header."); + } + client.add_account(&account, false).await?; if deploy { diff --git a/crates/rust-client/Cargo.toml b/crates/rust-client/Cargo.toml index 7233971883..562d1469fc 100644 --- a/crates/rust-client/Cargo.toml +++ b/crates/rust-client/Cargo.toml @@ -24,7 +24,8 @@ ignored = ["getrandom", "prost-types", "tonic-prost"] crate-type = ["lib"] [features] -default = ["std"] +dap = ["dep:miden-debug"] +default = ["dap", "std"] std = [ "dep:tempfile", "dep:tokio", @@ -41,6 +42,7 @@ tonic = [] [dependencies] # Miden dependencies +miden-debug = { optional = true, workspace = true } miden-protocol = { workspace = true } miden-remote-prover-client = { default-features = false, features = ["tx-prover"], workspace = true } miden-standards = { workspace = true } diff --git a/crates/rust-client/src/sync/block_header.rs b/crates/rust-client/src/sync/block_header.rs index 5ed4330e8a..9b53de22c5 100644 --- a/crates/rust-client/src/sync/block_header.rs +++ b/crates/rust-client/src/sync/block_header.rs @@ -5,6 +5,8 @@ use miden_protocol::Word; use miden_protocol::block::{BlockHeader, BlockNumber}; use miden_protocol::crypto::merkle::MerklePath; use miden_protocol::crypto::merkle::mmr::{Forest, InOrderIndex, MmrPeaks, PartialMmr}; +#[cfg(feature = "testing")] +use miden_protocol::transaction::TransactionKernel; use tracing::warn; use crate::rpc::NodeRpcClient; @@ -43,6 +45,31 @@ impl Client { Ok(()) } + /// Seeds the local client state needed to create accounts and execute programs without a node. + /// + /// This stores default RPC limits and inserts a synthetic genesis header if one is not + /// already present in the store. The synthetic header is only intended for local-only + /// execution and debugging. + #[cfg(feature = "testing")] + pub async fn prepare_offline_bootstrap(&mut self) -> Result<(), ClientError> { + let limits = self.store.get_rpc_limits().await?.unwrap_or_default(); + self.store.set_rpc_limits(limits).await?; + self.rpc_api.set_rpc_limits(limits).await; + + if let Some((genesis, _)) = self.store.get_block_header_by_num(BlockNumber::GENESIS).await? + { + self.rpc_api.set_genesis_commitment(genesis.commitment()).await?; + return Ok(()); + } + + let genesis = synthetic_offline_genesis_header(); + let blank_mmr_peaks = MmrPeaks::new(Forest::empty(), vec![]) + .expect("Blank MmrPeaks should not fail to instantiate"); + self.store.insert_block_header(&genesis, blank_mmr_peaks, false).await?; + self.rpc_api.set_genesis_commitment(genesis.commitment()).await?; + Ok(()) + } + /// Fetches from the store the current view of the chain's [`PartialMmr`]. pub async fn get_current_partial_mmr(&self) -> Result { self.store.get_current_partial_mmr().await.map_err(Into::into) @@ -86,6 +113,11 @@ impl Client { } } +#[cfg(feature = "testing")] +fn synthetic_offline_genesis_header() -> BlockHeader { + BlockHeader::mock(BlockNumber::GENESIS, None, None, &[], TransactionKernel.to_commitment()) +} + // UTILS // -------------------------------------------------------------------------------------------- @@ -156,12 +188,16 @@ pub(crate) async fn fetch_block_header( #[cfg(test)] mod tests { + use miden_protocol::block::account_tree::AccountTree; use miden_protocol::block::{BlockHeader, BlockNumber}; use miden_protocol::crypto::merkle::MerklePath; use miden_protocol::crypto::merkle::mmr::{Forest, InOrderIndex, Mmr, PartialMmr}; + use miden_protocol::crypto::merkle::smt::Smt; use miden_protocol::transaction::TransactionKernel; use miden_protocol::{Felt, Word}; + #[cfg(feature = "testing")] + use super::synthetic_offline_genesis_header; use super::{adjust_merkle_path_for_forest, authenticated_block_nodes}; fn word(n: u64) -> Word { @@ -281,4 +317,14 @@ mod tests { assert_eq!(nodes[0], (InOrderIndex::from_leaf_pos(4), block_header.commitment())); assert_eq!(&nodes[1..], path_nodes.as_slice()); } + + #[test] + #[cfg(feature = "testing")] + fn synthetic_offline_genesis_header_uses_mock_genesis() { + let genesis = synthetic_offline_genesis_header(); + + assert_eq!(genesis.block_num(), BlockNumber::GENESIS); + assert_eq!(genesis.account_root(), AccountTree::::default().root()); + assert_eq!(genesis.tx_kernel_commitment(), TransactionKernel.to_commitment()); + } } diff --git a/crates/rust-client/src/transaction/mod.rs b/crates/rust-client/src/transaction/mod.rs index d7c50224d3..060225c263 100644 --- a/crates/rust-client/src/transaction/mod.rs +++ b/crates/rust-client/src/transaction/mod.rs @@ -478,36 +478,30 @@ where advice_inputs: AdviceInputs, foreign_accounts: BTreeMap, ) -> Result<[Felt; 16], ClientError> { - let (fpi_block_number, foreign_account_inputs) = - self.retrieve_foreign_account_inputs(foreign_accounts).await?; - - let block_ref = if let Some(block_number) = fpi_block_number { - block_number - } else { - self.get_sync_height().await? - }; - - let account_record = self - .store - .get_account(account_id) - .await? - .ok_or(ClientError::AccountDataNotFound(account_id))?; - - let account: Account = account_record.try_into()?; - - let data_store = ClientDataStore::new(self.store.clone(), self.rpc_api.clone()); - - data_store.register_foreign_account_inputs(foreign_account_inputs.iter().cloned()); + let (data_store, block_ref) = + self.prepare_program_execution(account_id, foreign_accounts).await?; - // Ensure code is loaded on MAST store - data_store.mast_store().load_account_code(account.code()); + Ok(self + .build_executor(&data_store)? + .execute_tx_view_script(account_id, block_ref, tx_script, advice_inputs) + .await?) + } - for fpi_account in &foreign_account_inputs { - data_store.mast_store().load_account_code(fpi_account.code()); - } + /// Executes the provided transaction script with a DAP debug adapter listening for + /// connections, allowing interactive debugging via any DAP-compatible client. + #[cfg(feature = "dap")] + pub async fn execute_program_with_dap( + &mut self, + account_id: AccountId, + tx_script: TransactionScript, + advice_inputs: AdviceInputs, + foreign_accounts: BTreeMap, + ) -> Result<[Felt; 16], ClientError> { + let (data_store, block_ref) = + self.prepare_program_execution(account_id, foreign_accounts).await?; Ok(self - .build_executor(&data_store)? + .build_dap_executor(&data_store)? .execute_tx_view_script(account_id, block_ref, tx_script, advice_inputs) .await?) } @@ -797,6 +791,45 @@ where Ok((Some(block_num), return_foreign_account_inputs)) } + /// Prepares the data store and block reference for program execution. + /// + /// This is shared setup for both `execute_program` and `execute_program_with_dap`. + async fn prepare_program_execution( + &mut self, + account_id: AccountId, + foreign_accounts: BTreeMap, + ) -> Result<(ClientDataStore, BlockNumber), ClientError> { + let (fpi_block_number, foreign_account_inputs) = + self.retrieve_foreign_account_inputs(foreign_accounts).await?; + + let block_ref = if let Some(block_number) = fpi_block_number { + block_number + } else { + self.get_sync_height().await? + }; + + let account_record = self + .store + .get_account(account_id) + .await? + .ok_or(ClientError::AccountDataNotFound(account_id))?; + + let account: Account = account_record.try_into()?; + + let data_store = ClientDataStore::new(self.store.clone(), self.rpc_api.clone()); + + data_store.register_foreign_account_inputs(foreign_account_inputs.iter().cloned()); + + // Ensure code is loaded on MAST store + data_store.mast_store().load_account_code(account.code()); + + for fpi_account in &foreign_account_inputs { + data_store.mast_store().load_account_code(fpi_account.code()); + } + + Ok((data_store, block_ref)) + } + /// Creates a transaction executor configured with the client's runtime options, /// authenticator, and source manager. pub(crate) fn build_executor<'store, 'auth, STORE: DataStore + Sync>( @@ -811,6 +844,20 @@ where Ok(executor) } + + /// Creates a transaction executor configured for DAP (Debug Adapter Protocol) debugging. + #[cfg(feature = "dap")] + pub(crate) fn build_dap_executor<'store, 'auth, STORE: DataStore + Sync>( + &'auth self, + data_store: &'store STORE, + ) -> Result< + TransactionExecutor<'store, 'auth, STORE, AUTH, miden_debug::DapExecutor>, + TransactionExecutorError, + > { + Ok(self + .build_executor(data_store)? + .with_program_executor::()) + } } // HELPERS diff --git a/docs/external/src/rust-client/debugging.md b/docs/external/src/rust-client/debugging.md new file mode 100644 index 0000000000..d8a1f411ff --- /dev/null +++ b/docs/external/src/rust-client/debugging.md @@ -0,0 +1,96 @@ +--- +title: DAP Debugging +sidebar_position: 7 +--- + +# DAP Debugging + +The Miden client supports interactive debugging via the [Debug Adapter Protocol (DAP)](https://microsoft.github.io/debug-adapter-protocol/). You can debug both raw Miden Assembly scripts and Rust programs compiled to Miden via `midenc`. This lets you step through execution, set breakpoints, and inspect stack/memory state using any DAP-compatible client (e.g. VS Code, the `miden-debug` TUI). + +## Feature Flags + +Two feature flags control debugging support: + +| Feature | Crate | What it enables | +|---------|-------|-----------------| +| `dap` | `miden-client`, `miden-client-cli` | Compiles in DAP support (`execute_program_with_dap`, `--start-debug-adapter` CLI flag). **Enabled by default.** | +| `testing` | `miden-client-cli` | Enables the `--offline` flag on `new-wallet`/`new-account` commands for node-less account creation. Not available in production builds. | + +### Building with features + +```bash +# Default build (DAP enabled) +cargo build -p miden-client-cli + +# With offline mode for testing +cargo build -p miden-client-cli --features testing + +# Without DAP (smaller binary) +cargo build -p miden-client-cli --no-default-features +``` + +If you build from source with default features disabled, include the `dap` feature to use +`--start-debug-adapter`. + +## Quick Start + +### 1. Create an account + +With a running node: + +```bash +miden-client init +miden-client new-wallet +miden-client sync +``` + +Or without a node (requires `testing` feature): + +```bash +miden-client init +miden-client new-wallet --offline +``` + +### 2. Write a test script + +Create a file `test_debug.masm`: + +``` +begin + push.1.2 + add + push.3 + mul +end +``` + +### 3. Start the DAP server + +```bash +miden-client exec \ + --script-path test_debug.masm \ + --start-debug-adapter 127.0.0.1:4711 +``` + +The client will compile the script and wait for a DAP client to connect before executing. + +### 4. Connect a debugger + +In a separate terminal, connect the `miden-debug` TUI: + +```bash +miden-debug --dap-connect 127.0.0.1:4711 +``` + +You can now step through execution, inspect the stack, and set breakpoints. + + +## How it Works + +When `--start-debug-adapter` is passed: + +1. The client compiles the transaction script normally. +2. Instead of using the default `FastProcessor`, it creates a `DapExecutor` (from the `miden-debug` crate) which implements the `ProgramExecutor` trait. +3. The `DapExecutor` binds a TCP listener on the specified address and waits for a DAP client connection. +4. Once connected, the DAP client controls execution (continue, step, breakpoints, inspect state). +5. If the DAP client requests a restart, the client recompiles the script from disk and re-executes — enabling an edit-and-continue workflow.