From 34c51ceada0ae3cff40feede8d71519006d0d3e7 Mon Sep 17 00:00:00 2001 From: tacryt-socryp <1377557+tacryt-socryp@users.noreply.github.com> Date: Sat, 25 Oct 2025 15:39:02 -0500 Subject: [PATCH 01/99] nockchain: fix save interval to 120s --- crates/nockapp/src/kernel/boot.rs | 70 +++++++++++++++++++-- crates/nockapp/src/nockapp/mod.rs | 6 +- crates/nockchain-libp2p-io/src/p2p_state.rs | 2 +- crates/nockchain/src/lib.rs | 5 +- crates/nockchain/src/setup.rs | 3 +- 5 files changed, 74 insertions(+), 12 deletions(-) diff --git a/crates/nockapp/src/kernel/boot.rs b/crates/nockapp/src/kernel/boot.rs index 391c2a4a9..de25dc21a 100644 --- a/crates/nockapp/src/kernel/boot.rs +++ b/crates/nockapp/src/kernel/boot.rs @@ -24,7 +24,8 @@ use crate::save::SaveableCheckpoint; use crate::utils::error::{CrownError, ExternalError}; use crate::{default_data_dir, AtomExt, NockApp}; -const DEFAULT_SAVE_INTERVAL: u64 = 120000; +pub const DEFAULT_SAVE_INTERVAL: u64 = 120000; +const DEFAULT_SAVE_INTERVAL_STR: &str = "120000"; const DEFAULT_LOG_FILTER: &str = "info"; #[derive(Debug, Clone, ValueEnum)] @@ -109,7 +110,12 @@ pub struct Cli { #[command(flatten)] pub trace_opts: TraceOpts, - #[arg(long, help = "Set the save interval for checkpoints (in ms)")] + #[arg( + long, + help = "Set the save interval for checkpoints (in ms). Use 'none' or '0' to disable periodic saves.", + default_value = DEFAULT_SAVE_INTERVAL_STR, + value_parser = parse_save_interval + )] pub save_interval: Option, #[arg(long, help = "Control colored output", value_enum, default_value_t = ColorChoice::Auto)] @@ -136,6 +142,60 @@ pub struct Cli { pub stack_size: NockStackSize, } +impl Cli { + fn normalized_save_interval(&self) -> Option { + self.save_interval + .and_then(|value| if value == 0 { None } else { Some(value) }) + } +} + +fn parse_save_interval(input: &str) -> Result { + let trimmed = input.trim(); + + if trimmed.eq_ignore_ascii_case("none") { + Ok(0) + } else { + let value = trimmed + .parse::() + .map_err(|e| format!("Invalid save interval '{trimmed}': {e}"))?; + Ok(value) + } +} + +#[cfg(test)] +mod tests { + use super::parse_save_interval; + + #[test] + fn parse_save_interval_none_variants() { + assert_eq!(parse_save_interval("none").unwrap(), 0); + assert_eq!(parse_save_interval("NoNe").unwrap(), 0); + assert_eq!(parse_save_interval("0").unwrap(), 0); + assert_eq!(parse_save_interval(" 0 ").unwrap(), 0); + } + + #[test] + fn parse_save_interval_positive_values() { + assert_eq!(parse_save_interval("1").unwrap(), 1); + assert_eq!(parse_save_interval(" 120000 ").unwrap(), 120000); + } + + #[test] + fn parse_save_interval_rejects_invalid() { + assert!(parse_save_interval("abc").is_err()); + } + + #[test] + fn normalized_save_interval_filters_zero() { + let mut cli = super::default_boot_cli(false); + cli.save_interval = Some(0); + assert_eq!(cli.normalized_save_interval(), None); + + cli.save_interval = Some(5000); + assert_eq!(cli.normalized_save_interval(), Some(5000)); + } +} + /// Result of setting up a NockApp pub enum SetupResult { /// A fully initialized NockApp @@ -355,6 +415,10 @@ pub async fn setup_( info!("kernel: starting"); debug!("kernel: pma directory: {:?}", pma_dir); debug!("kernel: snapshots directory: {:?}", jams_dir); + info!("NockApp boot cli: {:?}", cli); + let save_interval = cli + .normalized_save_interval() + .map(std::time::Duration::from_millis); let kernel_f = async |checkpoint| { let kernel: Kernel = match cli.stack_size { @@ -397,8 +461,6 @@ pub async fn setup_( res }; - let save_interval = cli.save_interval.map(std::time::Duration::from_millis); - let app: NockApp = NockApp::new(kernel_f, &jams_dir, save_interval).await?; if let Some(export_path) = cli.export_state_jam.clone() { diff --git a/crates/nockapp/src/nockapp/mod.rs b/crates/nockapp/src/nockapp/mod.rs index 5b95e2a55..440c8ebf9 100644 --- a/crates/nockapp/src/nockapp/mod.rs +++ b/crates/nockapp/src/nockapp/mod.rs @@ -25,7 +25,7 @@ use tokio::select; use tokio::sync::{broadcast, mpsc, Mutex, OwnedMutexGuard}; use tokio::time::{interval_at, Duration, Instant, Interval}; use tokio_util::task::TaskTracker; -use tracing::{debug, error, instrument, trace, warn}; +use tracing::{debug, error, info, instrument, trace, warn}; use wire::WireRepr; use crate::kernel::form::Kernel; @@ -171,14 +171,14 @@ impl NockApp { // let tasks = Arc::new(TaskJoinSet::new()); let tasks = TaskTracker::new(); let save_interval = save_interval_duration.map(|duration| { - debug!("save_interval_duration: {:?}", duration); + info!("Nockapp save interval duration: {:?}", duration); let first_tick_at = Instant::now() + duration; let mut interval = interval_at(first_tick_at, duration); interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); // important so we don't stack ticks when lagging interval }); if save_interval.is_none() { - debug!("save interval disabled; periodic saves off"); + info!("Nockapp save interval disabled; periodic saves off"); } let exit_status = AtomicBool::new(false); let abort_immediately = AtomicBool::new(false); diff --git a/crates/nockchain-libp2p-io/src/p2p_state.rs b/crates/nockchain-libp2p-io/src/p2p_state.rs index 5fc48f0f4..d43b6cf8e 100644 --- a/crates/nockchain-libp2p-io/src/p2p_state.rs +++ b/crates/nockchain-libp2p-io/src/p2p_state.rs @@ -9,7 +9,7 @@ use nockapp::noun::slab::NounSlab; use nockapp::NockAppError; use nockvm::noun::Noun; use rand::prelude::SliceRandom; -use tracing::{debug, info, trace, warn}; +use tracing::{debug, info, trace}; use crate::messages::NockchainDataRequest; use crate::metrics::NockchainP2PMetrics; diff --git a/crates/nockchain/src/lib.rs b/crates/nockchain/src/lib.rs index 5d5dc6b3e..71e40175e 100644 --- a/crates/nockchain/src/lib.rs +++ b/crates/nockchain/src/lib.rs @@ -6,7 +6,6 @@ use std::error::Error; use std::fs; use std::path::Path; -use bitcoincore_rpc::bitcoin::Block; pub use config::NockchainCli; use libp2p::identity::Keypair; use libp2p::multiaddr::Multiaddr; @@ -25,7 +24,7 @@ use nockvm_macros::tas; use tracing::{debug, info, instrument}; use crate::mining::{MiningKeyConfig, MiningPkhConfig}; -use crate::setup::{fakenet_blockchain_constants, BlockchainConstants, Seconds}; +use crate::setup::fakenet_blockchain_constants; /// Module for handling driver initialization signals pub mod driver_init { @@ -213,7 +212,7 @@ pub async fn init_with_kernel( let keypair_path = Path::new(config::IDENTITY_PATH); load_keypair(keypair_path, cli.no_new_peer_id)? }; - eprintln!("allowed_peers_path: {:?}", cli.allowed_peers_path); + info!("allowed_peers_path: {:?}", cli.allowed_peers_path); let allowed = cli.allowed_peers_path.as_ref().map(|path| { let contents = fs::read_to_string(path).expect("failed to read allowed peers file: {}"); let peer_ids: Vec = contents diff --git a/crates/nockchain/src/setup.rs b/crates/nockchain/src/setup.rs index 87ba20f54..800874249 100644 --- a/crates/nockchain/src/setup.rs +++ b/crates/nockchain/src/setup.rs @@ -10,6 +10,7 @@ use nockapp::{AtomExt, Bytes, NockApp, NockAppError, ToBytes}; use nockvm::noun::{Atom, Noun, NounAllocator, D, T}; use nockvm_macros::tas; use noun_serde::NounEncode; +use tracing::info; use crate::NounSlab; @@ -232,7 +233,7 @@ impl BlockchainConstants { pub fn with_genesis_target_atom_bex(mut self, bex: u128) -> Self { let difficulty = UBig::from((1 << bex) as u128); self.genesis_target_atom = self.max_target_atom.clone() / difficulty; - eprintln!("Genesis target atom set to {}", self.genesis_target_atom); + info!("Genesis target atom set to {}", self.genesis_target_atom); self } From 240c0e97827e89b1b219519ada6a1dcfafb70dc7 Mon Sep 17 00:00:00 2001 From: Sigilante <57601680+sigilante@users.noreply.github.com> Date: Mon, 27 Oct 2025 11:19:34 -0500 Subject: [PATCH 02/99] Add Linux memory overcommit setup instructions (#67) --- README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/README.md b/README.md index e8c291592..668d4ac85 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,22 @@ Copy the example environment file and rename it to `.env`: cp .env_example .env ``` +### For Linux + +Linux users **must** manually set their memory overcommit status: + +``` +# Enable always-overcommit: +echo 'vm.overcommit_memory=1' | sudo tee /etc/sysctl.d/99-overcommit.conf + +# Reload kernel parameters: +sudo sysctl --system +# or: +sudo sysctl -p /etc/sysctl.d/99-overcommit.conf +``` + +## Install Hoon Compiler + Install `hoonc`, the Hoon compiler: ``` From 87f7e5f03cc9a2aef07956c82994a5974519ccf4 Mon Sep 17 00:00:00 2001 From: tacryt-socryp <1377557+tacryt-socryp@users.noreply.github.com> Date: Mon, 27 Oct 2025 13:52:26 -0500 Subject: [PATCH 03/99] nockchain: add --fast-sync option --- crates/nockapp/src/kernel/boot.rs | 4 ++-- crates/nockchain-libp2p-io/src/driver.rs | 29 ++++++++++++++++++++---- crates/nockchain/src/config.rs | 3 +++ crates/nockchain/src/lib.rs | 1 + 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/crates/nockapp/src/kernel/boot.rs b/crates/nockapp/src/kernel/boot.rs index de25dc21a..95cebd0cd 100644 --- a/crates/nockapp/src/kernel/boot.rs +++ b/crates/nockapp/src/kernel/boot.rs @@ -330,10 +330,10 @@ fn init_with_default_filter LookupSpan<'a> } else { reg.with(tracy).init(); } - info!("Tracy tracing is enabled"); + debug!("Tracy tracing is enabled"); return; } else { - info!("Tracy tracing is disabled"); + debug!("Tracy tracing is disabled"); } reg.init(); } diff --git a/crates/nockchain-libp2p-io/src/driver.rs b/crates/nockchain-libp2p-io/src/driver.rs index 37c4011ad..f54e4b164 100644 --- a/crates/nockchain-libp2p-io/src/driver.rs +++ b/crates/nockchain-libp2p-io/src/driver.rs @@ -31,6 +31,7 @@ use nockapp::wire::{Wire, WireRepr}; use nockapp::{AtomExt, NockAppError, NounExt}; use nockvm::noun::{Atom, Noun, D, T}; use nockvm_macros::tas; +use rand::rng; use rand::seq::SliceRandom; use tokio::sync::{mpsc, Mutex, MutexGuard}; use tokio::time::{Duration, MissedTickBehavior}; @@ -153,6 +154,7 @@ pub fn make_libp2p_driver( initial_peers: &[Multiaddr], force_peers: &[Multiaddr], prune_inbound_size: Option, + fast_sync: bool, equix_builder: equix::EquiXBuilder, chain_interval: Duration, init_complete_tx: Option>, @@ -248,7 +250,7 @@ pub fn make_libp2p_driver( let state_guard = Arc::clone(&driver_state); // Clone the Arc, not the P2P state let metrics_clone = metrics.clone(); join_set.spawn("handle_effect".to_string(), async move { - handle_effect(noun_slab, swarm_tx_clone, equix_builder_clone, local_peer_id, connected_peers, state_guard, metrics_clone).await + handle_effect(noun_slab, swarm_tx_clone, equix_builder_clone, local_peer_id, connected_peers, fast_sync, state_guard, metrics_clone).await }); }, Some(event) = swarm.next() => { @@ -486,6 +488,7 @@ async fn handle_effect( equix_builder: equix::EquiXBuilder, local_peer_id: PeerId, connected_peers: Vec, + fast_sync: bool, driver_state: Arc>, metrics: Arc, ) -> Result<(), NockAppError> { @@ -533,6 +536,8 @@ async fn handle_effect( let request_body = request_cell.tail().as_cell()?; let request_type = request_body.head().as_direct()?; + let mut is_limited_request = false; + let target_peers = if request_type.data() == tas!(b"block") { let block_cell = request_body.tail().as_cell()?; if block_cell.head().eq_bytes(b"elders") { @@ -543,9 +548,11 @@ async fn handle_effect( if let Ok(peer_id) = PeerId::from_bytes(&bytes) { vec![peer_id] } else { + is_limited_request = fast_sync; connected_peers.clone() } } else { + is_limited_request = fast_sync; connected_peers.clone() } } else { @@ -558,6 +565,7 @@ async fn handle_effect( if request_type.data() == tas!(b"raw-tx") { if let Ok(raw_tx_cell) = request_body.tail().as_cell() { if raw_tx_cell.head().eq_bytes(b"by-id") { + is_limited_request = fast_sync; trace!("Requesting raw transaction by ID, removing ID from seen set"); let tx_id = tip5_hash_to_base58_stack(&mut noun_slab, raw_tx_cell.tail())?; let mut state_guard = driver_state.clone().lock_owned().await; @@ -566,9 +574,16 @@ async fn handle_effect( } } - debug!("Sending request to {} peers", target_peers.len()); - - for peer_id in target_peers { + let request_peers: Vec<_> = if is_limited_request { + let mut rng = rng(); + let mut request_peers = target_peers.clone(); + request_peers.shuffle(&mut rng); + request_peers.into_iter().take(2).collect() + } else { + target_peers.clone() + }; + debug!("Sending request to {} peers", request_peers.len()); + for peer_id in request_peers { let local_peer_id_clone = local_peer_id; let mut equix_builder_clone = equix_builder.clone(); let request = NockchainRequest::new_request( @@ -1890,6 +1905,7 @@ mod tests { EquiXBuilder::new(), PeerId::random(), // local peer ID (not relevant for this test) vec![], // connected peers (not relevant for this test) + false, Arc::new(Mutex::new(P2PState::new( metrics.clone(), LIBP2P_CONFIG.seen_tx_clear_interval, @@ -1960,6 +1976,7 @@ mod tests { EquiXBuilder::new(), PeerId::random(), // local peer ID (not relevant for this test) vec![], // connected peers (not relevant for this test) + false, state_arc.clone(), metrics, ) @@ -2068,6 +2085,7 @@ mod tests { EquiXBuilder::new(), PeerId::random(), // local peer ID (not relevant for this test) vec![], // connected peers (not relevant for this test) + false, state_arc.clone(), metrics, ) @@ -2210,6 +2228,7 @@ mod tests { EquiXBuilder::new(), PeerId::random(), // local peer ID (not relevant for this test) vec![], // connected peers (not relevant for this test) + false, state_arc.clone(), metrics, ) @@ -2333,6 +2352,7 @@ mod tests { EquiXBuilder::new(), PeerId::random(), // local peer ID (not relevant for this test) vec![], // connected peers (not relevant for this test) + false, state_arc_clone, metrics, ) @@ -2383,6 +2403,7 @@ mod tests { EquiXBuilder::new(), PeerId::random(), // local peer ID (not relevant for this test) vec![], // connected peers (not relevant for this test) + false, state_arc_clone, metrics, ) diff --git a/crates/nockchain/src/config.rs b/crates/nockchain/src/config.rs index e1d332afd..2bc79dddd 100644 --- a/crates/nockchain/src/config.rs +++ b/crates/nockchain/src/config.rs @@ -140,6 +140,8 @@ pub struct NockchainCli { pub bind_public_grpc_addr: std::net::SocketAddr, #[arg(long, default_value = "5555")] pub bind_private_grpc_port: u16, + #[arg(long, default_value = "false")] + pub fast_sync: bool, } impl NockchainCli { @@ -249,6 +251,7 @@ mod tests { fakenet_coinbase_timelock_min: None, bind_public_grpc_addr: "127.0.0.1:5555".parse().unwrap(), bind_private_grpc_port: 5555, + fast_sync: false, } } diff --git a/crates/nockchain/src/lib.rs b/crates/nockchain/src/lib.rs index 71e40175e..37d707f00 100644 --- a/crates/nockchain/src/lib.rs +++ b/crates/nockchain/src/lib.rs @@ -486,6 +486,7 @@ pub async fn init_with_kernel( &initial_peer_multiaddrs, &force_peers, prune_inbound, + cli.fast_sync, equix_builder, config::CHAIN_INTERVAL, Some(libp2p_init_tx), From 23c172552f65023ceb47afc85703c978b943b562 Mon Sep 17 00:00:00 2001 From: tacryt-socryp <1377557+tacryt-socryp@users.noreply.github.com> Date: Mon, 27 Oct 2025 16:51:29 -0500 Subject: [PATCH 04/99] nockchain-libp2p: properly handle liar effect noun decoding --- crates/nockchain-libp2p-io/src/driver.rs | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/nockchain-libp2p-io/src/driver.rs b/crates/nockchain-libp2p-io/src/driver.rs index f54e4b164..8cf86be40 100644 --- a/crates/nockchain-libp2p-io/src/driver.rs +++ b/crates/nockchain-libp2p-io/src/driver.rs @@ -599,7 +599,13 @@ async fn handle_effect( } EffectType::LiarPeer => { let effect_cell = unsafe { noun_slab.root().as_cell()? }; - let peer_id_atom = effect_cell.tail().as_atom().map_err(|_| { + let liar_peer_cell = effect_cell.tail().as_cell().map_err(|_| { + NockAppError::IoError(std::io::Error::new( + std::io::ErrorKind::Other, + "Expected peer ID cell in liar-peer effect", + )) + })?; + let peer_id_atom = liar_peer_cell.head().as_atom().map_err(|_| { NockAppError::IoError(std::io::Error::new( std::io::ErrorKind::Other, "Expected peer ID atom in liar-peer effect", @@ -633,7 +639,14 @@ async fn handle_effect( } EffectType::LiarBlockId => { let effect_cell = unsafe { noun_slab.root().as_cell()? }; - let block_id = effect_cell.tail(); + let liar_block_cell = effect_cell.tail().as_cell().map_err(|_| { + NockAppError::IoError(std::io::Error::new( + std::io::ErrorKind::Other, + "Expected block ID cell in liar-block-id effect", + )) + })?; + + let block_id = liar_block_cell.head(); // Add the bad block ID let mut state_guard = driver_state.lock().await; @@ -1885,9 +1898,10 @@ mod tests { .expect("Failed to create liar-peer atom"); let peer_id_atom = Atom::from_value(&mut effect_slab, peer_id_base58) .expect("Failed to create peer ID atom"); + let reason_atom = make_tas(&mut effect_slab, "bad peer"); let effect = T( &mut effect_slab, - &[liar_peer_atom.as_noun(), peer_id_atom.as_noun()], + &[liar_peer_atom.as_noun(), peer_id_atom.as_noun(), reason_atom.as_noun()], ); effect_slab.set_root(effect); let metrics = Arc::new( @@ -2203,10 +2217,11 @@ mod tests { // Copy the bad block ID tuple to the effect slab let bad_block_id_in_effect = T(&mut effect_slab, &[D(1), D(2), D(3), D(4), D(5)]); + let reason_atom = make_tas(&mut effect_slab, "bad block"); // Build the noun structure: [%liar-block-id bad-block-id] let effect = T( &mut effect_slab, - &[liar_block_id_atom.as_noun(), bad_block_id_in_effect], + &[liar_block_id_atom.as_noun(), bad_block_id_in_effect, reason_atom.as_noun()], ); effect_slab.set_root(effect); println!("Created liar-block-id effect"); From ea4afc9b0959ec5c2f390fb57e5780f29596f406 Mon Sep 17 00:00:00 2001 From: tacryt-socryp <1377557+tacryt-socryp@users.noreply.github.com> Date: Tue, 28 Oct 2025 12:05:09 -0500 Subject: [PATCH 05/99] nockvm: explicitly assert that we only support little-endian --- crates/nockvm/rust/nockvm/src/noun.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/nockvm/rust/nockvm/src/noun.rs b/crates/nockvm/rust/nockvm/src/noun.rs index d3c34520c..941649246 100644 --- a/crates/nockvm/rust/nockvm/src/noun.rs +++ b/crates/nockvm/rust/nockvm/src/noun.rs @@ -6,11 +6,17 @@ use either::{Either, Left, Right}; use ibig::{Stack, UBig}; use intmap::IntMap; use nockvm_macros::tas; +use static_assertions::assert_cfg; use crate::mem::{word_size_of, NockStack}; crate::gdb!(); +assert_cfg!( + target_endian = "little", + "nockvm will not execute correctly on non-little-endian systems" +); + /** Tag for a direct atom. */ pub(crate) const DIRECT_TAG: u64 = 0x0; From 0f1a127f95377e03fb7bbf6f4d3bfb57dd87e468 Mon Sep 17 00:00:00 2001 From: tacryt-socryp <1377557+tacryt-socryp@users.noreply.github.com> Date: Tue, 28 Oct 2025 18:38:16 -0500 Subject: [PATCH 06/99] wallet: smoother tx building --- hoon/apps/wallet/lib/tx-builder-v1.hoon | 32 ++++++++++++------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/hoon/apps/wallet/lib/tx-builder-v1.hoon b/hoon/apps/wallet/lib/tx-builder-v1.hoon index 1b7019f4a..8f7464d82 100644 --- a/hoon/apps/wallet/lib/tx-builder-v1.hoon +++ b/hoon/apps/wallet/lib/tx-builder-v1.hoon @@ -33,17 +33,17 @@ |= [a=nnote:v0:transact b=nnote:v0:transact] (gth assets.a assets.b) (create-spends-0 notes) - :: If all notes are v1 + :: If all notes are v1 ?: (levy notes |=(=nnote:transact ?=(@ -.nnote))) =/ notes=(list nnote-1:v1:transact) %+ turn notes |= =nnote:transact ?> ?=(@ -.nnote) - =. notes - %+ sort notes - |= [a=nnote:v1:transact b=nnote:v1:transact] - (gth assets.a assets.b) nnote + =. notes + %+ sort notes + |= [a=nnote-1:v1:transact b=nnote-1:v1:transact] + (gth assets.a assets.b) (create-spends-1 notes) :: :: I don't want to do this, but the fact that we're constrained to a single master seckey @@ -85,16 +85,15 @@ spends =/ fee-portion=@ ?: =(0 fee.remaining) 0 (min fee.remaining available-for-fee) - ?: &(=(0 gift-portion) =(0 fee-portion)) + =/ refund=@ (sub assets.note (add gift-portion fee-portion)) + :: skip if no seeds would be created (protocol requires >=1 seed) + ?: &(=(0 gift-portion) =(0 refund)) [spends remaining] =/ [new-gift-remaining=@ new-fee-remaining=@] :- (sub gift.remaining gift-portion) (sub fee.remaining fee-portion) - =/ refund=@ (sub assets.note (add gift-portion fee-portion)) - ?: ?& =(0 gift-portion) - =(0 refund) - == - [spends remaining] + ~| "assets in must equal gift + fee + refund" + ?> =(assets.note (add gift-portion (add fee-portion refund))) =/ =seeds:v1:transact %- z-silt:zo =| seeds=(list seed:v1:transact) @@ -176,16 +175,15 @@ spends =/ fee-portion=@ ?: =(0 fee.remaining) 0 (min fee.remaining available-for-fee) - ?: &(=(0 gift-portion) =(0 fee-portion)) + =/ refund=@ (sub assets.note (add gift-portion fee-portion)) + :: skip if no seeds would be created (protocol requires >=1 seed) + ?: &(=(0 gift-portion) =(0 refund)) [spends remaining] =/ [new-gift-remaining=@ new-fee-remaining=@] :- (sub gift.remaining gift-portion) (sub fee.remaining fee-portion) - =/ refund=@ (sub assets.note (add gift-portion fee-portion)) - ?: ?& =(0 gift-portion) - =(0 refund) - == - [spends remaining] + ~| "assets in must equal gift + fee + refund" + ?> =(assets.note (add gift-portion (add fee-portion refund))) =/ =seeds:v1:transact %- z-silt:zo =| seeds=(list seed:v1:transact) From 8cf1c79c7a15a2602a1aa2a0763761ca1e81e384 Mon Sep 17 00:00:00 2001 From: tacryt-socryp <1377557+tacryt-socryp@users.noreply.github.com> Date: Tue, 28 Oct 2025 19:10:20 -0500 Subject: [PATCH 07/99] nockchain: remove deprecated mining-pubkey command-line arguments --- .env_example | 1 - Makefile | 7 +- README.md | 35 ++++---- .../src/tx_engine/common/mod.rs | 2 +- crates/nockchain/src/config.rs | 89 +------------------ crates/nockchain/src/lib.rs | 11 +-- crates/nockchain/src/mining.rs | 16 +--- scripts/run_nockchain_miner.sh | 4 +- scripts/run_nockchain_miner_fakenet.sh | 4 +- scripts/run_nockchain_node_fakenet.sh | 2 +- 10 files changed, 36 insertions(+), 135 deletions(-) diff --git a/.env_example b/.env_example index e3d44ee85..ac2399118 100644 --- a/.env_example +++ b/.env_example @@ -1,4 +1,3 @@ RUST_LOG=info,nockchain=debug,nockchain_libp2p_io=info,libp2p=info,libp2p_quic=info MINIMAL_LOG_FORMAT=true -MINING_PUBKEY=EHmKL2U3vXfS5GYAY5aVnGdukfDWwvkQPCZXnjvZVShsSQi3UAuA4tQQpVwGJMzc9FfpTY8pLDkqhBGfWutiF4prrCktUH9oAWJxkXQBzAavKDc95NR3DjmYwnnw8GuugnK MINING_PKH=9yPePjfWAdUnzaQKyxcRXKRa5PpUzKKEwtpECBZsUYt9Jd7egSDEWoV diff --git a/Makefile b/Makefile index 3476df08c..8e0fdc67d 100644 --- a/Makefile +++ b/Makefile @@ -8,7 +8,7 @@ include .env export RUST_BACKTRACE ?= full export RUST_LOG ?= info,nockchain=info,nockchain_libp2p_io=info,libp2p=info,libp2p_quic=info export MINIMAL_LOG_FORMAT ?= true -export MINING_PUBKEY ?= 2qwq9dQRZfpFx8BDicghpMRnYGKZsZGxxhh9m362pzpM9aeo276pR1yHZPS41y3CW3vPKxeYM8p8fzZS8GXmDGzmNNCnVNekjrSYogqfEFMqwhHh5iCjaKPaDTwhupWqiXj6 +export MINING_PKH ?= 9yPePjfWAdUnzaQKyxcRXKRa5PpUzKKEwtpECBZsUYt9Jd7egSDEWoV export .PHONY: build @@ -53,6 +53,11 @@ update-hoonc: $(call show_env_vars) cargo install --locked --path crates/hoonc --bin hoonc +.PHONY: build-nockchain +build-nockchain: assets/dumb.jam assets/miner.jam + $(call show_env_vars) + cargo build --release --bin nockchain --features tracing-tracy + .PHONY: install-nockchain install-nockchain: assets/dumb.jam assets/miner.jam $(call show_env_vars) diff --git a/README.md b/README.md index 668d4ac85..c2bc2b65b 100644 --- a/README.md +++ b/README.md @@ -83,24 +83,15 @@ nockchain-wallet keygen This will print a new public/private key pair + chain code to the console, as well as the seed phrase for the private key. -Use `.env_example` as a template and copy the public key to the `.env` file: +Use `.env_example` as a template and copy your pkh to the `.env` file. -``` -MINING_PUBKEY= -``` -When the v1 protocol cut-off block-height is reached, the miner will automatically generate v1 coinbases for blocks that it mines. -You will need to supply a pkh for the coinbase ahead of time by generating a v1 key using the latest wallet. pkhs cannot be generated -from v0 keys. - -Generate the v1 pkh by running `nockchain-wallet generate-mining-pkh` on the latest version of the wallet. The pkh should be listed as the `Address`. Then, in your `.env` file, set the `MINING_PKH` variable to the address of the v1 key you generated. ``` MINING_PKH=
``` -To reiterate, before the upgrade cutoff, the miner will generate v0 coinbases spendable by the `MINING_PUBKEY`. After the cutoff, it will generate -v1 coinbases spendable by the `MINING_PKH`. +The miner will generate v1 coinbases spendable by the `MINING_PKH`. ## Backup Keys @@ -128,7 +119,7 @@ To run a Nockchain node without mining. bash ./scripts/run_nockchain_node.sh ``` -To run a Nockchain node and mine to a pubkey: +To run a Nockchain node and mine to a pkh: ``` bash ./scripts/run_nockchain_miner.sh @@ -138,19 +129,23 @@ For launch, make sure you run in a fresh working directory that does not include ## FAQ -### Can I use same pubkey if running multiple miners? +### What is a pkh? -Yes, you can use the same pubkey if running multiple miners. +A pkh is a "pubkey hash", which is a shorter representation of a public key. v1 pkhs are base58-encoded. -### How do I change the mining pubkey? +### Can I use same pkh if running multiple miners? + +Yes, you can use the same pkh if running multiple miners. + +### How do I change the mining pkh? Run `nockchain-wallet keygen` to generate a new key pair. -If you are using the Makefile workflow, copy the public key to the `.env` file. +If you are using the Makefile workflow, copy the pkh to the `.env` file. ### How do I run a testnet? -To run a testnet on your machine, follow the same instructions as above, except use the fakenet -scripts provided in the `scripts` directory. + +To run a testnet on your machine, follow the same instructions as above, except use the fakenet scripts provided in the `scripts` directory. Here's how to set it up: @@ -257,7 +252,7 @@ Common errors and their solutions: To check your wallet balance: ```bash -# List all notes by pubkey +# List all notes by pkh nockchain-wallet list-notes-by-address ``` @@ -365,7 +360,7 @@ ssh -L 8087:backbone-us-south-mig:8086 backbone-us-south-mig - Check firewall settings 3. **Mining Not Working**: - - Verify mining pubkey + - Verify mining pkh - Check --mine flag - Ensure peers are connected - Check system resources diff --git a/crates/nockchain-types/src/tx_engine/common/mod.rs b/crates/nockchain-types/src/tx_engine/common/mod.rs index 64ac91717..08e9a561e 100644 --- a/crates/nockchain-types/src/tx_engine/common/mod.rs +++ b/crates/nockchain-types/src/tx_engine/common/mod.rs @@ -135,7 +135,7 @@ pub struct Source { #[derive(Debug, thiserror::Error)] pub enum HashDecodeError { - #[error("Provided base58 corresponds to a value too large to be a tip5 hash")] + #[error("Provided base58 corresponds to a value too large to be a tip5 hash (likely a v0 pubkey instead of a v1 pkh)")] ProvidedValueTooLarge, #[error("base58 decode error: {0}")] Base58(#[from] bs58::decode::Error), diff --git a/crates/nockchain/src/config.rs b/crates/nockchain/src/config.rs index 2bc79dddd..8ee9bc5eb 100644 --- a/crates/nockchain/src/config.rs +++ b/crates/nockchain/src/config.rs @@ -43,23 +43,11 @@ pub struct NockchainCli { pub nockapp_cli: nockapp::kernel::boot::Cli, #[arg(long, help = "Mine in-kernel", default_value = "false")] pub mine: bool, - #[arg( - long, - help = "Pubkey to mine to (mutually exclusive with --mining-key-adv)" - )] - pub mining_pubkey: Option, #[arg( long, help = "Pubkey hash to mine to (mutually exclusive with --mining-pkh-adv)" )] pub mining_pkh: Option, - #[arg( - long, - help = "Advanced mining key configuration (mutually exclusive with --mining-pubkey). Format: share,m:key1,key2,key3", - value_parser = value_parser!(MiningKeyConfig), - num_args = 1.., - )] - pub mining_key_adv: Option>, #[arg( long, help = "Advanced mining pubkey hash configuration (mutually exclusive with --mining-pkh). Format: share,pkh", @@ -146,15 +134,9 @@ pub struct NockchainCli { impl NockchainCli { pub fn validate(&self) -> Result<(), String> { - if self.mine && !(self.mining_pubkey.is_some() || self.mining_key_adv.is_some()) { + if self.mine && !(self.mining_pkh.is_some() || self.mining_pkh_adv.is_some()) { return Err( - "Cannot specify mine without either mining_pubkey or mining_key_adv".to_string(), - ); - } - - if self.mining_pubkey.is_some() && self.mining_key_adv.is_some() { - return Err( - "Cannot specify both mining_pubkey and mining_key_adv at the same time".to_string(), + "Cannot specify mine without either mining_pkh or mining_pkh_adv".to_string(), ); } @@ -164,20 +146,6 @@ impl NockchainCli { ); } - if let Some(pubkey) = &self.mining_pubkey { - SchnorrPubkey::from_base58(pubkey) - .map_err(|err| format!("Invalid mining_pubkey: {err}"))?; - } - - if let Some(key_configs) = &self.mining_key_adv { - for config in key_configs { - for key in &config.keys { - SchnorrPubkey::from_base58(key) - .map_err(|err| format!("Invalid mining_key_adv pubkey '{key}': {err}"))?; - } - } - } - if let Some(pkh) = &self.mining_pkh { Hash::from_base58(pkh).map_err(|err| format!("Invalid mining_pkh: {err}"))?; } @@ -190,22 +158,6 @@ impl NockchainCli { } } - if self.mining_pubkey.is_some() { - if !self.mining_pkh.is_some() { - return Err( - "Have mining_pubkey, but no mining_pkh. Must specify neither or both of mining_pubkey and mining_pkh. To get a pkh, you must generate a v1 key by running `generate-mining-pkh` on the latest version of the wallet. The pkh will be listed as the 'Address' ".to_string(), - ); - } - } - - if self.mining_key_adv.is_some() { - if !self.mining_pkh_adv.is_some() { - return Err( - "Must specify neither or both of mining_key_adv and mining_pkh_adv".to_string(), - ); - } - } - Ok(()) } } @@ -216,16 +168,14 @@ mod tests { use super::*; - const VALID_MINING_PUBKEY: &str = "2cPnE4Z9RevhTv9is9Hmc1amFubEFbUxzCV2Fxb9GxevJstV5VG92oYt6Sai3d3NjLFcsuVXSLx9hikMbD1agv9M267TVw3hV9MCpMfEnGo5LYtjJ7jPyHg8SERPjJRCWTgZ"; + const VALID_V0_PUBKEY: &str = "2cPnE4Z9RevhTv9is9Hmc1amFubEFbUxzCV2Fxb9GxevJstV5VG92oYt6Sai3d3NjLFcsuVXSLx9hikMbD1agv9M267TVw3hV9MCpMfEnGo5LYtjJ7jPyHg8SERPjJRCWTgZ"; const VALID_MINING_PKH: &str = "9yPePjfWAdUnzaQKyxcRXKRa5PpUzKKEwtpECBZsUYt9Jd7egSDEWoV"; fn base_cli() -> NockchainCli { NockchainCli { nockapp_cli: default_boot_cli(false), mine: false, - mining_pubkey: None, mining_pkh: None, - mining_key_adv: None, mining_pkh_adv: None, fakenet: false, peer: Vec::new(), @@ -258,11 +208,6 @@ mod tests { #[test] fn validate_accepts_valid_advanced_configs() { let mut cli = base_cli(); - cli.mining_key_adv = Some(vec![MiningKeyConfig { - share: 1, - m: 1, - keys: vec![VALID_MINING_PUBKEY.to_string()], - }]); cli.mining_pkh_adv = Some(vec![MiningPkhConfig { share: 1, pkh: VALID_MINING_PKH.to_string(), @@ -271,40 +216,14 @@ mod tests { assert!(cli.validate().is_ok()); } - #[test] - fn validate_rejects_invalid_mining_key_adv_pubkey() { - let mut cli = base_cli(); - // We specifically want to catch if users mix up v0 and v1 addresses, because they are both base58-encoded. - // Using a base58-encoded pkh ensures the input is base58 but not a valid pubkey. - let invalid_pubkey = VALID_MINING_PKH; - cli.mining_key_adv = Some(vec![MiningKeyConfig { - share: 1, - m: 1, - keys: vec![invalid_pubkey.to_string()], - }]); - cli.mining_pkh_adv = Some(vec![MiningPkhConfig { - share: 1, - pkh: VALID_MINING_PKH.to_string(), - }]); - - let err = cli.validate().expect_err("expected invalid pubkey"); - assert!(err.contains("Invalid mining_key_adv pubkey")); - } - #[test] fn validate_rejects_invalid_mining_pkh_adv_entry() { // We specifically want to catch if users mix up v0 and v1 addresses, because they are both base58-encoded. // Using a base58-encoded pubkey ensures the input is base58 but not a valid hash. - let invalid_mining_pkh = VALID_MINING_PUBKEY.to_string(); let mut cli = base_cli(); - cli.mining_key_adv = Some(vec![MiningKeyConfig { - share: 1, - m: 1, - keys: vec![VALID_MINING_PUBKEY.to_string()], - }]); cli.mining_pkh_adv = Some(vec![MiningPkhConfig { share: 1, - pkh: invalid_mining_pkh, + pkh: VALID_V0_PUBKEY.to_string(), }]); let err = cli.validate().expect_err("expected invalid pkh adv"); diff --git a/crates/nockchain/src/lib.rs b/crates/nockchain/src/lib.rs index 37d707f00..5999e8e08 100644 --- a/crates/nockchain/src/lib.rs +++ b/crates/nockchain/src/lib.rs @@ -435,15 +435,8 @@ pub async fn init_with_kernel( }; setup::poke(&mut nockapp, setup::SetupCommand::PokeSetBtcData).await?; - let mining_config = if let Some(pubkey) = &cli.mining_pubkey { - Some(vec![MiningKeyConfig { - share: 1, - m: 1, - keys: vec![pubkey.clone()], - }]) - } else if let Some(mining_key_adv) = &cli.mining_key_adv { - Some(mining_key_adv.clone()) - } else { + // Set up empty mining config by default (TODO remove when taking out pubkey infra) + let mining_config: Option> = { None }; diff --git a/crates/nockchain/src/mining.rs b/crates/nockchain/src/mining.rs index 4998855ee..1008540e2 100644 --- a/crates/nockchain/src/mining.rs +++ b/crates/nockchain/src/mining.rs @@ -118,19 +118,9 @@ pub fn create_mining_driver( ) -> IODriverFn { Box::new(move |handle| { Box::pin(async move { - let Some(configs) = mining_config else { - enable_mining(&handle, false).await?; + // set up empty config for v0 keys (TODO remove when taking out pubkey infra) + let configs = Vec::::new(); - if let Some(tx) = init_complete_tx { - tx.send(()).map_err(|_| { - NockAppError::OtherError(String::from( - "Could not send driver initialization for mining driver.", - )) - })?; - } - - return Ok(()); - }; let Some(pkh_configs) = mining_pkh_config else { enable_mining(&handle, false).await?; @@ -342,7 +332,7 @@ async fn set_mining_key_advanced( let set_mining_key_adv = Atom::from_value(&mut set_mining_key_slab, "set-mining-key-advanced") .expect("Failed to create set-mining-key-advanced atom"); - // Create the list of v0 (pubkey) configs + // Create the list of v0 (pubkey) configs (TODO remove when taking out pubkey infra) let mut configs_list = D(0); for config in configs { // Create the list of keys diff --git a/scripts/run_nockchain_miner.sh b/scripts/run_nockchain_miner.sh index 95fbb81c4..5d1082a65 100755 --- a/scripts/run_nockchain_miner.sh +++ b/scripts/run_nockchain_miner.sh @@ -2,7 +2,7 @@ source .env export RUST_LOG export MINIMAL_LOG_FORMAT -export MINING_PUBKEY +export MINING_PKH get_cpu_count() { if [[ "$OSTYPE" == "darwin"* ]]; then @@ -25,4 +25,4 @@ num_threads=$((threads > 1 ? threads : 1)) echo "Starting nockchain miner with $num_threads mining threads:" -nockchain --mining-pubkey ${MINING_PUBKEY} --mining-pkh ${MINING_PKH} --mine --num-threads $num_threads +nockchain --mining-pkh ${MINING_PKH} --mine --num-threads $num_threads diff --git a/scripts/run_nockchain_miner_fakenet.sh b/scripts/run_nockchain_miner_fakenet.sh index 8e6a275d5..5f4ab2725 100644 --- a/scripts/run_nockchain_miner_fakenet.sh +++ b/scripts/run_nockchain_miner_fakenet.sh @@ -2,5 +2,5 @@ source .env export RUST_LOG export MINIMAL_LOG_FORMAT -export MINING_PUBKEY -nockchain --mine --fakenet --mining-pubkey ${MINING_PUBKEY} --mining-pkh ${MINING_PKH} --peer /ip4/127.0.0.1/udp/3006/quic-v1 --no-default-peers +export MINING_PKH +nockchain --mine --fakenet --mining-pkh ${MINING_PKH} --peer /ip4/127.0.0.1/udp/3006/quic-v1 --no-default-peers diff --git a/scripts/run_nockchain_node_fakenet.sh b/scripts/run_nockchain_node_fakenet.sh index 50d388ba0..b17e1687f 100644 --- a/scripts/run_nockchain_node_fakenet.sh +++ b/scripts/run_nockchain_node_fakenet.sh @@ -2,5 +2,5 @@ source .env export RUST_LOG export MINIMAL_LOG_FORMAT -export MINING_PUBKEY +export MINING_PKH nockchain --fakenet --grpc-address http://127.0.0.1:5555 --bind /ip4/127.0.0.1/udp/3006/quic-v1 From 8983615c400dbc344149c59d32e571fac52b501f Mon Sep 17 00:00:00 2001 From: tacryt-socryp <1377557+tacryt-socryp@users.noreply.github.com> Date: Thu, 30 Oct 2025 09:19:36 -0500 Subject: [PATCH 08/99] nockchain: stubbed out deprecated mining pubkey argument and added new decoding error type --- crates/nockchain-math/src/crypto/cheetah.rs | 6 ++++++ crates/nockchain/src/lib.rs | 4 +--- crates/nockchain/src/mining.rs | 10 ++++++++-- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/nockchain-math/src/crypto/cheetah.rs b/crates/nockchain-math/src/crypto/cheetah.rs index 31fec83fb..21a8477ab 100644 --- a/crates/nockchain-math/src/crypto/cheetah.rs +++ b/crates/nockchain-math/src/crypto/cheetah.rs @@ -44,6 +44,9 @@ pub enum CheetahError { #[error("base58 decode error: {0}")] Base58(#[from] bs58::decode::Error), + #[error("used zpub import key instead of address")] + ZPubUsed, + #[error("invalid base58 string length, got {0}")] InvalidLength(usize), @@ -79,6 +82,9 @@ impl CheetahPoint { pub fn from_base58(b58: &str) -> Result { let v = bs58::decode(b58).into_vec()?; if v.len() != Self::BYTES { + if b58.starts_with("zpub") { + return Err(CheetahError::ZPubUsed); + } return Err(CheetahError::InvalidLength(v.len())); } diff --git a/crates/nockchain/src/lib.rs b/crates/nockchain/src/lib.rs index 5999e8e08..ee8ab9e0e 100644 --- a/crates/nockchain/src/lib.rs +++ b/crates/nockchain/src/lib.rs @@ -436,9 +436,7 @@ pub async fn init_with_kernel( setup::poke(&mut nockapp, setup::SetupCommand::PokeSetBtcData).await?; // Set up empty mining config by default (TODO remove when taking out pubkey infra) - let mining_config: Option> = { - None - }; + let mining_config: Option> = { None }; let mining_pkh_config = if let Some(pkh) = &cli.mining_pkh { Some(vec![MiningPkhConfig { diff --git a/crates/nockchain/src/mining.rs b/crates/nockchain/src/mining.rs index 1008540e2..c26c3802b 100644 --- a/crates/nockchain/src/mining.rs +++ b/crates/nockchain/src/mining.rs @@ -118,8 +118,14 @@ pub fn create_mining_driver( ) -> IODriverFn { Box::new(move |handle| { Box::pin(async move { - // set up empty config for v0 keys (TODO remove when taking out pubkey infra) - let configs = Vec::::new(); + // set up empty config for v0 keys (TODO remove when taking out pubkey infra from kernel) + let mut configs = Vec::::new(); + configs.push(MiningKeyConfig { + share: 1, + m: 1, + // hardcoded key to satisfy pass-through for v0 pubkey mining infra + keys: vec!["2qwq9dQRZfpFx8BDicghpMRnYGKZsZGxxhh9m362pzpM9aeo276pR1yHZPS41y3CW3vPKxeYM8p8fzZS8GXmDGzmNNCnVNekjrSYogqfEFMqwhHh5iCjaKPaDTwhupWqiXj6".to_string()], + }); let Some(pkh_configs) = mining_pkh_config else { enable_mining(&handle, false).await?; From 6fb652e5a6355565912eb947ec026aeb7f512373 Mon Sep 17 00:00:00 2001 From: tacryt-socryp <1377557+tacryt-socryp@users.noreply.github.com> Date: Thu, 30 Oct 2025 19:05:17 -0500 Subject: [PATCH 09/99] nockchain: update fakenet default args for simpler fakenet setup --- crates/nockchain/src/config.rs | 4 +++- hoon/apps/dumbnet/inner.hoon | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/nockchain/src/config.rs b/crates/nockchain/src/config.rs index 8ee9bc5eb..5abf51260 100644 --- a/crates/nockchain/src/config.rs +++ b/crates/nockchain/src/config.rs @@ -105,7 +105,7 @@ pub struct NockchainCli { pub fakenet_pow_len: u64, #[arg( long, - help = "log target difficulty for mining on fakenet. Defaults to 2 (so 2^2 attempts on average find a block). Ignored on mainnet.", + help = "log target difficulty for mining on fakenet. Defaults to 1 (so 2^1 attempts on average find a block). Ignored on mainnet.", default_value = "1", requires = "fakenet" )] @@ -113,12 +113,14 @@ pub struct NockchainCli { #[arg( long, help = "Minimum timelock for coinbase transactions on fakenet. Defaults to 100 blocks. Ignored on mainnet.", + default_value = "100", requires = "fakenet" )] pub fakenet_coinbase_timelock_min: Option, #[arg( long, help = "Override the v1-phase activation height when running on fakenet. Requires --fakenet.", + default_value = "1", requires = "fakenet" )] pub fakenet_v1_phase: Option, diff --git a/hoon/apps/dumbnet/inner.hoon b/hoon/apps/dumbnet/inner.hoon index 786cbae6e..d5336933c 100644 --- a/hoon/apps/dumbnet/inner.hoon +++ b/hoon/apps/dumbnet/inner.hoon @@ -1124,6 +1124,13 @@ :: a serious bug otherwise. ~| 'liar-effect: ATTN: miner or +do-genesis produced a bad block!' !! + :: + [%poke %sys *] + ?: =(%not-a-genesis-block r) + ~| 'liar-effect: ATTN: received a bad genesis block! check pow params and jamfile' + !! + ~| 'liar-effect: ATTN: received an unknown bad poke!' + !! == :: ++ get-peer-id From da65e41afa65d2f03ddabdcbc585b43ee38ee5ec Mon Sep 17 00:00:00 2001 From: tacryt-socryp <1377557+tacryt-socryp@users.noreply.github.com> Date: Thu, 30 Oct 2025 20:15:34 -0500 Subject: [PATCH 10/99] nockchain-peek: add utility for peeking out data from a running nockchain node --- Cargo.lock | 16 + Cargo.toml | 2 +- Makefile | 12 + crates/kernels/Cargo.toml | 1 + crates/kernels/src/lib.rs | 3 + crates/kernels/src/nockchain_peek.rs | 8 + crates/nockchain-peek/Cargo.toml | 19 ++ crates/nockchain-peek/src/lib.rs | 140 +++++++++ crates/nockchain-peek/src/main.rs | 16 + hoon/apps/peek/peek.hoon | 445 +++++++++++++++++++++++++++ hoon/apps/wallet/wallet.hoon | 9 + 11 files changed, 670 insertions(+), 1 deletion(-) create mode 100644 crates/kernels/src/nockchain_peek.rs create mode 100644 crates/nockchain-peek/Cargo.toml create mode 100644 crates/nockchain-peek/src/lib.rs create mode 100644 crates/nockchain-peek/src/main.rs create mode 100644 hoon/apps/peek/peek.hoon diff --git a/Cargo.lock b/Cargo.lock index 88a0ac1d1..0d454db58 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3967,6 +3967,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "nockchain-peek" +version = "0.1.0" +dependencies = [ + "clap", + "kernels", + "nockapp", + "nockapp-grpc", + "nockvm", + "nockvm_macros", + "rustls", + "tokio", + "tracing", + "zkvm-jetpack", +] + [[package]] name = "nockchain-types" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index d85395b92..b496a6ecc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ resolver = "2" [workspace] -members = ["crates/equix-latency", "crates/hoon", "crates/hoonc", "crates/kernels", "crates/nockapp", "crates/nockapp-grpc", "crates/nockchain", "crates/nockchain-libp2p-io", "crates/nockchain-math", "crates/nockchain-types", "crates/nockchain-wallet", "crates/nockvm/rust/ibig", "crates/nockvm/rust/murmur3", "crates/nockvm/rust/nockvm", "crates/nockvm/rust/nockvm_macros", "crates/noun-serde", "crates/noun-serde-derive", "crates/zkvm-jetpack"] +members = ["crates/equix-latency", "crates/hoon", "crates/hoonc", "crates/kernels", "crates/nockapp", "crates/nockapp-grpc", "crates/nockchain", "crates/nockchain-libp2p-io", "crates/nockchain-math", "crates/nockchain-peek", "crates/nockchain-types", "crates/nockchain-wallet", "crates/nockvm/rust/ibig", "crates/nockvm/rust/murmur3", "crates/nockvm/rust/nockvm", "crates/nockvm/rust/nockvm_macros", "crates/noun-serde", "crates/noun-serde-derive", "crates/zkvm-jetpack"] [workspace.package] version = "0.1.0" diff --git a/Makefile b/Makefile index 8e0fdc67d..9df41f222 100644 --- a/Makefile +++ b/Makefile @@ -68,6 +68,11 @@ install-nockchain-wallet: assets/wal.jam $(call show_env_vars) cargo install --locked --force --path crates/nockchain-wallet --bin nockchain-wallet +.PHONY: install-nockchain-peek +install-nockchain-peek: assets/nockchain-peek.jam + $(call show_env_vars) + cargo install --locked --force --path crates/nockchain-peek --bin nockchain-peek + .PHONY: ensure-dirs ensure-dirs: mkdir -p hoon @@ -124,3 +129,10 @@ assets/miner.jam: ensure-dirs hoon/apps/dumbnet/miner.hoon $(HOON_SRCS) rm -f assets/miner.jam hoonc hoon/apps/dumbnet/miner.hoon hoon mv out.jam assets/miner.jam + +## Build peek.jam with hoonc +assets/nockchain-peek.jam: ensure-dirs hoon/apps/peek/peek.hoon $(HOON_SRCS) + $(call show_env_vars) + rm -f assets/nockchain-peek.jam + hoonc hoon/apps/peek/peek.hoon hoon + mv out.jam assets/nockchain-peek.jam diff --git a/crates/kernels/Cargo.toml b/crates/kernels/Cargo.toml index 4adb97fe3..c1c7f78ea 100644 --- a/crates/kernels/Cargo.toml +++ b/crates/kernels/Cargo.toml @@ -13,3 +13,4 @@ bazel_build = [] dumb = [] wallet = [] miner = [] +nockchain_peek = [] diff --git a/crates/kernels/src/lib.rs b/crates/kernels/src/lib.rs index bc215c1de..ad7009d26 100644 --- a/crates/kernels/src/lib.rs +++ b/crates/kernels/src/lib.rs @@ -6,3 +6,6 @@ pub mod dumb; #[cfg(feature = "miner")] pub mod miner; + +#[cfg(feature = "nockchain_peek")] +pub mod nockchain_peek; diff --git a/crates/kernels/src/nockchain_peek.rs b/crates/kernels/src/nockchain_peek.rs new file mode 100644 index 000000000..522dd3781 --- /dev/null +++ b/crates/kernels/src/nockchain_peek.rs @@ -0,0 +1,8 @@ +#[cfg(feature = "bazel_build")] +pub static KERNEL: &[u8] = include_bytes!(env!("NOCKCHAIN_PEEK_JAM_PATH")); + +#[cfg(not(feature = "bazel_build"))] +pub const KERNEL: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../assets/nockchain-peek.jam" +)); diff --git a/crates/nockchain-peek/Cargo.toml b/crates/nockchain-peek/Cargo.toml new file mode 100644 index 000000000..bedbbdd88 --- /dev/null +++ b/crates/nockchain-peek/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "nockchain-peek" +publish = false +version.workspace = true +edition.workspace = true + +[dependencies] +clap.workspace = true +kernels = { workspace = true, features = ["nockchain_peek"] } +nockapp.workspace = true +nockapp-grpc = { workspace = true } +nockvm = { workspace = true } +nockvm_macros = { workspace = true } +rustls.workspace = true +zkvm-jetpack.workspace = true + +tokio = { workspace = true, features = ["full"] } +tracing = { workspace = true } + diff --git a/crates/nockchain-peek/src/lib.rs b/crates/nockchain-peek/src/lib.rs new file mode 100644 index 000000000..4c788bdea --- /dev/null +++ b/crates/nockchain-peek/src/lib.rs @@ -0,0 +1,140 @@ +use std::error::Error; + +use clap::{arg, command, Parser, Subcommand}; +use nockapp::driver::Operation; +use nockapp::kernel::boot; +use nockapp::noun::slab::NounSlab; +use nockapp::utils::make_tas; +use nockapp::{file_driver, markdown_driver, AtomExt, NockApp}; +use nockapp_grpc::private_nockapp::grpc_listener_driver; +use nockvm::noun::{D, T}; +use nockvm_macros::tas; +use tracing::info; +use zkvm_jetpack::hot::produce_prover_hot_state; + +#[derive(Parser, Debug, Clone)] +#[command(name = "nockchain-peek")] +pub struct NockchainPeekCli { + #[command(flatten)] + nockapp_cli: nockapp::kernel::boot::Cli, + #[arg( + long, + value_name = "GRPC_ADDRESS", + default_value = "http://localhost:5555", + help = "Nockchain gRPC server address" + )] + grpc_address: String, + #[command(subcommand)] + command: PeekCommand, +} + +#[derive(Subcommand, Debug, Clone)] +pub enum PeekCommand { + #[command(about = "Peek at the heaviest block ID")] + Heavy, + #[command(about = "Peek at a specific block by ID")] + Block { + #[arg(help = "Block ID in base58 format")] + block_id: String, + }, + #[command(about = "Peek at all blocks (full block data with pow)")] + Blocks, + #[command(about = "Peek at the heaviest block page")] + HeaviestBlock, + #[command(about = "Peek at a page by height using heavy-n")] + HeavyN { + #[arg(help = "Page number to peek")] + page_number: u64, + }, + #[command(about = "Peek at small blocks (blocks without pow data)")] + SmallBlocks, + #[command(about = "Check for note intersection in a block")] + CheckNotes { + #[arg(help = "Block ID in base58 format")] + block_id: String, + }, +} + +pub async fn init_with_kernel( + cli: NockchainPeekCli, + kernel_jam: &[u8], +) -> Result> { + let prover_hot_state = produce_prover_hot_state(); + + let mut nockapp = boot::setup( + kernel_jam, + cli.nockapp_cli.clone(), + prover_hot_state.as_slice(), + "nockchain-peek", + None, + ) + .await?; + boot::init_default_tracing(&cli.nockapp_cli); + + let mut born_slab = NounSlab::new(); + let command = cli.command.clone(); + + let command_noun = command.to_noun(&mut born_slab)?; + let born_noun = T(&mut born_slab, &[D(tas!(b"born")), command_noun]); + born_slab.set_root(born_noun); + nockapp + .add_io_driver(nockapp::one_punch_driver(born_slab, Operation::Poke)) + .await; + nockapp.add_io_driver(markdown_driver()).await; + nockapp.add_io_driver(file_driver()).await; + nockapp.add_io_driver(nockapp::exit_driver()).await; + nockapp + .add_io_driver(grpc_listener_driver(cli.grpc_address.clone())) + .await; + info!("Connected gRPC listener to {}", cli.grpc_address); + + Ok(nockapp) +} + +impl PeekCommand { + fn to_noun(&self, slab: &mut NounSlab) -> Result> { + use nockvm::noun::Atom; + match self { + PeekCommand::Heavy => { + let heavy_atom = make_tas(slab, "heavy"); + let path = T(slab, &[heavy_atom.as_noun(), D(0)]); + Ok(path) + } + PeekCommand::Block { block_id } => { + let block_id_atom = Atom::from_value(slab, block_id.as_bytes()) + .map_err(|e| format!("failed to create block_id atom: {}", e))? + .as_noun(); + Ok(T(slab, &[D(tas!(b"block")), block_id_atom])) + } + PeekCommand::Blocks => { + let blocks_atom = make_tas(slab, "blocks"); + let path = T(slab, &[blocks_atom.as_noun(), D(0)]); + Ok(path) + } + PeekCommand::HeaviestBlock => { + let heaviest_block_atom = make_tas(slab, "heaviest-block"); + let path = T(slab, &[heaviest_block_atom.as_noun(), D(0)]); + Ok(path) + } + PeekCommand::HeavyN { page_number } => { + info!("page_number: {}", page_number); + let page_number_atom = Atom::from_value(slab, &page_number.to_le_bytes()[..]) + .map_err(|e| format!("failed to create page_number atom: {}", e))?; + info!("page_number: {:?}", page_number_atom); + + Ok(T(slab, &[D(tas!(b"heavy-n")), page_number_atom.as_noun()])) + } + PeekCommand::SmallBlocks => { + let small_blocks_atom = make_tas(slab, "small-blocks"); + let path = T(slab, &[small_blocks_atom.as_noun(), D(0)]); + Ok(path) + } + PeekCommand::CheckNotes { block_id } => { + let block_id_atom = Atom::from_value(slab, block_id.as_bytes()) + .map_err(|e| format!("failed to create block_id atom: {}", e))? + .as_noun(); + Ok(T(slab, &[D(tas!(b"chknote")), block_id_atom])) + } + } + } +} diff --git a/crates/nockchain-peek/src/main.rs b/crates/nockchain-peek/src/main.rs new file mode 100644 index 000000000..b6bd09d6e --- /dev/null +++ b/crates/nockchain-peek/src/main.rs @@ -0,0 +1,16 @@ +use std::error::Error; + +use clap::Parser; +use kernels::nockchain_peek::KERNEL; + +#[tokio::main] +async fn main() -> Result<(), Box> { + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .expect("default provider already set elsewhere"); + + let cli = nockchain_peek::NockchainPeekCli::parse(); + let mut nockchain_peek = nockchain_peek::init_with_kernel(cli, KERNEL).await?; + nockchain_peek.run().await?; + Ok(()) +} diff --git a/hoon/apps/peek/peek.hoon b/hoon/apps/peek/peek.hoon new file mode 100644 index 000000000..1bbf2d49f --- /dev/null +++ b/hoon/apps/peek/peek.hoon @@ -0,0 +1,445 @@ +:: nockchain peek nockapp +:: +/= t /common/tx-engine +/= * /common/wrapper +/= * /common/zoon +:: +=> +|% ++$ kernel-state + $: peek-command=peek-command + peek-data=(unit *) + == +:: ++$ peek-command + $% [%heavy ~] + [%block block-id=@t] + [%blocks ~] + [%heaviest-block ~] + [%heavy-n page-number=@ud] + [%chknote block-id=@t] + == +:: +++ moat (keep kernel-state) +:: ++$ cause + $% other-cause + grpc-bind-cause + == ++$ other-cause + $% [%born command=peek-command] + == ++$ grpc-bind-cause + $% [%grpc-bind result=(unit (unit *))] + == +:: ++$ effect + $% [%exit code=@] + [%grpc grpc-effect] + [%markdown @t] + [%file file-effect] + == +:: ++$ grpc-effect + $% [%peek pid=@ typ=@tas =path] + == ++$ file-effect + $% [%write path=@t data=@] + == +-- +:: +=< +%- (moat |) +^- fort:moat +|_ k=kernel-state ++* util +> +:: +load: upgrade from previous state +++ load + |= arg=kernel-state + ^- kernel-state + arg +:: +:: +peek: external inspect +++ peek + |= arg=path + ^- (unit (unit *)) + ?+ arg ~ + [%peek-data ~] + ``peek-data.k + [%peek-command ~] + ``peek-command.k + == +:: +:: +poke: external apply +++ poke + |= [=wire eny=@ our=@ux now=@da dat=*] + ^- [(list effect) kernel-state] + ~& > "{}: poked on wire: {}" + =/ soft-cau ((soft cause) dat) + ?~ soft-cau + ~& >>> "could not mold poke: {}" !! + =/ c=cause u.soft-cau + ?+ wire ~|("unsupported wire: {}" !!) + :: + [%poke %grpc ver=@ pid=@ tag=@tas ~] + ~& "in %grpc-bind" + ?> ?=(%grpc-bind -.c) + ?~ result.c + ~& >> "bad peek response" + :_ k + [%exit 1]~ + ?~ u.result.c + =. peek-data.k ~ + :_ k + :~ :- %markdown + (crip "no data found for /{(trip (command-description peek-command.k))}") + [%exit 0] + == + =/ data=* u.u.result.c + =. peek-data.k `data + =/ [effects=(list effect) markdown-content=@t] + =- [`(list effect)`-< (crip ->)] + ?- -.peek-command.k + :: + %heavy + :- ~ + ~| "error: could not parse heaviest block data" + =/ heaviest=(unit (unit block-id:t)) + %- (soft (unit block-id:t)) + data + ?~ heaviest + "error: could not parse heaviest block data" + ?~ u.heaviest + "empty: no heaviest block data" + (format-heavy:util u.heaviest) + :: + %block + :- ~ + ~| "block {(trip block-id.peek-command.k)} not found" + =/ page=(unit page:t) + %- (soft page:t) + data + ?~ page + "error: could not parse block data" + (format-page:util (cat 3 'Block ' block-id.peek-command.k) u.page) + :: + %heaviest-block + :- ~ + ~| "heaviest block not found" + =/ page=(unit page:t) + %- (soft page:t) + data + ?~ page + "error: could not parse heaviest block page data" + (format-page:util 'Heaviest Block' u.page) + :: + %heavy-n + :- ~ + ~| "no page found at height {}" + =/ page=(unit page:t) + %- (soft page:t) + data + ?~ page + "error: could not parse page data at height {}" + (format-page:util (cat 3 'Page at height ' (scot %ud page-number.peek-command.k)) u.page) + :: + %blocks + ~| "error: could not parse blocks data" + =/ blocks=(unit (z-map block-id:t page:t)) + %- (soft (z-map block-id:t page:t)) + data + ?~ blocks + :- ~ + "error: could not parse blocks data" + :: jam the blocks and save to file + =/ jammed-blocks=@ (jam u.blocks) + :- [%file %write 'blocks.jam' jammed-blocks]~ + (format-blocks:util u.blocks) + :: + %chknote + :- ~ + ~| "error: could not parse block transactions" + =/ txs=(unit (z-map tx-id:t tx:t)) + %- (soft (z-map tx-id:t tx:t)) + data + ?~ txs + "error: could not parse block transactions data" + :: build unified input set + =/ all-inputs=(z-set nname:t) + %- ~(rep z-by u.txs) + |= [[tid=tx-id:t tx=tx:t] acc=(z-set nname:t)] + (~(uni z-in acc) (extract-input-nnames:util tx)) + :: build unified output set + =/ all-outputs=(z-set nname:t) + %- ~(rep z-by u.txs) + |= [[tid=tx-id:t tx=tx:t] acc=(z-set nname:t)] + (~(uni z-in acc) (extract-output-nnames:util tx)) + :: compute intersection + =/ intersection=(z-set nname:t) + (~(int z-in all-inputs) all-outputs) + :: find problematic transactions + =/ problematic-txs=(list [tx-id:t tx:t]) + (find-problematic-txs:util u.txs intersection) + :: format output + %+ format-check-notes:util + block-id.peek-command.k + [u.txs all-inputs all-outputs intersection problematic-txs] + == + :_ k + %+ weld effects + ^- (list effect) + :~ [%markdown markdown-content] + [%exit 0] + == + :: + [%poke src=?(%one-punch) ver=@ *] + ?> ?=(other-cause c) + ?- -.c + %born + ~& "%born: attempting to peek {<(command-description command.c)>}" + ~& "peek-command path: {<;;(path (command-to-path:util command.c))>}" + =. peek-command.k command.c + ~& peek-command.k + :_ k + ^- (list effect) + [%grpc %peek 0 %peek (command-to-path:util command.c)]~ + == + == +-- +|% +:: +++ format-blocks + |= blocks=(z-map block-id:t page:t) + ^- tape + =/ block-count=@ + ~(wyt z-by blocks) + """ + # Blocks + Total blocks: {} + """ +:: +++ format-heavy + |= heaviest=(unit block-id:t) + ^- tape + ?~ heaviest + "no heaviest block found" + """ + # Heaviest Block + - id: {(trip (to-b58:hash:t u.heaviest))} + """ +:: +++ format-page + |= [title=@t page=page:t] + ^- tape + =/ pow=(unit proof:t) ~(pow get:page:t page) + =/ proof-version=(unit @ud) + ?~ pow ~ + `-.u.pow + =/ height=@ud ~(height get:page:t page) + =/ digest=block-id:t ~(digest get:page:t page) + =/ parent=block-id:t ~(parent get:page:t page) + =/ timestamp=@ ~(timestamp get:page:t page) + =/ msg=page-msg:t ~(msg get:page:t page) + =/ epoch-counter=@ud ~(epoch-counter get:page:t page) + =/ target=bignum:bn:t ~(target get:page:t page) + =/ tx-ids=(z-set tx-id:t) ~(tx-ids get:page:t page) + =/ tx-ids-tape=tape + ;; tape + %+ join ' ' + ^- (list @t) + %+ turn ~(tap z-in tx-ids) + |= =tx-id:t + (to-b58:hash:t tx-id) + """ + # {(trip title)} + ## Page Data + - height: {} + - digest: {<(trip (to-b58:hash:t digest))>} + - parent: {<(trip (to-b58:hash:t parent))>} + - timestamp: {} + - msg: {} + - epoch-counter: {} + - target: {} + - proof-version: {} + - tx-ids: {tx-ids-tape} + """ +:: +++ command-to-path + |= command=peek-command + ^- (list @) + ?- -.command + %heavy /heavy + %block /block/[block-id.command] + %heaviest-block /heaviest-block + %heavy-n [%heavy-n page-number.command ~] + %blocks /blocks + %chknote /block-transactions/[block-id.command] + == +:: +++ command-description + |= command=peek-command + ^- @t + ?- -.command + %heavy 'heavy' + %block (cat 3 'block/' block-id.command) + %heaviest-block 'heaviest-block' + %heavy-n (cat 3 'heavy-n/' (scot %ud page-number.command)) + %blocks 'blocks' + %chknote (cat 3 'check-notes/' block-id.command) + == +:: +++ extract-input-nnames + |= tx=tx:t + ^- (z-set nname:t) + ?- -.tx + %0 ~(key z-by inputs.raw-tx.tx) + %1 ~|("v1 transactions not yet supported" !!) + == +:: +++ extract-output-nnames + |= tx=tx:t + ^- (z-set nname:t) + ?- -.tx + %0 =/ output-list=(list output:v0:t) ~(val z-by outputs.tx) + =/ nname-list=(list nname:t) + %+ turn output-list + |= out=output:v0:t + name.note.out + (~(gas z-in `(z-set nname:t)`~) nname-list) + %1 ~|("v1 transactions not yet supported" !!) + == +:: +++ find-problematic-txs + |= [txs=(z-map tx-id:t tx:t) intersection=(z-set nname:t)] + ^- (list [tx-id:t tx:t]) + =/ tx-list=(list [tx-id:t tx:t]) ~(tap z-by txs) + %+ skim tx-list + |= [tid=tx-id:t tx=tx:t] + =/ inputs=(z-set nname:t) (extract-input-nnames tx) + =/ outputs=(z-set nname:t) (extract-output-nnames tx) + =/ all-notes=(z-set nname:t) (~(uni z-in inputs) outputs) + =/ has-problematic=? + %- ~(any z-in intersection) + |=(n=nname:t (~(has z-in all-notes) n)) + has-problematic +:: +++ format-check-notes + |= $: block-id=@t + all-txs=(z-map tx-id:t tx:t) + all-inputs=(z-set nname:t) + all-outputs=(z-set nname:t) + intersection=(z-set nname:t) + problematic-txs=(list [tx-id:t tx:t]) + == + ^- tape + =/ intersection-count=@ud ~(wyt z-in intersection) + =/ input-count=@ud ~(wyt z-in all-inputs) + =/ output-count=@ud ~(wyt z-in all-outputs) + =/ tx-count=@ud ~(wyt z-by all-txs) + :: format all transactions + =/ all-tx-details=tape + =/ tx-list=(list [tx-id:t tx:t]) ~(tap z-by all-txs) + %+ roll tx-list + |= [[tid=tx-id:t tx=tx:t] acc=tape] + =/ tx-id-str=tape (trip (to-b58:hash:t tid)) + =/ [in-count=@ud out-count=@ud] + ?- -.tx + %0 :- ~(wyt z-by inputs.raw-tx.tx) + ~(wyt z-by outputs.tx) + %1 [0 0] + == + =/ tx-section=tape + """ + ## Transaction {tx-id-str} + - Inputs: {(a-co:co in-count)} + - Outputs: {(a-co:co out-count)} + + """ + (welp acc tx-section) + :: format all input notes + =/ input-notes-formatted=tape + =/ input-list=(list nname:t) ~(tap z-in all-inputs) + %+ roll input-list + |= [n=nname:t acc=tape] + =/ note-hash=tape <(to-b58:nname:t n)> + =/ note-line=tape + """ + - {note-hash} + + """ + (welp acc note-line) + :: format all output notes + =/ output-notes-formatted=tape + =/ output-list=(list nname:t) ~(tap z-in all-outputs) + %+ roll output-list + |= [n=nname:t acc=tape] + =/ note-hash=tape <(to-b58:nname:t n)> + =/ note-line=tape + """ + - {note-hash} + + """ + (welp acc note-line) + :: format intersection if any + =/ intersection-section=tape + ?: =(0 intersection-count) + """ + ## Intersection Analysis + No intersection found between input and output notes. + + """ + =/ intersection-list=(list nname:t) ~(tap z-in intersection) + =/ intersection-formatted=tape + %+ roll intersection-list + |= [n=nname:t acc=tape] + =/ note-hash=tape <(to-b58:nname:t n)> + =/ note-line=tape + """ + - {note-hash} + + """ + (welp acc note-line) + =/ problematic-details=tape + %+ roll problematic-txs + |= [[tid=tx-id:t tx=tx:t] acc=tape] + =/ tx-id-str=tape (trip (to-b58:hash:t tid)) + (welp acc (welp " - " (welp tx-id-str "\0a"))) + """ + ## Intersection Analysis + WARNING: Found {(a-co:co intersection-count)} note(s) in both inputs and outputs! + + ### Intersecting Notes: + {intersection-formatted} + ### Transactions with Intersecting Notes: + {problematic-details} + """ + =/ block-id-str=tape (trip block-id) + """ + # Check Notes + + ## Summary + - Total Transactions: {(a-co:co tx-count)} + - Total Input Notes: {(a-co:co input-count)} + - Total Output Notes: {(a-co:co output-count)} + + --- + + {intersection-section} + + --- + + ## All Transactions + + {all-tx-details} + ## All Input Notes ({(a-co:co input-count)}) + {input-notes-formatted} + ## All Output Notes ({(a-co:co output-count)}) + {output-notes-formatted} + + --- + + https://nockblocks.com/block/{block-id-str} + + + """ +-- diff --git a/hoon/apps/wallet/wallet.hoon b/hoon/apps/wallet/wallet.hoon index 570c871b2..43816eef5 100644 --- a/hoon/apps/wallet/wallet.hoon +++ b/hoon/apps/wallet/wallet.hoon @@ -618,6 +618,15 @@ %- (debug "send-tx: creating raw-tx") :: =/ raw=raw-tx:v1:transact (new:raw-tx:v1:transact p.dat.cause) + ?. (validate:raw-tx:v1:transact raw) + :_ state + :~ :- %markdown + %- crip + """ + Cannot send transaction: invalid raw-tx + """ + [%exit 1] + == =/ nock-cause=$>(%fact cause:dumb) [%fact %0 %heard-tx raw] %- (debug "send-tx: made raw-tx, sending poke request over grpc") From a4d0fddc7e37c7baaf76b85d7ea4de2deddef94f Mon Sep 17 00:00:00 2001 From: tacryt-socryp <1377557+tacryt-socryp@users.noreply.github.com> Date: Fri, 31 Oct 2025 08:27:00 -0500 Subject: [PATCH 11/99] Makefile: nockchain-peek added to hoon_targets --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 9df41f222..7b4d32aa8 100644 --- a/Makefile +++ b/Makefile @@ -84,7 +84,7 @@ build-trivial: ensure-dirs echo '%trivial' > hoon/trivial.hoon hoonc --arbitrary hoon/trivial.hoon -HOON_TARGETS=assets/dumb.jam assets/wal.jam assets/miner.jam +HOON_TARGETS=assets/dumb.jam assets/wal.jam assets/miner.jam assets/nockchain-peek.jam .PHONY: nuke-hoonc-data nuke-hoonc-data: From cd13983ad11007e98afeb75bd07e5335be6e6059 Mon Sep 17 00:00:00 2001 From: tacryt-socryp <1377557+tacryt-socryp@users.noreply.github.com> Date: Mon, 3 Nov 2025 14:18:06 -0600 Subject: [PATCH 12/99] nockchain-wallet: watch-only addresses work for v1 --- README.md | 8 + crates/nockapp-grpc-proto/src/v1/convert.rs | 12 +- crates/nockapp-grpc-proto/src/v2/convert.rs | 101 +++---- .../src/services/public_nockchain/v2/mod.rs | 1 - .../services/public_nockchain/v2/server.rs | 2 +- .../nockchain-types/src/tx_engine/v1/note.rs | 7 +- crates/nockchain-wallet/README.md | 8 +- crates/nockchain-wallet/src/command.rs | 12 +- crates/nockchain-wallet/src/main.rs | 274 +++++++++--------- hoon/apps/wallet/lib/types.hoon | 2 +- hoon/apps/wallet/lib/utils.hoon | 10 +- hoon/apps/wallet/wallet.hoon | 48 ++- 12 files changed, 230 insertions(+), 255 deletions(-) diff --git a/README.md b/README.md index c2bc2b65b..da85b1984 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,14 @@ nockchain-wallet keygen This will print a new public/private key pair + chain code to the console, as well as the seed phrase for the private key. +To track a watch-only address or pubkey without importing private material: + +``` +nockchain-wallet watch-address +``` + +The wallet normalizes the identifier so you can supply either a v1 payee hash or a schnorr pubkey. + Use `.env_example` as a template and copy your pkh to the `.env` file. Then, in your `.env` file, set the `MINING_PKH` variable to the address of the v1 key you generated. diff --git a/crates/nockapp-grpc-proto/src/v1/convert.rs b/crates/nockapp-grpc-proto/src/v1/convert.rs index 3e1bf69f9..48261c647 100644 --- a/crates/nockapp-grpc-proto/src/v1/convert.rs +++ b/crates/nockapp-grpc-proto/src/v1/convert.rs @@ -580,12 +580,12 @@ impl TryFrom for v0::Timelock { })) } Some(time_lock_intent::Value::AbsoluteAndRelative(both)) => { - let abs = both.absolute.ok_or(ConversionError::Invalid( - "absolute not present in AbsoluteAndRelative", - ))?; - let rel = both.relative.ok_or(ConversionError::Invalid( - "relative not present in AbsoluteAndRelative", - ))?; + let abs = both + .absolute + .required("AbsoluteAndRelative", "absolute")?; + let rel = both + .relative + .required("AbsoluteAndRelative", "relative")?; v0::Timelock(Some(v0::TimelockIntent { absolute: abs.into(), relative: rel.into(), diff --git a/crates/nockapp-grpc-proto/src/v2/convert.rs b/crates/nockapp-grpc-proto/src/v2/convert.rs index f8a382e18..78ba66163 100644 --- a/crates/nockapp-grpc-proto/src/v2/convert.rs +++ b/crates/nockapp-grpc-proto/src/v2/convert.rs @@ -8,7 +8,7 @@ use nockchain_types::tx_engine::v1::{ }; use nockchain_types::{v0, v1}; -use crate::common::ConversionError; +use crate::common::{ConversionError, Required}; use crate::pb::common::v1::{ BlockHeight as PbBlockHeight, Hash as PbHash, Name as PbName, Nicks as PbNicks, NoteVersion as PbNoteVersion, PageResponse as PbPageResponse, SchnorrPubkey as PbSchnorrPubkey, @@ -102,25 +102,21 @@ impl TryFrom for Note { fn try_from(note: PbNote) -> Result { match note .note_version - .ok_or(ConversionError::Invalid("missing note_version"))? + .required("Note", "note_version")? { note::NoteVersion::Legacy(legacy) => Ok(Note::V0(legacy.try_into()?)), note::NoteVersion::V1(v1) => Ok(Note::V1(NoteV1 { version: v1::Version::V1, origin_page: v1::BlockHeight(Belt( v1.origin_page - .ok_or(ConversionError::Invalid("missing origin_page"))? + .required("NoteV1", "origin_page")? .value, )), - name: v0::Name::try_from(v1.name.ok_or(ConversionError::Invalid("missing name"))?)?, + name: v0::Name::try_from(v1.name.required("NoteV1", "name")?)?, note_data: NoteData::try_from( - v1.note_data - .ok_or(ConversionError::Invalid("missing note_data"))?, + v1.note_data.required("NoteV1", "note_data")?, )?, - assets: v1 - .assets - .ok_or(ConversionError::Invalid("missing assets"))? - .into(), + assets: v1.assets.required("NoteV1", "assets")?.into(), })), } } @@ -134,22 +130,17 @@ impl TryFrom for BalanceUpdate { .into_iter() .map(|be| -> Result<(v1::Name, v1::Note), ConversionError> { Ok(( - v0::Name::try_from(be.name.ok_or(ConversionError::Invalid("missing name"))?)?, - v1::Note::try_from(be.note.ok_or(ConversionError::Invalid("missing note"))?)?, + v1::Name::try_from(be.name.required("BalanceEntry", "name")?)?, + v1::Note::try_from(be.note.required("BalanceEntry", "note")?)?, )) }) .collect::, _>>()?; Ok(BalanceUpdate { height: v1::BlockHeight(Belt( - update - .height - .ok_or(ConversionError::Invalid("missing height"))? - .value, + update.height.required("Balance", "height")?.value, )), - block_id: v0::Hash::try_from( - update - .block_id - .ok_or(ConversionError::Invalid("missing block_id"))?, + block_id: v1::Hash::try_from( + update.block_id.required("Balance", "block_id")?, )?, notes: v1::Balance(notes), }) @@ -371,20 +362,14 @@ impl TryFrom for V1Seed { Ok(V1Seed { output_source: seed.output_source.map(|s| s.try_into()).transpose()?, lock_root: v1::Hash::try_from( - seed.lock_root - .ok_or(ConversionError::Invalid("missing lock_root"))?, + seed.lock_root.required("Seed", "lock_root")?, )?, note_data: NoteData::try_from( - seed.note_data - .ok_or(ConversionError::Invalid("missing note_data"))?, + seed.note_data.required("Seed", "note_data")?, )?, - gift: seed - .gift - .ok_or(ConversionError::Invalid("missing gift"))? - .into(), + gift: seed.gift.required("Seed", "gift")?.into(), parent_hash: v1::Hash::try_from( - seed.parent_hash - .ok_or(ConversionError::Invalid("missing parent_hash"))?, + seed.parent_hash.required("Seed", "parent_hash")?, )?, }) } @@ -394,14 +379,14 @@ impl TryFrom for PkhSignatureEntry { type Error = ConversionError; fn try_from(entry: PbPkhSignatureEntry) -> Result { Ok(PkhSignatureEntry { - hash: v1::Hash::try_from(entry.hash.ok_or(ConversionError::Invalid("missing hash"))?)?, + hash: v1::Hash::try_from(entry.hash.required("PkhSignatureEntry", "hash")?)?, pubkey: entry .pubkey - .ok_or(ConversionError::Invalid("missing pubkey"))? + .required("PkhSignatureEntry", "pubkey")? .try_into()?, signature: entry .signature - .ok_or(ConversionError::Invalid("missing signature"))? + .required("PkhSignatureEntry", "signature")? .try_into()?, }) } @@ -424,9 +409,7 @@ impl TryFrom for V1HaxPreimage { fn try_from(preimage: PbHaxPreimage) -> Result { Ok(V1HaxPreimage { hash: v1::Hash::try_from( - preimage - .hash - .ok_or(ConversionError::Invalid("missing hash"))?, + preimage.hash.required("HaxPreimage", "hash")?, )?, value: preimage.value.into(), }) @@ -437,7 +420,7 @@ impl TryFrom for MerkleProof { type Error = ConversionError; fn try_from(proof: PbMerkleProof) -> Result { Ok(MerkleProof { - root: v1::Hash::try_from(proof.root.ok_or(ConversionError::Invalid("missing root"))?)?, + root: v1::Hash::try_from(proof.root.required("MerkleProof", "root")?)?, path: proof .path .into_iter() @@ -452,7 +435,7 @@ impl TryFrom for LockPrimitive { fn try_from(primitive: PbLockPrimitive) -> Result { match primitive .primitive - .ok_or(ConversionError::Invalid("missing primitive"))? + .required("LockPrimitive", "primitive")? { lock_primitive::Primitive::Pkh(pkh) => { let hashes = pkh @@ -463,14 +446,8 @@ impl TryFrom for LockPrimitive { Ok(LockPrimitive::Pkh(Pkh { m: pkh.m, hashes })) } lock_primitive::Primitive::Tim(tim) => Ok(LockPrimitive::Tim(LockTim { - rel: tim - .rel - .ok_or(ConversionError::Invalid("missing rel"))? - .into(), - abs: tim - .abs - .ok_or(ConversionError::Invalid("missing abs"))? - .into(), + rel: tim.rel.required("LockTim", "rel")?.into(), + abs: tim.abs.required("LockTim", "abs")?.into(), })), lock_primitive::Primitive::Hax(hax) => { let hashes = hax @@ -504,13 +481,11 @@ impl TryFrom for LockMerkleProof { spend_condition: SpendCondition::try_from( proof .spend_condition - .ok_or(ConversionError::Invalid("missing spend_condition"))?, + .required("LockMerkleProof", "spend_condition")?, )?, axis: proof.axis, proof: MerkleProof::try_from( - proof - .proof - .ok_or(ConversionError::Invalid("missing proof"))?, + proof.proof.required("LockMerkleProof", "proof")?, )?, }) } @@ -523,12 +498,12 @@ impl TryFrom for V1Witness { lock_merkle_proof: LockMerkleProof::try_from( witness .lock_merkle_proof - .ok_or(ConversionError::Invalid("missing lock_merkle_proof"))?, + .required("Witness", "lock_merkle_proof")?, )?, pkh_signature: PkhSignature::try_from( witness .pkh_signature - .ok_or(ConversionError::Invalid("missing pkh_signature"))?, + .required("Witness", "pkh_signature")?, )?, hax: witness .hax @@ -551,12 +526,12 @@ impl TryFrom for Spend0 { Ok(Spend0 { signature: spend .signature - .ok_or(ConversionError::Invalid("missing signature"))? + .required("LegacySpend", "signature")? .try_into()?, seeds: nockchain_types::tx_engine::v1::Seeds(seeds), fee: spend .fee - .ok_or(ConversionError::Invalid("missing fee"))? + .required("LegacySpend", "fee")? .into(), }) } @@ -574,12 +549,12 @@ impl TryFrom for Spend1 { witness: V1Witness::try_from( spend .witness - .ok_or(ConversionError::Invalid("missing witness"))?, + .required("WitnessSpend", "witness")?, )?, seeds: nockchain_types::tx_engine::v1::Seeds(seeds), fee: spend .fee - .ok_or(ConversionError::Invalid("missing fee"))? + .required("WitnessSpend", "fee")? .into(), }) } @@ -590,7 +565,7 @@ impl TryFrom for V1Spend { fn try_from(spend: PbSpend) -> Result { match spend .spend_kind - .ok_or(ConversionError::Invalid("missing spend_kind"))? + .required("Spend", "spend_kind")? { spend::SpendKind::Legacy(legacy) => Ok(V1Spend::Legacy(Spend0::try_from(legacy)?)), spend::SpendKind::Witness(witness) => Ok(V1Spend::Witness(Spend1::try_from(witness)?)), @@ -602,12 +577,8 @@ impl TryFrom for (Name, V1Spend) { type Error = ConversionError; fn try_from(entry: PbSpendEntry) -> Result { Ok(( - v0::Name::try_from(entry.name.ok_or(ConversionError::Invalid("missing name"))?)?, - V1Spend::try_from( - entry - .spend - .ok_or(ConversionError::Invalid("missing spend"))?, - )?, + v0::Name::try_from(entry.name.required("SpendEntry", "name")?)?, + V1Spend::try_from(entry.spend.required("SpendEntry", "spend")?)?, )) } } @@ -617,7 +588,7 @@ impl TryFrom for V1RawTx { fn try_from(tx: PbRawTransaction) -> Result { let version_value = tx .version - .ok_or(ConversionError::Invalid("missing version"))? + .required("RawTransaction", "version")? .value; let version = match version_value { @@ -633,7 +604,7 @@ impl TryFrom for V1RawTx { Ok(V1RawTx { version, - id: v0::Hash::try_from(tx.id.ok_or(ConversionError::Invalid("missing id"))?)?, + id: v0::Hash::try_from(tx.id.required("RawTransaction", "id")?)?, spends: nockchain_types::tx_engine::v1::Spends(spends), }) } diff --git a/crates/nockapp-grpc/src/services/public_nockchain/v2/mod.rs b/crates/nockapp-grpc/src/services/public_nockchain/v2/mod.rs index 58b6b03a1..a673b51c0 100644 --- a/crates/nockapp-grpc/src/services/public_nockchain/v2/mod.rs +++ b/crates/nockapp-grpc/src/services/public_nockchain/v2/mod.rs @@ -7,7 +7,6 @@ pub mod server; #[cfg(test)] pub(crate) mod fixtures { use nockchain_math::belt::Belt; - use nockchain_math::crypto::cheetah::A_GEN; use nockchain_types::tx_engine::v1; pub fn make_balance_update(count: usize) -> (v1::BalanceUpdate, Vec) { diff --git a/crates/nockapp-grpc/src/services/public_nockchain/v2/server.rs b/crates/nockapp-grpc/src/services/public_nockchain/v2/server.rs index f02305c13..609bf4990 100644 --- a/crates/nockapp-grpc/src/services/public_nockchain/v2/server.rs +++ b/crates/nockapp-grpc/src/services/public_nockchain/v2/server.rs @@ -1034,7 +1034,7 @@ mod tests { use nockchain_types::v1::Hash; use super::*; - use crate::pb::common::{v1 as pb_common_v1, v2 as pb_common_v2}; + use crate::pb::common::v1 as pb_common_v1; use crate::public_nockchain::v1::fixtures as fixtures_v1; use crate::public_nockchain::v2::fixtures; use crate::v2::pagination::cmp_name; diff --git a/crates/nockchain-types/src/tx_engine/v1/note.rs b/crates/nockchain-types/src/tx_engine/v1/note.rs index 47853d98d..bfc49a963 100644 --- a/crates/nockchain-types/src/tx_engine/v1/note.rs +++ b/crates/nockchain-types/src/tx_engine/v1/note.rs @@ -40,12 +40,7 @@ impl NounDecode for Balance { .map(|kv| { let [k, v] = kv.uncell()?; let name = Name::from_noun(&k)?; - let cell = v.as_cell()?; - let note = match cell.head().as_direct() { - Ok(tag) if tag.data() == 0 => Note::V0(NoteV0::from_noun(&v)?), - Ok(tag) if tag.data() == 1 => Note::V1(NoteV1::from_noun(&v)?), - _ => return Err(NounDecodeError::InvalidTag), - }; + let note = ::from_noun(&v)?; Ok((name, note)) }) diff --git a/crates/nockchain-wallet/README.md b/crates/nockchain-wallet/README.md index 6994eecf9..82fe84a95 100644 --- a/crates/nockchain-wallet/README.md +++ b/crates/nockchain-wallet/README.md @@ -30,8 +30,8 @@ nockchain-wallet import-keys --key "zprv..." # importing the seed phrase again with version 1. nockchain-wallet import-keys --seedphrase "your seed phrase here" --version -# Import a watch-only public address -nockchain-wallet import-keys --watch-only +# Import a watch-only address or pubkey +nockchain-wallet watch-address # Import a master public key from exported file nockchain-wallet import-master-pubkey keys.export @@ -143,13 +143,13 @@ Shows only the notes associated with the specified public key. Useful for filter ### List Arbitrary Notes by Public Key (Watch-Only) ```bash -nockchain-wallet import-keys --watch-only
+nockchain-wallet watch-address
nockchain-wallet list-notes-by-address
--include-watch-only ``` Shows only the notes associated with the specified public key. Useful for filtering wallet contents by address or for multisig scenarios. -You must add the watch-only pubkey to the wallet before it will be recognized. +You must add the watch-only identifier to the wallet before it will be recognized. ### List Notes by Public Key (CSV format) diff --git a/crates/nockchain-wallet/src/command.rs b/crates/nockchain-wallet/src/command.rs index 9fed5f37b..e89904671 100644 --- a/crates/nockchain-wallet/src/command.rs +++ b/crates/nockchain-wallet/src/command.rs @@ -254,7 +254,7 @@ pub enum Commands { }, /// Import keys from a file, extended key, seed phrase, or master private key - #[command(group = clap::ArgGroup::new("import_source").required(true).args(&["file", "key", "seedphrase", "watch_only_pubkey"]))] + #[command(group = clap::ArgGroup::new("import_source").required(true).args(&["file", "key", "seedphrase"]))] ImportKeys { /// Path to the jammed keys file #[arg(short = 'f', long = "file", value_name = "FILE")] @@ -273,10 +273,13 @@ pub enum Commands { /// Master key version to use when generating from seed phrase #[arg(long = "version", value_name = "VERSION", requires = "seedphrase")] version: Option, + }, - /// Pubkey (watch only) - #[arg(short = 'c', long = "watch-only", value_name = "WATCH_ONLY")] - watch_only_pubkey: Option, + /// Add a watch-only address or pubkey to the wallet + WatchAddress { + /// Public key hash (v1 address) or schnorr pubkey (v0 address). base58 encoded. + #[arg(value_name = "address")] + address: String, }, /// Export keys to a file @@ -516,6 +519,7 @@ impl Commands { Commands::SignHash { .. } => "sign-hash", Commands::VerifyHash { .. } => "verify-hash", Commands::TxAccepted { .. } => "tx-accepted", + Commands::WatchAddress { .. } => "watch-address", } } } diff --git a/crates/nockchain-wallet/src/main.rs b/crates/nockchain-wallet/src/main.rs index 4da2bd18a..0e384b513 100644 --- a/crates/nockchain-wallet/src/main.rs +++ b/crates/nockchain-wallet/src/main.rs @@ -77,6 +77,7 @@ async fn main() -> Result<(), NockAppError> { Commands::Keygen | Commands::DeriveChild { .. } | Commands::ImportKeys { .. } + | Commands::WatchAddress { .. } | Commands::ExportKeys | Commands::SignMessage { .. } | Commands::VerifyMessage { .. } @@ -209,7 +210,6 @@ async fn main() -> Result<(), NockAppError> { key, seedphrase, version, - watch_only_pubkey, } => { if let Some(file_path) = file { Wallet::import_keys(file_path) @@ -224,10 +224,6 @@ async fn main() -> Result<(), NockAppError> { // normalize seedphrase to have exactly one space between words let normalized_seed = seed.split_whitespace().collect::>().join(" "); Wallet::import_seed_phrase(&normalized_seed, version) - } else if let Some(pubkey) = watch_only_pubkey { - let _ = SchnorrPubkey::from_base58(pubkey) - .map_err(|e| CrownError::Unknown(format!("Invalid public key: {}", e)))?; - Wallet::import_watch_only_pubkey(&pubkey) } else { return Err(CrownError::Unknown( "One of --file, --key, --seedphrase, or --master-privkey must be provided for import-keys".to_string(), @@ -235,6 +231,12 @@ async fn main() -> Result<(), NockAppError> { .into()); } } + Commands::WatchAddress { address } => match normalize_watch_address(address.clone())? { + Some(normalized) => Wallet::watch_address(&normalized), + None => { + return Err(CrownError::Unknown("Invalid watch identifier provided".into()).into()); + } + }, Commands::ExportKeys => Wallet::export_keys(), Commands::ListNotes => Wallet::list_notes(), Commands::ListNotesByAddress { address } => { @@ -290,42 +292,44 @@ async fn main() -> Result<(), NockAppError> { pubkey_peek_slab.set_root(path); let pubkey_slab = wallet.app.peek_handle(pubkey_peek_slab).await?; - let first_name_slab = if pubkey_slab.is_some() { - let mut first_name_peek_slab = NounSlab::new(); - let tracked_tag = make_tas(&mut first_name_peek_slab, "tracked-names").as_noun(); - let watch_only = cli.include_watch_only.to_noun(&mut first_name_peek_slab); - let path = T(&mut first_name_peek_slab, &[tracked_tag, watch_only, SIG]); - first_name_peek_slab.set_root(path); - wallet.app.peek_handle(first_name_peek_slab).await? - } else { - None - }; + let mut first_name_peek_slab = NounSlab::new(); + let tracked_tag = make_tas(&mut first_name_peek_slab, "tracked-names").as_noun(); + let watch_only = cli.include_watch_only.to_noun(&mut first_name_peek_slab); + let path = T(&mut first_name_peek_slab, &[tracked_tag, watch_only, SIG]); + first_name_peek_slab.set_root(path); + let first_name_slab = wallet.app.peek_handle(first_name_peek_slab).await?; - if let Some(pubkey_slab) = pubkey_slab { - let pubkeys = pubkey_slab + let pubkeys = if let Some(pubkey_slab) = pubkey_slab { + pubkey_slab .to_vec() .iter() .map(|key| String::from_noun(unsafe { key.root() })) - .collect::, NounDecodeError>>()?; + .collect::, NounDecodeError>>()? + .into_iter() + .filter_map(|value| match normalize_watch_address(value) { + Ok(Some(normalized)) => Some(Ok(normalized)), + Ok(None) => None, + Err(err) => Some(Err(err)), + }) + .collect::, NockAppError>>()? + } else { + Vec::new() + }; - let first_names: Vec = if let Some(name_slab) = first_name_slab { - let names_noun = unsafe { name_slab.root() }; - >::from_noun(names_noun)? - } else { - Vec::new() - }; + let first_names: Vec = if let Some(name_slab) = first_name_slab { + let names_noun = unsafe { name_slab.root() }; + >::from_noun(names_noun)? + } else { + Vec::new() + }; - let connection_target = cli.connection.target(); - let pokes = connection::sync_wallet_balance( - &mut wallet, &connection_target, pubkeys, first_names, - ) - .await?; + let connection_target = cli.connection.target(); + let pokes = + connection::sync_wallet_balance(&mut wallet, &connection_target, pubkeys, first_names) + .await?; - for poke in pokes { - let _ = wallet.app.poke(SystemWire.to_wire(), poke).await.unwrap(); - } - } else { - info!("No pubkeys found, not updating balance") + for poke in pokes { + let _ = wallet.app.poke(SystemWire.to_wire(), poke).await.unwrap(); } } @@ -677,16 +681,11 @@ impl Wallet { /// /// # Arguments /// - /// * `watch_pubkey` - Watch-only b58 encoded public key string - fn import_watch_only_pubkey(watch_pubkey: &str) -> CommandNoun { + /// * `watch_address` - Watch-only b58 encoded address. Can be v1 or v0. + fn watch_address(watch_address: &str) -> CommandNoun { let mut slab = NounSlab::new(); - let key_noun = make_tas(&mut slab, watch_pubkey).as_noun(); - Self::wallet( - "import-watch-only-pubkey", - &[key_noun], - Operation::Poke, - &mut slab, - ) + let address_noun = make_tas(&mut slab, watch_address).as_noun(); + Self::wallet("watch-address", &[address_noun], Operation::Poke, &mut slab) } /// Exports keys to a file. @@ -877,50 +876,42 @@ impl Wallet { ) -> Result, NockAppError> { let mut results = Vec::new(); - if !first_names.is_empty() { - for first_name in first_names { - let mut slab = NounSlab::new(); // Define slab - adjust as needed - let response = client - .wallet_get_balance(&BalanceRequest::FirstName(first_name)) - .await - .map_err(|e| { - NockAppError::OtherError(format!( - "Failed to request current balance: {}", - e - )) - })?; - let balance_update = v1::BalanceUpdate::try_from(response).map_err(|e| { - NockAppError::OtherError(format!("Failed to parse balance update: {}", e)) + for first_name in first_names { + let mut slab = NounSlab::new(); // Define slab - adjust as needed + let response = client + .wallet_get_balance(&BalanceRequest::FirstName(first_name)) + .await + .map_err(|e| { + NockAppError::OtherError(format!("Failed to request current balance: {}", e)) })?; - let wrapped_balance = Some(Some(balance_update)); - let balance_noun = wrapped_balance.to_noun(&mut slab); - let head = make_tas(&mut slab, "update-balance-grpc").as_noun(); - let full = T(&mut slab, &[head, balance_noun]); - slab.set_root(full); - results.push(slab); - } - } else { - for (_index, key) in pubkeys.iter().enumerate() { - let mut slab = NounSlab::new(); // Define slab - adjust as needed - let response = client - .wallet_get_balance(&BalanceRequest::Address(key.to_owned())) - .await - .map_err(|e| { - NockAppError::OtherError(format!( - "Failed to request current balance: {}", - e - )) - })?; - let balance_update = v1::BalanceUpdate::try_from(response).map_err(|e| { - NockAppError::OtherError(format!("Failed to parse balance update: {}", e)) + let balance_update = v1::BalanceUpdate::try_from(response).map_err(|e| { + NockAppError::OtherError(format!("Failed to parse balance update: {}", e)) + })?; + let wrapped_balance = Some(Some(balance_update)); + let balance_noun = wrapped_balance.to_noun(&mut slab); + let head = make_tas(&mut slab, "update-balance-grpc").as_noun(); + let full = T(&mut slab, &[head, balance_noun]); + slab.set_root(full); + results.push(slab); + } + + for (_index, key) in pubkeys.iter().enumerate() { + let mut slab = NounSlab::new(); // Define slab - adjust as needed + let response = client + .wallet_get_balance(&BalanceRequest::Address(key.to_owned())) + .await + .map_err(|e| { + NockAppError::OtherError(format!("Failed to request current balance: {}", e)) })?; - let wrapped_balance = Some(Some(balance_update)); - let balance_noun = wrapped_balance.to_noun(&mut slab); - let head = make_tas(&mut slab, "update-balance-grpc").as_noun(); - let full = T(&mut slab, &[head, balance_noun]); - slab.set_root(full); - results.push(slab); - } + let balance_update = v1::BalanceUpdate::try_from(response).map_err(|e| { + NockAppError::OtherError(format!("Failed to parse balance update: {}", e)) + })?; + let wrapped_balance = Some(Some(balance_update)); + let balance_noun = wrapped_balance.to_noun(&mut slab); + let head = make_tas(&mut slab, "update-balance-grpc").as_noun(); + let full = T(&mut slab, &[head, balance_noun]); + slab.set_root(full); + results.push(slab); } Ok(results) @@ -939,57 +930,47 @@ impl Wallet { let mut request_index: i32 = 0; let mut results = Vec::new(); - if first_names.is_empty() { - warn!("No tracked first names available; skipping balance-by-first-name peeks"); - } else { - for first_name in first_names { - let mut slab = NounSlab::new(); - - let mut path_slab = NounSlab::::new(); - let path_noun = vec!["balance-by-first-name".to_string(), first_name.clone()] - .to_noun(&mut path_slab); - path_slab.set_root(path_noun); - let path_bytes = path_slab.jam().to_vec(); - - let response = client.peek(request_index, path_bytes).await.map_err(|e| { - NockAppError::OtherError(format!( - "Failed to peek balance for first name {first_name}: {e}" - )) - })?; - request_index = request_index.wrapping_add(1); + for first_name in first_names { + let mut slab = NounSlab::new(); - let balance = slab.cue_into(response.as_bytes()?)?; - let head = make_tas(&mut slab, "update-balance-grpc").as_noun(); - let full = T(&mut slab, &[head, balance]); - slab.set_root(full); - results.push(slab); - } + let mut path_slab = NounSlab::::new(); + let path_noun = vec!["balance-by-first-name".to_string(), first_name.clone()] + .to_noun(&mut path_slab); + path_slab.set_root(path_noun); + let path_bytes = path_slab.jam().to_vec(); + + let response = client.peek(request_index, path_bytes).await.map_err(|e| { + NockAppError::OtherError(format!( + "Failed to peek balance for first name {first_name}: {e}" + )) + })?; + request_index = request_index.wrapping_add(1); + + let balance = slab.cue_into(response.as_bytes()?)?; + let head = make_tas(&mut slab, "update-balance-grpc").as_noun(); + let full = T(&mut slab, &[head, balance]); + slab.set_root(full); + results.push(slab); } - if pubkeys.is_empty() { - warn!("No tracked pubkeys available; skipping balance-by-pubkey peeks"); - } else { - for key in pubkeys { - let mut slab = NounSlab::new(); - let mut path_slab = NounSlab::::new(); - let path_noun = - vec!["balance-by-pubkey".to_string(), key.clone()].to_noun(&mut path_slab); - path_slab.set_root(path_noun); - let path_bytes = path_slab.jam().to_vec(); - - let response = client.peek(request_index, path_bytes).await.map_err(|e| { - NockAppError::OtherError(format!( - "Failed to peek balance for pubkey {key}: {e}" - )) - })?; - request_index = request_index.wrapping_add(1); + for key in pubkeys { + let mut slab = NounSlab::new(); + let mut path_slab = NounSlab::::new(); + let path_noun = + vec!["balance-by-pubkey".to_string(), key.clone()].to_noun(&mut path_slab); + path_slab.set_root(path_noun); + let path_bytes = path_slab.jam().to_vec(); - let balance = slab.cue_into(response.as_bytes()?)?; - let head = make_tas(&mut slab, "update-balance-grpc").as_noun(); - let full = T(&mut slab, &[head, balance]); - slab.set_root(full); - results.push(slab); - } + let response = client.peek(request_index, path_bytes).await.map_err(|e| { + NockAppError::OtherError(format!("Failed to peek balance for pubkey {key}: {e}")) + })?; + request_index = request_index.wrapping_add(1); + + let balance = slab.cue_into(response.as_bytes()?)?; + let head = make_tas(&mut slab, "update-balance-grpc").as_noun(); + let full = T(&mut slab, &[head, balance]); + slab.set_root(full); + results.push(slab); } Ok(results) @@ -1186,6 +1167,32 @@ fn confirm_upper_bound_warning() -> Result<(), NockAppError> { } } +fn normalize_watch_address(value: String) -> Result, NockAppError> { + if value.as_bytes().len() >= SchnorrPubkey::BYTES_BASE58 { + match SchnorrPubkey::from_base58(&value) { + Ok(pubkey) => pubkey + .to_base58() + .map(Some) + .map_err(|err| NockAppError::OtherError(err.to_string())), + Err(err) => { + warn!( + "Skipping invalid watch-only schnorr pubkey '{}': {}", + value, err + ); + Ok(None) + } + } + } else { + match Hash::from_base58(&value) { + Ok(hash) => Ok(Some(hash.to_base58())), + Err(err) => { + warn!("Skipping invalid watch-only hash '{}': {}", value, err); + Ok(None) + } + } + } +} + async fn run_transaction_accepted( connection: &connection::ConnectionCli, tx_id: &str, @@ -1578,7 +1585,6 @@ mod tests { key: None, seedphrase: Some(seedphrase.to_string()), version: Some(version), - watch_only_pubkey: None, }) .to_wire(); let privkey_result = wallet.app.poke(wire, noun.clone()).await?; @@ -1614,7 +1620,6 @@ mod tests { key: None, seedphrase: None, version: None, - watch_only_pubkey: None, }) .to_wire(); let import_result = wallet.app.poke(wire, noun.clone()).await?; @@ -1706,7 +1711,6 @@ mod tests { key: None, seedphrase: Some("correct horse battery staple".to_string()), version: Some(version), - watch_only_pubkey: None, }) .to_wire(); let genkey_result = wallet.app.poke(wire1, genkey_noun.clone()).await?; diff --git a/hoon/apps/wallet/lib/types.hoon b/hoon/apps/wallet/lib/types.hoon index 89a55612c..fd0bd41d8 100644 --- a/hoon/apps/wallet/lib/types.hoon +++ b/hoon/apps/wallet/lib/types.hoon @@ -364,7 +364,7 @@ [%derive-child i=@ hardened=? label=(unit @tas)] [%import-keys keys=(list (pair trek *))] [%import-extended extended-key=@t] :: extended key string - [%import-watch-only-pubkey key=@t] :: imports base58-encoded pubkey + [%watch-address address=@t] :: imports base58-encoded pubkey [%export-keys ~] [%export-master-pubkey ~] [%import-master-pubkey coil=*] :: base58-encoded pubkey + chain code diff --git a/hoon/apps/wallet/lib/utils.hoon b/hoon/apps/wallet/lib/utils.hoon index 83c620abb..a1747188c 100644 --- a/hoon/apps/wallet/lib/utils.hoon +++ b/hoon/apps/wallet/lib/utils.hoon @@ -171,7 +171,7 @@ ^- meta:wt (~(got of keys.state) seed-path) :: - ++ watch-keys + ++ watch-addrs ^- (list @t) =/ subtree (~(kids of keys.state) watch-path) %+ turn @@ -225,11 +225,11 @@ (welp key-path /label) label/u.label :: - ++ watch-key - |= b58-key=@t + ++ watch-addrs + |= b58-addr=@t %+ ~(put of keys.state) - (welp watch-path ~[t/b58-key]) - [%watch-key b58-key] + (welp watch-path ~[t/b58-addr]) + [%watch-key b58-addr] -- :: ++ get-note diff --git a/hoon/apps/wallet/wallet.hoon b/hoon/apps/wallet/wallet.hoon index 43816eef5..5aee27d5e 100644 --- a/hoon/apps/wallet/wallet.hoon +++ b/hoon/apps/wallet/wallet.hoon @@ -150,14 +150,16 @@ ?. include-watch-only.pole signing-names %+ weld signing-names - %+ turn watch-keys:get:v - |= addr=@t + %+ roll watch-addrs:get:v + |= [addr=@t first-names=(list @t)] :: v0 keys have at least 132 bytes ?: (gte (met 3 addr) 132) - =+ pubkey=(from-b58:schnorr-pubkey:transact addr) - (to-b58:hash:transact (simple:v0:first-name:transact pubkey)) + :: exclude names for v0 keys because those are handled through tracked pubkeys + first-names =+ pubkey-hash=(from-b58:hash:transact addr) - (to-b58:hash:transact (simple:v1:first-name:transact pubkey-hash)) + :+ (to-b58:hash:transact (simple:v1:first-name:transact pubkey-hash)) + (to-b58:hash:transact (coinbase:v1:first-name:transact pubkey-hash)) + first-names :: :: returns a list of pubkeys [%tracked-pubkeys include-watch-only=? ~] @@ -166,7 +168,12 @@ =; signing-keys=(list @t) ?. include-watch-only.pole signing-keys - (weld signing-keys watch-keys:get:v) + %+ weld signing-keys + %+ murn watch-addrs:get:v + |= addr=@t + ?: (lth (met 3 addr) 132) + ~ + `addr %+ murn ~(coils get:v %pub) |= =coil:wt @@ -214,7 +221,7 @@ %verify-hash (do-verify-hash cause) %import-keys (do-import-keys cause) %import-extended (do-import-extended cause) - %import-watch-only-pubkey (do-import-watch-only-pubkey cause) + %watch-address (do-watch-address cause) %export-keys (do-export-keys cause) %export-master-pubkey (do-export-master-pubkey cause) %import-master-pubkey (do-import-master-pubkey cause) @@ -369,16 +376,16 @@ [%exit 0] == :: - ++ do-import-watch-only-pubkey + ++ do-watch-address |= =cause:wt - ?> ?=(%import-watch-only-pubkey -.cause) - :_ state(keys (watch-key:put:v key.cause)) + ?> ?=(%watch-address -.cause) + :_ state(keys (watch-addrs:put:v address.cause)) :~ :- %markdown %- crip """ ## Imported watch-only pubkey - - Imported key: {} + - Imported key: {} """ [%exit 0] == @@ -671,8 +678,8 @@ --- """ - =/ base58-watch-keys=(list tape) - %+ turn watch-keys:get:v + =/ base58-watch-addrs=(list tape) + %+ turn watch-addrs:get:v |= key-b58=@t """ - {} @@ -689,7 +696,7 @@ ## Addresses -- Watch only - {?~(base58-watch-keys "No pubkeys found" (zing base58-watch-keys))} + {?~(base58-watch-addrs "No pubkeys found" (zing base58-watch-addrs))} """ [%exit 0] == @@ -1126,19 +1133,6 @@ """ [%exit 0] == - ?: ?=(%1 -.u.active-master.state) - :_ state - :~ :- %markdown - %- crip - """ - Cannot sign a message with v1 keys until forthcoming wallet update. Use the `list-master-addresses` command to list - your master addresses. Then use `set-active-master-address` to set your master address to an address corresponding - to a v0 key if available. If you have a v0 key stored as a seed phrase, you can import it by running - `nockchain-wallet import-keys --seedphrase --version 0`. If your key was generated before the - release of the v1 protocol upgrade on October 15, 2025, it is most likely a v0 key. - """ - [%exit 0] - == =/ sk=schnorr-seckey:transact (sign-key:get:v sign-key.cause) =/ msg-belts=page-msg:transact (new:page-msg:transact `cord`msg.cause) ?. (validate:page-msg:transact msg-belts) From 15d525e1d45feea65ae740394a027bb201098323 Mon Sep 17 00:00:00 2001 From: tacryt-socryp <1377557+tacryt-socryp@users.noreply.github.com> Date: Mon, 3 Nov 2025 16:24:34 -0600 Subject: [PATCH 13/99] cargo fmt --- crates/nockapp-grpc-proto/src/v1/convert.rs | 8 +-- crates/nockapp-grpc-proto/src/v2/convert.rs | 76 +++++---------------- 2 files changed, 19 insertions(+), 65 deletions(-) diff --git a/crates/nockapp-grpc-proto/src/v1/convert.rs b/crates/nockapp-grpc-proto/src/v1/convert.rs index 48261c647..37ea38dd6 100644 --- a/crates/nockapp-grpc-proto/src/v1/convert.rs +++ b/crates/nockapp-grpc-proto/src/v1/convert.rs @@ -580,12 +580,8 @@ impl TryFrom for v0::Timelock { })) } Some(time_lock_intent::Value::AbsoluteAndRelative(both)) => { - let abs = both - .absolute - .required("AbsoluteAndRelative", "absolute")?; - let rel = both - .relative - .required("AbsoluteAndRelative", "relative")?; + let abs = both.absolute.required("AbsoluteAndRelative", "absolute")?; + let rel = both.relative.required("AbsoluteAndRelative", "relative")?; v0::Timelock(Some(v0::TimelockIntent { absolute: abs.into(), relative: rel.into(), diff --git a/crates/nockapp-grpc-proto/src/v2/convert.rs b/crates/nockapp-grpc-proto/src/v2/convert.rs index 78ba66163..b57e3b888 100644 --- a/crates/nockapp-grpc-proto/src/v2/convert.rs +++ b/crates/nockapp-grpc-proto/src/v2/convert.rs @@ -100,22 +100,15 @@ impl From for PbNote { impl TryFrom for Note { type Error = ConversionError; fn try_from(note: PbNote) -> Result { - match note - .note_version - .required("Note", "note_version")? - { + match note.note_version.required("Note", "note_version")? { note::NoteVersion::Legacy(legacy) => Ok(Note::V0(legacy.try_into()?)), note::NoteVersion::V1(v1) => Ok(Note::V1(NoteV1 { version: v1::Version::V1, origin_page: v1::BlockHeight(Belt( - v1.origin_page - .required("NoteV1", "origin_page")? - .value, + v1.origin_page.required("NoteV1", "origin_page")?.value, )), name: v0::Name::try_from(v1.name.required("NoteV1", "name")?)?, - note_data: NoteData::try_from( - v1.note_data.required("NoteV1", "note_data")?, - )?, + note_data: NoteData::try_from(v1.note_data.required("NoteV1", "note_data")?)?, assets: v1.assets.required("NoteV1", "assets")?.into(), })), } @@ -136,12 +129,8 @@ impl TryFrom for BalanceUpdate { }) .collect::, _>>()?; Ok(BalanceUpdate { - height: v1::BlockHeight(Belt( - update.height.required("Balance", "height")?.value, - )), - block_id: v1::Hash::try_from( - update.block_id.required("Balance", "block_id")?, - )?, + height: v1::BlockHeight(Belt(update.height.required("Balance", "height")?.value)), + block_id: v1::Hash::try_from(update.block_id.required("Balance", "block_id")?)?, notes: v1::Balance(notes), }) } @@ -361,16 +350,10 @@ impl TryFrom for V1Seed { fn try_from(seed: PbSeed) -> Result { Ok(V1Seed { output_source: seed.output_source.map(|s| s.try_into()).transpose()?, - lock_root: v1::Hash::try_from( - seed.lock_root.required("Seed", "lock_root")?, - )?, - note_data: NoteData::try_from( - seed.note_data.required("Seed", "note_data")?, - )?, + lock_root: v1::Hash::try_from(seed.lock_root.required("Seed", "lock_root")?)?, + note_data: NoteData::try_from(seed.note_data.required("Seed", "note_data")?)?, gift: seed.gift.required("Seed", "gift")?.into(), - parent_hash: v1::Hash::try_from( - seed.parent_hash.required("Seed", "parent_hash")?, - )?, + parent_hash: v1::Hash::try_from(seed.parent_hash.required("Seed", "parent_hash")?)?, }) } } @@ -408,9 +391,7 @@ impl TryFrom for V1HaxPreimage { type Error = ConversionError; fn try_from(preimage: PbHaxPreimage) -> Result { Ok(V1HaxPreimage { - hash: v1::Hash::try_from( - preimage.hash.required("HaxPreimage", "hash")?, - )?, + hash: v1::Hash::try_from(preimage.hash.required("HaxPreimage", "hash")?)?, value: preimage.value.into(), }) } @@ -433,10 +414,7 @@ impl TryFrom for MerkleProof { impl TryFrom for LockPrimitive { type Error = ConversionError; fn try_from(primitive: PbLockPrimitive) -> Result { - match primitive - .primitive - .required("LockPrimitive", "primitive")? - { + match primitive.primitive.required("LockPrimitive", "primitive")? { lock_primitive::Primitive::Pkh(pkh) => { let hashes = pkh .hashes @@ -484,9 +462,7 @@ impl TryFrom for LockMerkleProof { .required("LockMerkleProof", "spend_condition")?, )?, axis: proof.axis, - proof: MerkleProof::try_from( - proof.proof.required("LockMerkleProof", "proof")?, - )?, + proof: MerkleProof::try_from(proof.proof.required("LockMerkleProof", "proof")?)?, }) } } @@ -501,9 +477,7 @@ impl TryFrom for V1Witness { .required("Witness", "lock_merkle_proof")?, )?, pkh_signature: PkhSignature::try_from( - witness - .pkh_signature - .required("Witness", "pkh_signature")?, + witness.pkh_signature.required("Witness", "pkh_signature")?, )?, hax: witness .hax @@ -529,10 +503,7 @@ impl TryFrom for Spend0 { .required("LegacySpend", "signature")? .try_into()?, seeds: nockchain_types::tx_engine::v1::Seeds(seeds), - fee: spend - .fee - .required("LegacySpend", "fee")? - .into(), + fee: spend.fee.required("LegacySpend", "fee")?.into(), }) } } @@ -546,16 +517,9 @@ impl TryFrom for Spend1 { .map(V1Seed::try_from) .collect::, _>>()?; Ok(Spend1 { - witness: V1Witness::try_from( - spend - .witness - .required("WitnessSpend", "witness")?, - )?, + witness: V1Witness::try_from(spend.witness.required("WitnessSpend", "witness")?)?, seeds: nockchain_types::tx_engine::v1::Seeds(seeds), - fee: spend - .fee - .required("WitnessSpend", "fee")? - .into(), + fee: spend.fee.required("WitnessSpend", "fee")?.into(), }) } } @@ -563,10 +527,7 @@ impl TryFrom for Spend1 { impl TryFrom for V1Spend { type Error = ConversionError; fn try_from(spend: PbSpend) -> Result { - match spend - .spend_kind - .required("Spend", "spend_kind")? - { + match spend.spend_kind.required("Spend", "spend_kind")? { spend::SpendKind::Legacy(legacy) => Ok(V1Spend::Legacy(Spend0::try_from(legacy)?)), spend::SpendKind::Witness(witness) => Ok(V1Spend::Witness(Spend1::try_from(witness)?)), } @@ -586,10 +547,7 @@ impl TryFrom for (Name, V1Spend) { impl TryFrom for V1RawTx { type Error = ConversionError; fn try_from(tx: PbRawTransaction) -> Result { - let version_value = tx - .version - .required("RawTransaction", "version")? - .value; + let version_value = tx.version.required("RawTransaction", "version")?.value; let version = match version_value { 1 => v1::Version::V1, From 9298da1153214c18b223eb2f0fc4763853c59dc0 Mon Sep 17 00:00:00 2001 From: tacryt-socryp <1377557+tacryt-socryp@users.noreply.github.com> Date: Mon, 3 Nov 2025 20:24:30 -0600 Subject: [PATCH 14/99] nockchain-libp2p config: bump timeout for network pokes to 180s --- crates/nockchain-libp2p-io/src/config.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/nockchain-libp2p-io/src/config.rs b/crates/nockchain-libp2p-io/src/config.rs index 7d17c1795..9b27223ec 100644 --- a/crates/nockchain-libp2p-io/src/config.rs +++ b/crates/nockchain-libp2p-io/src/config.rs @@ -65,7 +65,7 @@ const IDENTIFY_PROTOCOL_VERSION: &str = "/nockchain-1-identify"; const PEER_STORE_RECORD_CAPACITY: usize = 1024; // Default timeout for network-originating pokes -const POKE_TIMEOUT_SECS: u64 = 60; +const POKE_TIMEOUT_SECS: u64 = 180; // Default max failed pings before closing connection const FAILED_PINGS_BEFORE_CLOSE: u64 = 4; From 3ab0fdd9d53887c0274382d46176dde10a5dfc0d Mon Sep 17 00:00:00 2001 From: tacryt-socryp <1377557+tacryt-socryp@users.noreply.github.com> Date: Tue, 4 Nov 2025 12:24:48 -0600 Subject: [PATCH 15/99] nockchain-wallet: support m-to-n spends and add --include-data option to reduce fees for power-users --- crates/nockchain-wallet/README.md | 12 +- crates/nockchain-wallet/src/command.rs | 17 +- crates/nockchain-wallet/src/main.rs | 90 ++++++++-- hoon/apps/dumbnet/inner.hoon | 10 +- hoon/apps/wallet/lib/tx-builder-v1.hoon | 228 ++++++++++++------------ hoon/apps/wallet/lib/types.hoon | 7 +- hoon/apps/wallet/lib/utils.hoon | 47 +++-- hoon/apps/wallet/wallet.hoon | 14 +- hoon/common/tx-engine.hoon | 11 +- 9 files changed, 266 insertions(+), 170 deletions(-) diff --git a/crates/nockchain-wallet/README.md b/crates/nockchain-wallet/README.md index 82fe84a95..97297282e 100644 --- a/crates/nockchain-wallet/README.md +++ b/crates/nockchain-wallet/README.md @@ -179,9 +179,7 @@ Displays the aggregate wallet balance, including the total number of notes and t ### Create a Transaction -We currently only support fan-in transactions (multiple inputs, a single recipient). - -#### Single Recipient Transaction +We support transactions with any amount of input notes going to any number of recipients. ```bash # Send to a single recipient @@ -189,6 +187,14 @@ nockchain-wallet create-tx \ --names "[first1 last1],[first2 last2]" \ --recipient ":" \ --fee 10 + +# Send to multiple recipients +nockchain-wallet create-tx \ + --names "[first1 last1],[first2 last2]" \ + --recipient ":" \ + --recipient ":" \ + ... + --fee 10 ``` Gifts and fees are denominated in nicks (65536 nicks = 1 nock). diff --git a/crates/nockchain-wallet/src/command.rs b/crates/nockchain-wallet/src/command.rs index e89904671..2adab213f 100644 --- a/crates/nockchain-wallet/src/command.rs +++ b/crates/nockchain-wallet/src/command.rs @@ -1,6 +1,7 @@ use std::str::FromStr; -use clap::{Parser, Subcommand}; +use clap::builder::BoolishValueParser; +use clap::{ArgAction, Parser, Subcommand}; use nockapp::driver::Operation; use nockapp::kernel::boot::Cli as BootCli; use nockapp::wire::{Wire, WireRepr}; @@ -338,15 +339,15 @@ pub enum Commands { /// Create a transaction (use --refund-pkh when spending legacy v0 notes) #[command( name = "create-tx", - override_usage = "nockchain-wallet create-tx --names --recipient --fee [--refund-pkh ]\n\n# NOTE: --refund-pkh is required when spending from v0 notes. For v1 notes, the refund defaults to the note owner.\n\nExamples:\n # Send to a single recipient\n nockchain-wallet create-tx \\\n --names \"[first1 last1],[first2 last2]\" \\\n --recipient \":\" \\\n --fee 10 \\\n --refund-pkh " + override_usage = "nockchain-wallet create-tx --names --recipient --fee [--refund-pkh ] [--include-data ]\n\n# NOTE: --refund-pkh is required when spending from v0 notes. For v1 notes, the refund defaults to the note owner. --include-data defaults to true (pass 'false' to exclude note data).\n\nExamples:\n # Send to a single recipient\n nockchain-wallet create-tx \\\n --names \"[first1 last1],[first2 last2]\" \\\n --recipient \":\" \\\n --fee 10 \\\n --refund-pkh " )] CreateTx { /// Names of notes to spend (comma-separated) #[arg(long)] names: String, /// Transaction output, formatted as ":" - #[arg(long)] - recipient: String, + #[arg(long = "recipient")] + recipients: Vec, /// Transaction fee #[arg(long)] fee: u64, @@ -359,6 +360,14 @@ pub enum Commands { /// Hardened or unhardened child key #[arg(long, default_value = "false")] hardened: bool, + /// Include note data in output note + #[arg( + long, + action = ArgAction::Set, + value_parser = BoolishValueParser::new(), + default_value_t = true + )] + include_data: bool, }, /// Export a master public key diff --git a/crates/nockchain-wallet/src/main.rs b/crates/nockchain-wallet/src/main.rs index 0e384b513..752de0c5e 100644 --- a/crates/nockchain-wallet/src/main.rs +++ b/crates/nockchain-wallet/src/main.rs @@ -249,18 +249,20 @@ async fn main() -> Result<(), NockAppError> { Commands::ListNotesByAddressCsv { address } => Wallet::list_notes_by_address_csv(address), Commands::CreateTx { names, - recipient, + recipients, fee, refund_pkh, index, hardened, + include_data, } => Wallet::create_tx( names.clone(), - recipient.clone(), + recipients.clone(), *fee, refund_pkh.clone(), *index, *hardened, + *include_data, ), Commands::SendTx { transaction } => Wallet::send_tx(transaction), Commands::ShowTx { transaction } => Wallet::show_tx(transaction), @@ -750,6 +752,16 @@ impl Wallet { Ok(names) } + fn parse_multiple_recipients( + recipients: &Vec, + ) -> Result, NockAppError> { + recipients + .iter() + .map(|v| &**v) + .map(Self::parse_single_output) + .collect::, _>>() + } + fn parse_single_output(raw: &str) -> Result<(String, u64), NockAppError> { let specs: Vec<&str> = raw .split(',') @@ -795,6 +807,12 @@ impl Wallet { )) })?; + if amount == 0 { + return Err( + CrownError::Unknown("Gift amount to recipient cannot be 0".to_string()).into(), + ); + } + Ok((pkh_trimmed.to_string(), amount)) } @@ -803,16 +821,17 @@ impl Wallet { /// defaults back to the note owner, so `--refund-pkh` can be omitted. fn create_tx( names: String, - recipients: String, + recipients: Vec, fee: u64, refund_pkh: Option, index: Option, hardened: bool, + include_data: bool, ) -> CommandNoun { let mut slab = NounSlab::new(); let names_vec = Self::parse_note_names(&names)?; - let (pkh, amount) = Self::parse_single_output(&recipients)?; + let recipients = Self::parse_multiple_recipients(&recipients)?; // Convert names to list of pairs let names_noun = names_vec @@ -838,15 +857,21 @@ impl Wallet { None => D(0), }; - let recipient_pkh = Hash::from_base58(&pkh) - .map_err(|err| { - NockAppError::from(CrownError::Unknown(format!( - "Invalid output pubkey hash '{}': {}", - pkh, err - ))) - })? - .to_noun(&mut slab); - let order_noun = T(&mut slab, &[recipient_pkh, D(amount)]); + let mut order_nouns = vec![]; + for (pkh, amount) in recipients { + let recipient_pkh = Hash::from_base58(&pkh) + .map_err(|err| { + NockAppError::from(CrownError::Unknown(format!( + "Invalid output pubkey hash '{}': {}", + pkh, err + ))) + })? + .to_noun(&mut slab); + let order_noun = T(&mut slab, &[recipient_pkh, D(amount)]); + order_nouns.push(order_noun); + } + order_nouns.push(D(0)); + let order_noun = T(&mut slab, &order_nouns); let refund_noun = if let Some(refund) = refund_pkh { let refund_hash = Hash::from_base58(&refund).map_err(|err| { @@ -860,10 +885,11 @@ impl Wallet { } else { SIG }; + let include_data_noun = include_data.to_noun(&mut slab); Self::wallet( "create-tx", - &[names_noun, order_noun, fee_noun, sign_key_noun, refund_noun], + &[names_noun, order_noun, fee_noun, sign_key_noun, refund_noun, include_data_noun], Operation::Poke, &mut slab, ) @@ -1399,6 +1425,30 @@ mod tests { assert_eq!(amount, 65_536); } + #[test] + fn parse_multiple_output_accepts_valid_spec() -> Result<(), Box> { + let recipients = vec![ + "9phXGACnW4238oqgvn2gpwaUjG3RAqcxq2Ash2vaKp8KjzSd3MQ56Jt:65536".to_string(), + "9yPePjfWAdUnzaQKyxcRXKRa5PpUzKKEwtpECBZsUYt9Jd7egSDEWoV:99999".to_string(), + ]; + let allocations = Wallet::parse_multiple_recipients(&recipients)?; + assert_eq!( + allocations[0], + ( + "9phXGACnW4238oqgvn2gpwaUjG3RAqcxq2Ash2vaKp8KjzSd3MQ56Jt".to_string(), + 65536 + ) + ); + assert_eq!( + allocations[1], + ( + "9yPePjfWAdUnzaQKyxcRXKRa5PpUzKKEwtpECBZsUYt9Jd7egSDEWoV".to_string(), + 99999 + ) + ); + Ok(()) + } + #[test] fn parse_single_output_rejects_multiple_outputs() { let err = Wallet::parse_single_output("a:1,b:2").expect_err("expected failure"); @@ -1652,7 +1702,7 @@ mod tests { let mut wallet = Wallet::new(nockapp); let names = "[first1 last1],[first2 last2]".to_string(); - let recipients = "pk1:1".to_string(); + let recipients = vec!["pk1:1".to_string()]; let fee = 1; let (noun, op) = Wallet::create_tx( @@ -1662,14 +1712,16 @@ mod tests { None::, None, false, + true, )?; let wire = WalletWire::Command(Commands::CreateTx { names: names.clone(), - recipient: recipients.clone(), + recipients: recipients.clone(), fee: fee.clone(), refund_pkh: None, index: None, hardened: false, + include_data: true, }) .to_wire(); let spend_result = wallet.app.poke(wire, noun.clone()).await?; @@ -1690,7 +1742,7 @@ mod tests { // these should be valid names of notes in the wallet balance let names = "[Amt4GcpYievY4PXHfffiWriJ1sYfTXFkyQsGzbzwMVzewECWDV3Ad8Q BJnaDB3koU7ruYVdWCQqkFYQ9e3GXhFsDYjJ1vSmKFdxzf6Y87DzP4n]".to_string(); - let recipients = "3HKKp7xZgCw1mhzk4iw735S2ZTavCLHc8YDGRP6G9sSTrRGsaPBu1AqJ8cBDiw2LwhRFnQG7S3N9N9okc28uBda6oSAUCBfMSg5uC9cefhrFrvXVGomoGcRvcFZTWuJzm3ch:100".to_string(); + let recipients = vec!["3HKKp7xZgCw1mhzk4iw735S2ZTavCLHc8YDGRP6G9sSTrRGsaPBu1AqJ8cBDiw2LwhRFnQG7S3N9N9okc28uBda6oSAUCBfMSg5uC9cefhrFrvXVGomoGcRvcFZTWuJzm3ch:100".to_string()]; let fee = 0; // generate keys @@ -1704,6 +1756,7 @@ mod tests { None::, None, false, + true, )?; let wire1 = WalletWire::Command(Commands::ImportKeys { @@ -1718,11 +1771,12 @@ mod tests { let wire2 = WalletWire::Command(Commands::CreateTx { names: names.clone(), - recipient: recipients.clone(), + recipients: recipients.clone(), fee: fee.clone(), refund_pkh: None, index: None, hardened: false, + include_data: true, }) .to_wire(); let spend_result = wallet.app.poke(wire2, spend_noun.clone()).await?; diff --git a/hoon/apps/dumbnet/inner.hoon b/hoon/apps/dumbnet/inner.hoon index d5336933c..9145d4ebc 100644 --- a/hoon/apps/dumbnet/inner.hoon +++ b/hoon/apps/dumbnet/inner.hoon @@ -865,11 +865,15 @@ :: :: check tx-id. this is faster than calling validate:raw-tx (which also checks the id) :: so we do it first - ?. =((compute-id:raw-tx:t raw) ~(id get:raw-tx:t raw)) + =/ computed-id=hash:t (compute-id:raw-tx:t raw) + ?. =(computed-id ~(id get:raw-tx:t raw)) =/ log-message - %^ cat 3 + ;: (cury cat 3) 'heard-tx: Invalid transaction id: ' - id-b58 + id-b58 + ', expected: ' + (to-b58:hash:t computed-id) + == ~> %slog.[1 log-message] :_ k [(liar-effect wir %tx-id-invalid)]~ diff --git a/hoon/apps/wallet/lib/tx-builder-v1.hoon b/hoon/apps/wallet/lib/tx-builder-v1.hoon index 8f7464d82..06448c05f 100644 --- a/hoon/apps/wallet/lib/tx-builder-v1.hoon +++ b/hoon/apps/wallet/lib/tx-builder-v1.hoon @@ -5,20 +5,24 @@ :: :: Builds a simple fan-in transaction |= $: names=(list nname:transact) - =order:wt + orders=(list order:wt) fee=coins:transact sign-key=schnorr-seckey:transact pubkey=schnorr-pubkey:transact refund-pkh=(unit hash:transact) get-note=$-(nname:transact nnote:transact) + include-data=? == |^ -^- spends:v1:transact +^- [spends:v1:transact hash:transact] +?: (lien orders |=(ord=order:wt =(0 gift.ord))) + ~|('Cannot create a transaction with zero gift!' !!) +?: =(orders ~) + ~|("Cannot create a transaction with empty order" !!) +=/ sender-pkh=hash:transact (hash:schnorr-pubkey:transact pubkey) =/ notes=(list nnote:transact) (turn names get-note) :: TODO: unify functions across versions. There's too much repetition -=/ =spends:v1:transact - ?: (lte gift.order 0) - ~|("Cannot create a transaction with zero gift" !!) +=/ [=spends:v1:transact =hash:transact] :: If all notes are v0 ?: (levy notes |=(=nnote:transact ?=(^ -.nnote))) ?~ refund-pkh @@ -32,7 +36,7 @@ %+ sort notes |= [a=nnote:v0:transact b=nnote:v0:transact] (gth assets.a assets.b) - (create-spends-0 notes) + [(create-spends-0 notes) u.refund-pkh] :: If all notes are v1 ?: (levy notes |=(=nnote:transact ?=(@ -.nnote))) =/ notes=(list nnote-1:v1:transact) @@ -44,7 +48,9 @@ %+ sort notes |= [a=nnote-1:v1:transact b=nnote-1:v1:transact] (gth assets.a assets.b) - (create-spends-1 notes) + :: If a refund-pkh is passed in, use that. Otherwise, default to pkh + =/ refund-pkh=hash:transact (fall refund-pkh sender-pkh) + [(create-spends-1 notes) refund-pkh] :: :: I don't want to do this, but the fact that we're constrained to a single master seckey :: means no mixing versions in single spends. @@ -53,62 +59,34 @@ =+ min-fee=(calculate-min-fee:spends:transact spends) ?: (lth fee min-fee) ~|("Min fee not met. This transaction requires at least: {(trip (format-ui:common:display:utils min-fee))} nicks" !!) -spends +[spends hash] :: ++ create-spends-0 |= notes=(list nnote:v0:transact) - =; [=spends:v1:transact remaining=[gift=@ fee=@]] - ?. ?& =(0 gift.remaining) + =; [=spends:v1:transact remaining=[fee=@ orders=(list order:wt)]] + ?. ?& =(~ orders.remaining) =(0 fee.remaining) == - ~> %slog.[0 'Insufficient funds to pay fee and gift'] !! + ~|('Insufficient funds to pay fee and gift' !!) spends %+ roll notes |= $: note=nnote:v0:transact =spends:v1:transact - remaining=_[gift=gift.order fee=fee] + remaining=_[fee=fee orders=orders] == - =/ output-lock=lock:transact - [%pkh [m=1 (z-silt:zo ~[recipient.order])]]~ - =/ =note-data:v1:transact - %- ~(put z-by:zo *note-data:v1:transact) - =/ =lock-data:wt [%0 output-lock] - [%lock ^-(* lock-data)] ?. ?& =(1 m.sig.note) (~(has z-in:zo pubkeys.sig.note) pubkey) == ~> %slog.[0 'Note not spendable by signing key'] !! - =/ gift-portion=@ - ?: =(0 gift.remaining) 0 - (min gift.remaining assets.note) - =/ available-for-fee=@ (sub assets.note gift-portion) - =/ fee-portion=@ - ?: =(0 fee.remaining) 0 - (min fee.remaining available-for-fee) - =/ refund=@ (sub assets.note (add gift-portion fee-portion)) - :: skip if no seeds would be created (protocol requires >=1 seed) - ?: &(=(0 gift-portion) =(0 refund)) + =/ res (allocate-from-note orders.remaining note assets.note fee.remaining) + =/ [new-orders=(list order:wt) specs=(list order:wt) new-fee=@] res + :: skip if no seeds would be created (protocol requires >=1 seed) + :: do not update fees or orders + ?: =(~ specs) [spends remaining] - =/ [new-gift-remaining=@ new-fee-remaining=@] - :- (sub gift.remaining gift-portion) - (sub fee.remaining fee-portion) - ~| "assets in must equal gift + fee + refund" - ?> =(assets.note (add gift-portion (add fee-portion refund))) - =/ =seeds:v1:transact - %- z-silt:zo - =| seeds=(list seed:v1:transact) - =? seeds (gth gift-portion 0) - :_ seeds - :* output-source=~ - lock-root=(hash:lock:transact output-lock) - note-data - gift=gift-portion - parent-hash=(hash:nnote:transact note) - == - =? seeds (gth refund 0) - :_ seeds - (create-refund note refund) - seeds + =/ fee-portion=@ (sub fee.remaining new-fee) + :: turn specs (recipient,gift) into v1 seeds + =/ =seeds:v1:transact (seeds-from-specs specs note fee-portion) ?~ seeds ~|('No seeds were provided' !!) =/ spend=spend-0:v1:transact @@ -116,91 +94,48 @@ spends seeds seeds fee fee-portion == - :_ [gift=new-gift-remaining fee=new-fee-remaining] + :_ [fee=new-fee orders=new-orders] %- ~(put z-by:zo spends) [name.note (sign:spend-v1:transact [%0 spend] sign-key)] :: ++ create-spends-1 |= notes=(list nnote-1:v1:transact) - =; [=spends:v1:transact remaining=[gift=@ fee=@]] - ?. ?& =(0 gift.remaining) + =; [=spends:v1:transact remaining=[fee=@ orders=(list order:wt)]] + ?. ?& =(~ orders.remaining) =(0 fee.remaining) == - ~> %slog.[0 'Insufficient funds to pay fee and gift'] !! + ~|('Insufficient funds to pay fee and gift' !!) spends - =/ output-lock=lock:transact - [%pkh [m=1 (z-silt:zo ~[recipient.order])]]~ - =/ =note-data:v1:transact - %- ~(put z-by:zo *note-data:v1:transact) - =/ =lock-data:wt [%0 output-lock] - [%lock ^-(* lock-data)] =/ pkh=hash:transact (hash:schnorr-pubkey:transact pubkey) %+ roll notes |= $: note=nnote-1:v1:transact =spends:v1:transact - remaining=_[gift=gift.order fee=fee] + remaining=_[fee=fee orders=orders] == =/ nd=(unit note-data:v1:transact) ((soft note-data:v1:transact) note-data.note) ?~ nd ~> %slog.[0 'error: note-data malformed in note!'] !! - =+ simple-pkh=[%pkh [m=1 (z-silt:zo ~[pkh])]] - =/ coinbase-lock=spend-condition:transact ~[simple-pkh tim-lp:coinbase:transact] - =/ input-lock=(reason:transact lock:transact) - :: if there is no lock noun, default to coinbase lock - ?~ lok-noun=(~(get z-by:zo u.nd) %lock) - [%.y coinbase-lock] - ?~ parent-lock=((soft lock-data:wt) u.lok-noun) - ~> %slog.[0 'error: lock-data malformed in note!'] !! - :: more than one spend condition - ?@ -.lock.u.parent-lock - [%.n 'lock has multiple spend conditions, we are not supporting this at the moment'] - ?: (gth (lent lock.u.parent-lock) 1) - [%.n 'lock is a single spend-condition with more than one predicate'] - :: - :: Grab a single condition off of the lock, - :: check that it is a pkh condition that it is spendable - =/ lp=lock-primitive:v1:transact (snag 0 `spend-condition:transact`lock.u.parent-lock) - ?. ?=(%pkh -.lp) [%.n 'lock is not a pkh lock'] - ?. ?& =(1 m.lp) - (~(has z-in:zo h.lp) pkh) - == - [%.n 'lock has a spend-condition for more than on predicate'] - [%.y lock.u.parent-lock] + =/ coinbase-lock=spend-condition:transact (coinbase-pkh-sc:v1:first-name:transact pkh) + =/ input-lock=(reason:transact lock:transact) + :: if there is no lock noun, default to coinbase lock + ?~ parent-lock=(pull-lock:locks:utils [u.nd name.note (some pkh)]) + [%.n 'the first name of the note did not correspond to a simple-pkh or coinbase'] + [%.y u.parent-lock] ?: ?=(%.n -.input-lock) - ~> %slog.[0 "Error processing note {(trip (name:v1:display:utils name.note))}"] !! - =/ gift-portion=@ - ?: =(0 gift.remaining) 0 - (min gift.remaining assets.note) - =/ available-for-fee=@ (sub assets.note gift-portion) - =/ fee-portion=@ - ?: =(0 fee.remaining) 0 - (min fee.remaining available-for-fee) - =/ refund=@ (sub assets.note (add gift-portion fee-portion)) - :: skip if no seeds would be created (protocol requires >=1 seed) - ?: &(=(0 gift-portion) =(0 refund)) + =+ name-cord=(name:v1:display:utils name.note) + ~& "Error processing note {}. Reason: {(trip p.input-lock)}." !! + :: fan-out gifts + fee for this v1 note (reuse shared gates) + =/ res (allocate-from-note orders.remaining note assets.note fee.remaining) + =/ [new-orders=(list order:wt) specs=(list order:wt) new-fee=@] res + :: skip if no seeds would be created (protocol requires >=1 seed) + ?: =(~ specs) [spends remaining] - =/ [new-gift-remaining=@ new-fee-remaining=@] - :- (sub gift.remaining gift-portion) - (sub fee.remaining fee-portion) - ~| "assets in must equal gift + fee + refund" - ?> =(assets.note (add gift-portion (add fee-portion refund))) - =/ =seeds:v1:transact - %- z-silt:zo - =| seeds=(list seed:v1:transact) - =? seeds (gth gift-portion 0) - :_ seeds - :* output-source=~ - lock-root=(hash:lock:transact output-lock) - note-data - gift=gift-portion - parent-hash=(hash:nnote:transact note) - == - =? seeds (gth refund 0) - :_ seeds - (create-refund note refund) - seeds + =/ fee-portion=@ (sub fee.remaining new-fee) + :: build v1 seeds from specs (recipient,gift) + =/ =seeds:v1:transact (seeds-from-specs specs note fee-portion) ?~ seeds ~|('No seeds were provided' !!) + :: prove input lock and emit spend-1 with fee-portion =/ lmp=lock-merkle-proof:transact (build-lock-merkle-proof:lock:transact p.input-lock 1) =/ spend=spend-1:v1:transact @@ -212,7 +147,7 @@ spends %* . *witness:transact lmp lmp == - :_ [gift=new-gift-remaining fee=new-fee-remaining] + :_ [fee=new-fee orders=new-orders] %- ~(put z-by:zo spends) [name.note (sign:spend-v1:transact [%1 spend] sign-key)] :: @@ -222,10 +157,12 @@ spends =/ refund-lp=lock-primitive:transact ?^ refund-pkh [%pkh [m=1 (z-silt:zo ~[u.refund-pkh])]] - =/ pkh=hash:transact (hash:schnorr-pubkey:transact pubkey) - [%pkh [m=1 (z-silt:zo ~[pkh])]] + =/ sender-pkh=hash:transact (hash:schnorr-pubkey:transact pubkey) + [%pkh [m=1 (z-silt:zo ~[sender-pkh])]] =/ lok=lock:transact ~[refund-lp] =/ =note-data:v1:transact + ?. include-data + ~ %- ~(put z-by:zo *note-data:v1:transact) [%lock ^-(lock-data:wt [%0 lok])] :* output-source=~ @@ -234,4 +171,63 @@ spends gift=refund parent-hash=(hash:nnote:transact note) == +++ allocate-from-note + |= [orders=(list order:wt) note=nnote:transact assets=@ fee=@] + ^- [orders=(list order:wt) seeds=(list order:wt) fee=@] + :: fill gifts greedily left-to-right + =/ [remaining-orders=(list order:wt) out=(list order:wt) rem=@] + %+ roll orders + |= $: ord=order:wt + acc=_[remaining-orders=`(list order:wt)`~ out=`(list order:wt)`~ rem=`@`assets] + == + ?: =(0 rem.acc) + [remaining-orders=[ord remaining-orders.acc] out=out.acc rem=rem.acc] + =/ take=@ (min gift.ord rem.acc) + =. rem.acc (sub rem.acc take) + =. out.acc [[recipient=recipient.ord gift=take] out.acc] + =? remaining-orders.acc (lth take gift.ord) + [[recipient=recipient.ord gift=(sub gift.ord take)] remaining-orders.acc] + acc + :: pay fee from remainder (post-gift), then refund any tail + =/ fee-portion=@ (min fee rem) + =/ refund=@ (sub rem fee-portion) + =? out (gth refund 0) + =/ refund-recipient=hash:transact + ?^ refund-pkh + u.refund-pkh + (hash:schnorr-pubkey:transact pubkey) + [[recipient=refund-recipient gift=refund] out] + :: emit remaining orders, outputs which will translate into seeds, and remaining fee + [remaining-orders out (sub fee fee-portion)] +:: Build v1 seeds from (recipient,gift) specs for a given input note (any version). +++ seeds-from-specs + |= $: specs=(list order:wt) + note=nnote:transact + fee-portion=@ + == + ^- seeds:v1:transact + =/ [seeds=(list seed:v1:transact) gifts=@] + %+ roll specs + |= $: spec=order:wt + _acc=[seeds=`(list seed:v1:transact)`~ gifts=0] + == + =/ output-lock=lock:transact + [%pkh [m=1 (z-silt:zo ~[recipient.spec])]]~ + =/ nd=note-data:v1:transact + ?. include-data + ~ + %- ~(put z-by:zo *note-data:v1:transact) + [%lock ^-(lock-data:wt [%0 output-lock])] + :_ (add gifts.acc gift.spec) + :_ seeds.acc + :* output-source=~ + lock-root=(hash:lock:transact output-lock) + note-data=nd + gift=gift.spec + parent-hash=(hash:nnote:transact note) + == + ~| "assets in must equal gift + fee + refund" + ?> =(assets.note (add gifts fee-portion)) + %- z-silt:zo + seeds -- diff --git a/hoon/apps/wallet/lib/types.hoon b/hoon/apps/wallet/lib/types.hoon index fd0bd41d8..80b71755b 100644 --- a/hoon/apps/wallet/lib/types.hoon +++ b/hoon/apps/wallet/lib/types.hoon @@ -379,10 +379,15 @@ [%list-notes-by-address-csv address=@t] :: base58-encoded address, CSV format $: %create-tx names=(list [first=@t last=@t]) :: base58-encoded name hashes - =order + orders=(list order) fee=coins:transact :: fee sign-key=(unit [child-index=@ud hardened=?]) :: child key information to sign from refund-pkh=(unit hash:transact) :: refund pkh for spends over v0 notes + include-data=? :: whether or not we should include note-data. defaults + :: to yes in cli. not including note-data is a power-user option because + :: if the lock is not a standard 1-of-1 pkh or coinbase, the wallet won't + :: be able to guess it, so the funds could be lost forever if the user. + :: doesn't keep track of the lock. == [%list-active-addresses ~] [%list-notes ~] diff --git a/hoon/apps/wallet/lib/utils.hoon b/hoon/apps/wallet/lib/utils.hoon index a1747188c..f37e78126 100644 --- a/hoon/apps/wallet/lib/utils.hoon +++ b/hoon/apps/wallet/lib/utils.hoon @@ -31,6 +31,35 @@ ++ make-markdown-effect |= nodes=markdown:m [%markdown (crip (en:md nodes))] +++ locks + |% + ++ pull-lock-inner + |= [nd=note-data:v1:transact nn=nname:transact pkh=(unit hash:transact)] + ^- (unit lock:transact) + ?~ lock-noun=(~(get z-by:zo nd) %lock) + ?~ pkh + ~ + :: There's no stored lock. Attempt rebuilding from name + =/ simple-lock [(simple-pkh-lp:v1:first-name:transact u.pkh)]~ + ?: =((first:nname:transact (hash:lock:transact simple-lock)) -.nn) + (some simple-lock) + =/ coinbase-lock (coinbase-pkh-sc:v1:first-name:transact u.pkh) + ?: =((first:nname:transact (hash:lock:transact coinbase-lock)) -.nn) + (some coinbase-lock) + ~> %slog.[2 'unsupported lock type'] + ~ + ?~ soft-lock=((soft lock-data:wt) u.lock-noun) + ~> %slog.[0 'lock data in note is malformed'] ~ + (some lock.u.soft-lock) + ++ pull-lock + |= [nd=note-data:v1:transact nn=nname:transact pkh=(unit hash:transact)] + ^- (unit lock:transact) + ?~ lok=(pull-lock-inner [nd nn pkh]) + ~ + ?: =((first:nname:transact (hash:lock:transact u.lok)) -.nn) + lok + ~> %slog.[0 'first-name does not match the pulled lock'] ~ + -- :: :: +timelock-helpers: helper functions for creating timelock-intents :: @@ -75,7 +104,7 @@ :: ++ base-path ^- trek ?~ active-master.state - ~|("base path not accessible because master not set" !!) + ~|('base path not accessible because master not set' !!) /keys/[t/(to-b58:active:wt active-master.state)] :: ++ watch-path ^- trek @@ -510,22 +539,6 @@ ++ transaction |= [name=@t outs=outputs:v1:transact fees=@] ^- @t - ::=/ input-notes=tape - :: =/ notes=(list nnote:transact) - :: %~ tap z-in:zo - :: %- ~(gas z-in:zo *(z-set:zo nnote:transact)) - :: %+ turn - :: %+ roll ~(val z-by:zo spends) - :: |= [=spend:v1:transact seeds=(z-set:zo seed-v1:transact)] - :: (~(uni z-by:zo seeds) seeds.spend) - :: |=(seed=seed-v1:transact note.seed) - :: %- zing - :: %+ turn notes - :: |= =nnote:transact - :: """ - - :: {(trip (note nnote))} - :: """ =/ output-notes=tape %- zing %+ turn diff --git a/hoon/apps/wallet/wallet.hoon b/hoon/apps/wallet/wallet.hoon index 5aee27d5e..1db4f0830 100644 --- a/hoon/apps/wallet/wallet.hoon +++ b/hoon/apps/wallet/wallet.hoon @@ -817,11 +817,12 @@ ++ do-list-notes-by-address |= =cause:wt ?> ?=(%list-notes-by-address -.cause) - =/ matching-notes=(list [name=nname:transact note=nnote:transact]) + =/ [matching-notes=(list [name=nname:transact note=nnote:transact]) pkh=(unit hash:transact)] :: v0 address case ?: (gte (met 3 address.cause) 132) =/ target-pubkey=schnorr-pubkey:transact (from-b58:schnorr-pubkey:transact address.cause) + =/ notes %+ skim ~(tap z-by:zo notes.balance.state) |= [name=nname:transact note=nnote:transact] :: skip v1 notes @@ -829,9 +830,11 @@ :: this should cover all cases because we only :: sync coinbase notes or non-coinbase notes with m=1 locks. (~(has z-in:zo pubkeys.sig.note) target-pubkey) + [notes ~] :: v1 address case =/ target-pkh=hash:transact (from-b58:hash:transact address.cause) + =/ notes %+ skim ~(tap z-by:zo notes.balance.state) |= [name=nname:transact note=nnote:transact] :: skip v0 notes @@ -843,6 +846,7 @@ ?| =(simple-fn -.name.note) =(coinbase-fn -.name.note) == + [notes (some target-pkh)] :_ state :~ :- %markdown %- crip @@ -952,9 +956,9 @@ =/ pubkey=schnorr-pubkey:transact %- from-sk:schnorr-pubkey:transact (to-atom:schnorr-seckey:transact sign-key) - =/ =spends:transact - (tx-builder names order.cause fee.cause sign-key pubkey refund-pkh.cause get-note:v) - (save-transaction spends) + =/ [=spends:transact primary-pkh=hash:transact] + (tx-builder names orders.cause fee.cause sign-key pubkey refund-pkh.cause get-note:v include-data.cause) + (save-transaction spends primary-pkh) :: ++ parse-names |= raw-names=(list [first=@t last=@t]) @@ -964,7 +968,7 @@ (from-b58:nname:transact [first last]) :: ++ save-transaction - |= =spends:transact + |= [=spends:transact primary-pkh=hash:transact] ^- [(list effect:wt) state:wt] ~& "Validating transaction before saving" :: we fallback to the hash of the spends as the transaction name diff --git a/hoon/common/tx-engine.hoon b/hoon/common/tx-engine.hoon index 81f1fa42a..300ebe15c 100644 --- a/hoon/common/tx-engine.hoon +++ b/hoon/common/tx-engine.hoon @@ -187,13 +187,18 @@ =/ lock-hash (hash:lock pkh-lock) (first:nname (hash:lock pkh-lock)) :: + ++ coinbase-pkh-sc + |= key-hash=hash + ^- spend-condition + :~ ^-(lock-primitive (simple-pkh-lp key-hash)) + ^-(lock-primitive tim-lp:^coinbase) + == + :: ++ coinbase |= key-hash=hash ^- form =/ coinbase-lock=spend-condition - :~ ^-(lock-primitive (simple-pkh-lp key-hash)) - ^-(lock-primitive tim-lp:^coinbase) - == + (coinbase-pkh-sc key-hash) (first:nname (hash:lock coinbase-lock)) --::+v1 --::+first-name From eec69f4b25199e84101aad5cdb09984614d26e6c Mon Sep 17 00:00:00 2001 From: tacryt-socryp <1377557+tacryt-socryp@users.noreply.github.com> Date: Fri, 7 Nov 2025 14:00:51 -0600 Subject: [PATCH 16/99] tx-engine-1: band-aid lukechampine's issue where axis is used instead of axis.form --- hoon/common/tx-engine-1.hoon | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/hoon/common/tx-engine-1.hoon b/hoon/common/tx-engine-1.hoon index 8172dc054..c425a8e2f 100644 --- a/hoon/common/tx-engine-1.hoon +++ b/hoon/common/tx-engine-1.hoon @@ -1394,7 +1394,7 @@ ^- hashable:tip5 |^ :+ hash+(hash:spend-condition spend-condition.form) - leaf+axis + hash+(from-b58:^hash '6mhCSwJQDvbkbiPAUNjetJtVoo1VLtEhmEYoU4hmdGd6ep1F6ayaV4A') (hashable-merk-proof merk-proof.form) :: ++ hashable-merk-proof @@ -1418,6 +1418,9 @@ ++ check |= [=form parent-firstname=^hash] ^- ? + ?. =(1 axis.form) + ~> %slog.[0 'axis must be 1 until a protocol upgrade that properly commits to lmp in witness'] + %.n =/ spend-firstname (hash-hashable:tip5 [leaf+& hash+root.merk-proof.form]) ?. =(spend-firstname parent-firstname) From e6e3b65ff431caee029d798820f33b0fab43a3eb Mon Sep 17 00:00:00 2001 From: tacryt-socryp <1377557+tacryt-socryp@users.noreply.github.com> Date: Fri, 7 Nov 2025 16:19:41 -0600 Subject: [PATCH 17/99] tx-engine-1: remove extraneous jet hints --- hoon/common/tx-engine-1.hoon | 2 -- 1 file changed, 2 deletions(-) diff --git a/hoon/common/tx-engine-1.hoon b/hoon/common/tx-engine-1.hoon index c425a8e2f..bd667cb8b 100644 --- a/hoon/common/tx-engine-1.hoon +++ b/hoon/common/tx-engine-1.hoon @@ -121,7 +121,6 @@ == :: ++ compute-size-without-txs - ~/ %compute-size-without-txs |= pag=form ^- size ;: add @@ -863,7 +862,6 @@ [%.y ~] == ++ validate - ~/ %validate |= =form ^- ? ?: =(form *^form) %.n From 28fed72f583d553758dd7665c1bd25e28b2419f4 Mon Sep 17 00:00:00 2001 From: tacryt-socryp <1377557+tacryt-socryp@users.noreply.github.com> Date: Sat, 8 Nov 2025 18:15:05 -0600 Subject: [PATCH 18/99] nockchain-libp2p: reduce request fanout to 8 at max regardless of fast sync --- crates/nockchain-libp2p-io/src/driver.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/nockchain-libp2p-io/src/driver.rs b/crates/nockchain-libp2p-io/src/driver.rs index 8cf86be40..4f27f0507 100644 --- a/crates/nockchain-libp2p-io/src/driver.rs +++ b/crates/nockchain-libp2p-io/src/driver.rs @@ -580,7 +580,10 @@ async fn handle_effect( request_peers.shuffle(&mut rng); request_peers.into_iter().take(2).collect() } else { - target_peers.clone() + let mut rng = rng(); + let mut request_peers = target_peers.clone(); + request_peers.shuffle(&mut rng); + request_peers.into_iter().take(8).collect() }; debug!("Sending request to {} peers", request_peers.len()); for peer_id in request_peers { From dd5d4e5c47898805e00332bd9fda60ec707bd06f Mon Sep 17 00:00:00 2001 From: tacryt-socryp <1377557+tacryt-socryp@users.noreply.github.com> Date: Sat, 8 Nov 2025 21:38:01 -0600 Subject: [PATCH 19/99] raw-tx-checker: add ability to save raw-txs from wallet, add a crate for examining raw-txs, add batch writes to file driver, add file exts to http driver --- crates/hoonc/src/lib.rs | 10 +- .../services/public_nockchain/v1/driver.rs | 2 +- .../services/public_nockchain/v2/driver.rs | 2 +- crates/nockapp/Cargo.toml | 1 + crates/nockapp/src/drivers/file.rs | 228 +++++++++++------- crates/nockapp/src/drivers/http/http.rs | 31 ++- crates/nockapp/src/kernel/form.rs | 14 +- crates/nockapp/src/lib.rs | 2 +- crates/nockapp/src/nockapp/error.rs | 10 + crates/nockapp/src/noun/extensions.rs | 175 ++------------ crates/nockapp/src/utils/error.rs | 7 + crates/nockapp/src/utils/mod.rs | 11 +- crates/nockchain-libp2p-io/src/driver.rs | 3 +- crates/nockchain-libp2p-io/src/messages.rs | 3 +- crates/nockchain-math/Cargo.toml | 1 - crates/nockchain-math/src/crypto/argon2.rs | 56 ++++- crates/nockchain-math/src/shape.rs | 3 +- crates/nockchain-math/src/tip5/hash.rs | 3 +- crates/nockchain-math/src/zoon/zmap.rs | 28 +++ crates/nockchain-types/src/tx_engine/v1/tx.rs | 3 +- crates/nockchain-wallet/src/command.rs | 6 +- crates/nockchain-wallet/src/main.rs | 13 +- crates/nockchain/src/config.rs | 6 +- crates/nockchain/src/lib.rs | 19 +- crates/nockchain/src/main.rs | 16 +- crates/nockchain/src/mining.rs | 3 +- crates/nockvm/rust/nockvm/Cargo.toml | 3 + crates/nockvm/rust/nockvm/src/ext.rs | 179 ++++++++++++++ crates/nockvm/rust/nockvm/src/lib.rs | 1 + crates/noun-serde-derive/src/lib.rs | 14 +- crates/noun-serde/Cargo.toml | 2 +- crates/noun-serde/src/lib.rs | 38 +-- crates/noun-serde/src/wallet.rs | 3 +- crates/noun-serde/tests/serde.rs | 3 +- crates/raw-tx-checker/Cargo.toml | 14 ++ crates/raw-tx-checker/src/main.rs | 49 ++++ crates/zkvm-jetpack/Cargo.toml | 1 - crates/zkvm-jetpack/src/jets/cheetah_jets.rs | 2 +- .../src/jets/compute_table_jets_v2.rs | 3 +- crates/zkvm-jetpack/src/jets/crypto_jets.rs | 8 +- .../src/jets/memory_table_jets_v2.rs | 3 +- crates/zkvm-jetpack/src/jets/table_utils.rs | 2 +- .../zkvm-jetpack/src/jets/trace_gen_jets.rs | 3 +- hoon/apps/wallet/lib/types.hoon | 14 +- hoon/apps/wallet/wallet.hoon | 33 ++- 45 files changed, 676 insertions(+), 355 deletions(-) create mode 100644 crates/nockvm/rust/nockvm/src/ext.rs create mode 100644 crates/raw-tx-checker/Cargo.toml create mode 100644 crates/raw-tx-checker/src/main.rs diff --git a/crates/hoonc/src/lib.rs b/crates/hoonc/src/lib.rs index 9c12aefa0..bafa739b7 100644 --- a/crates/hoonc/src/lib.rs +++ b/crates/hoonc/src/lib.rs @@ -9,7 +9,8 @@ use nockapp::kernel::boot::{self, default_boot_cli, Cli as BootCli}; use nockapp::noun::slab::{Jammer, NockJammer, NounSlab}; use nockapp::one_punch::OnePunchWire; use nockapp::wire::Wire; -use nockapp::{system_data_dir, AtomExt, Noun, NounExt}; +use nockapp::{system_data_dir, AtomExt, Noun}; +use nockvm::ext::NounExt; use nockvm::interpreter::{self, Context}; use nockvm::noun::{Atom, D, T}; use nockvm_macros::tas; @@ -398,6 +399,13 @@ pub fn is_valid_file_or_dir(entry: &DirEntry) -> bool { || s.ends_with(".hoon") || s.ends_with(".txt") || s.ends_with(".jam") + // Include common web asset types + || s.ends_with(".html") + || s.ends_with(".css") + || s.ends_with(".js") + || s.ends_with(".jpg") + || s.ends_with(".png") + || s.ends_with(".gif") }) .unwrap_or(false); diff --git a/crates/nockapp-grpc/src/services/public_nockchain/v1/driver.rs b/crates/nockapp-grpc/src/services/public_nockchain/v1/driver.rs index a9604e641..bd302c280 100644 --- a/crates/nockapp-grpc/src/services/public_nockchain/v1/driver.rs +++ b/crates/nockapp-grpc/src/services/public_nockchain/v1/driver.rs @@ -1,8 +1,8 @@ use std::net::SocketAddr; use nockapp::driver::{make_driver, IODriverFn, NockAppHandle}; -use nockapp::NounExt; use nockchain_types::tx_engine::v0; +use nockvm::ext::NounExt; use nockvm_macros::tas; use noun_serde::{NounDecode, NounDecodeError}; use tracing::{error, info, warn}; diff --git a/crates/nockapp-grpc/src/services/public_nockchain/v2/driver.rs b/crates/nockapp-grpc/src/services/public_nockchain/v2/driver.rs index c46db7d35..6dfd411d3 100644 --- a/crates/nockapp-grpc/src/services/public_nockchain/v2/driver.rs +++ b/crates/nockapp-grpc/src/services/public_nockchain/v2/driver.rs @@ -1,8 +1,8 @@ use std::net::SocketAddr; use nockapp::driver::{make_driver, IODriverFn, NockAppHandle}; -use nockapp::NounExt; use nockchain_types::tx_engine::v1; +use nockvm::ext::NounExt; use nockvm_macros::tas; use noun_serde::{NounDecode, NounDecodeError}; use tracing::{error, info, warn}; diff --git a/crates/nockapp/Cargo.toml b/crates/nockapp/Cargo.toml index 1e8979447..17fa4649d 100644 --- a/crates/nockapp/Cargo.toml +++ b/crates/nockapp/Cargo.toml @@ -65,6 +65,7 @@ tracing-tracy = { workspace = true, optional = true, features = [ webpki-roots = { workspace = true } x509-parser = { workspace = true } yaque = { workspace = true } +noun-serde = { workspace = true } [lib] name = "nockapp" diff --git a/crates/nockapp/src/drivers/file.rs b/crates/nockapp/src/drivers/file.rs index 936a7b02f..c00007939 100644 --- a/crates/nockapp/src/drivers/file.rs +++ b/crates/nockapp/src/drivers/file.rs @@ -1,16 +1,65 @@ -use nockvm::noun::{IndirectAtom, Noun, D, NO, T, YES}; +use std::path::Path; + +use bytes::Bytes; +use nockvm::noun::{Atom, IndirectAtom, Noun, D, SIG, T}; use nockvm_macros::tas; +use noun_serde::{NounDecode, NounEncode}; +use tokio::fs::{self, File, OpenOptions}; +use tokio::io::AsyncWriteExt; use tracing::{debug, error}; use crate::nockapp::driver::{make_driver, IODriverFn}; +use crate::nockapp::error::NockAppError; use crate::nockapp::wire::{Wire, WireRepr}; use crate::noun::slab::NounSlab; -use crate::noun::FromAtom; -use crate::AtomExt; +use crate::{AtomExt, IndirectAtomExt}; + +#[derive(Clone, Debug, NounDecode)] +struct BatchWriteRequestEntry { + path: String, + contents: Bytes, +} + +#[derive(Clone, Debug, NounEncode)] +struct BatchWriteResultEntry { + path: String, + contents: Bytes, + success: bool, +} + +fn decode_from_noun(noun: Noun) -> Result { + T::from_noun(&noun).map_err(|err| NockAppError::NounDecodeError(Box::new(err))) +} + +async fn ensure_parent_dirs(path: &str) -> std::io::Result<()> { + if let Some(parent) = Path::new(path).parent() { + fs::create_dir_all(parent).await?; + } + Ok(()) +} + +async fn prepare_file_write(path: &str, contents: &[u8]) -> std::io::Result { + ensure_parent_dirs(path).await?; + let mut file = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(path) + .await?; + file.write_all(contents).await?; + Ok(file) +} + +async fn write_then_flush(path: &str, contents: &[u8]) -> std::io::Result<()> { + debug!("file driver: writing {} bytes to: {}", contents.len(), path); + let mut file = prepare_file_write(path, contents).await?; + file.sync_all().await +} pub enum FileWire { Read, Write, + BatchWrite, } impl Wire for FileWire { @@ -21,6 +70,7 @@ impl Wire for FileWire { let tags = match self { FileWire::Read => vec!["read".into()], FileWire::Write => vec!["write".into()], + FileWire::BatchWrite => vec!["batch-write".into()], }; WireRepr::new(FileWire::SOURCE, FileWire::VERSION, tags) } @@ -38,6 +88,10 @@ impl Wire for FileWire { /// `[%file %write path=@t contents=@]` /// results in file written to disk and poke /// `[%file %write path=@t contents=@ success=?]` +/// +/// `[%file %batch-write (list [path=@t contents=@])]` +/// results in each file written to disk and poke +/// `[%file %batch-write (list [path=@t contents=@ success=?])]` pub fn file() -> IODriverFn { make_driver(|handle| async move { loop { @@ -62,31 +116,25 @@ pub fn file() -> IODriverFn { continue; }; - let (operation, path_atom) = match file_cell.head().as_direct() { - Ok(tag) if tag.data() == tas!(b"read") => ("read", file_cell.tail().as_atom().ok()), - Ok(tag) if tag.data() == tas!(b"write") => { - let Ok(write_cell) = file_cell.tail().as_cell() else { - continue; - }; - ("write", write_cell.head().as_atom().ok()) - } - _ => continue, + let Ok(operation) = decode_from_noun::(file_cell.head()) else { + continue; }; - match (operation, path_atom) { - ("read", Some(path_atom)) => { - let path = String::from_utf8(Vec::from(path_atom.as_ne_bytes()))?; - match tokio::fs::read(&path).await { + match operation.as_str() { + "read" => { + let Ok(path) = decode_from_noun::(file_cell.tail()) else { + continue; + }; + match fs::read(&path).await { Ok(contents) => { let mut poke_slab = NounSlab::new(); - let contents_atom = unsafe { - IndirectAtom::new_raw_bytes_ref(&mut poke_slab, &contents) - .normalize_as_atom() - }; - let contents_noun = Noun::from_atom(contents_atom); - let poke_noun = T( + let contents_atom = ::from_bytes( &mut poke_slab, - &[D(tas!(b"file")), D(tas!(b"read")), D(0), contents_noun], + contents.as_slice(), + ); + let poke_noun: Noun = T( + &mut poke_slab, + &[D(tas!(b"file")), D(tas!(b"read")), SIG, contents_atom.as_noun()], ); poke_slab.set_root(poke_noun); let wire = FileWire::Read.to_wire(); @@ -94,82 +142,94 @@ pub fn file() -> IODriverFn { } Err(_) => { let mut poke_slab = NounSlab::new(); - let poke_noun = - T(&mut poke_slab, &[D(tas!(b"file")), D(tas!(b"read")), D(0)]); + let poke_items: Vec = + vec![D(tas!(b"file")), D(tas!(b"read")), D(0)]; + let poke_noun = poke_items.to_noun(&mut poke_slab); poke_slab.set_root(poke_noun); let wire = FileWire::Read.to_wire(); handle.poke(wire, poke_slab).await?; } } } - ("write", Some(path_atom)) => { - let Ok(write_cell) = file_cell.tail().as_cell() else { + "write" => { + let Ok((path, contents)) = + decode_from_noun::<(String, Bytes)>(file_cell.tail()) + else { continue; }; - let Ok(contents_atom) = write_cell.tail().as_atom() else { + let success = match write_then_flush(&path, contents.as_ref()).await { + Ok(_) => true, + Err(e) => { + error!("file driver: error finalizing path {}: {}", path, e); + false + } + }; + + let mut poke_slab = NounSlab::new(); + let path_atom = ::from_value(&mut poke_slab, path.as_str())?; + let contents_atom = ::from_bytes( + &mut poke_slab, + contents.as_ref(), + ); + let success_noun = success.to_noun(&mut poke_slab); + let poke_noun: Noun = T( + &mut poke_slab, + &[ + D(tas!(b"file")), + D(tas!(b"write")), + path_atom.as_noun(), + contents_atom.as_noun(), + success_noun, + ], + ); + poke_slab.set_root(poke_noun); + let wire = FileWire::Write.to_wire(); + handle.poke(wire, poke_slab).await?; + } + "batch-write" => { + let Ok(batch_entries) = + decode_from_noun::>(file_cell.tail()) + else { continue; }; - let path = path_atom.into_string()?; - let contents = contents_atom.as_ne_bytes(); - debug!("file driver: writing {} bytes to: {}", contents.len(), path); - - // Create parent directories if they don't exist - if let Some(parent) = std::path::Path::new(&path).parent() { - if let Err(e) = tokio::fs::create_dir_all(parent).await { - error!("file driver: error creating directories: {}", e); - let mut poke_slab = NounSlab::new(); - let poke_noun = T( - &mut poke_slab, - &[ - D(tas!(b"file")), - D(tas!(b"write")), - path_atom.as_noun(), - contents_atom.as_noun(), - NO, - ], - ); - poke_slab.set_root(poke_noun); - let wire = FileWire::Write.to_wire(); - handle.poke(wire, poke_slab).await?; - continue; + let mut results: Vec = + Vec::with_capacity(batch_entries.len()); + + let mut pending_flushes: Vec<(usize, String, File)> = + Vec::with_capacity(batch_entries.len()); + + for entry in batch_entries { + let BatchWriteRequestEntry { path, contents } = entry; + let idx = results.len(); + results.push(BatchWriteResultEntry { + path: path.clone(), + contents: contents.clone(), + success: false, + }); + + match prepare_file_write(&path, contents.as_ref()).await { + Ok(file) => pending_flushes.push((idx, path, file)), + Err(e) => error!("file driver: error writing to path {}: {}", path, e), } } - match tokio::fs::write(&path, contents).await { - Ok(_) => { - let mut poke_slab = NounSlab::new(); - let poke_noun = T( - &mut poke_slab, - &[ - D(tas!(b"file")), - D(tas!(b"write")), - path_atom.as_noun(), - contents_atom.as_noun(), - YES, - ], - ); - poke_slab.set_root(poke_noun); - let wire = FileWire::Write.to_wire(); - handle.poke(wire, poke_slab).await?; - } - Err(e) => { - error!("file driver: error writing to path: {}", e); - let mut poke_slab = NounSlab::new(); - let poke_noun = T( - &mut poke_slab, - &[ - D(tas!(b"file")), - D(tas!(b"write")), - path_atom.as_noun(), - contents_atom.as_noun(), - NO, - ], - ); - poke_slab.set_root(poke_noun); - let wire = FileWire::Write.to_wire(); - handle.poke(wire, poke_slab).await?; + for (idx, path, file) in pending_flushes { + match file.sync_all().await { + Ok(_) => results[idx].success = true, + Err(e) => error!("file driver: error flushing path {}: {}", path, e), } } + + let mut poke_slab = NounSlab::new(); + let entries_noun = results.to_noun(&mut poke_slab); + let batch_atom = ::from_value(&mut poke_slab, "batch-write")?; + let poke_noun = T( + &mut poke_slab, + &[D(tas!(b"file")), batch_atom.as_noun(), entries_noun], + ); + poke_slab.set_root(poke_noun); + let wire = FileWire::BatchWrite.to_wire(); + handle.poke(wire, poke_slab).await?; } _ => continue, } diff --git a/crates/nockapp/src/drivers/http/http.rs b/crates/nockapp/src/drivers/http/http.rs index 90dc09976..2a3c96489 100644 --- a/crates/nockapp/src/drivers/http/http.rs +++ b/crates/nockapp/src/drivers/http/http.rs @@ -344,8 +344,9 @@ pub fn http() -> IODriverFn { } let channel_map = RwLock::new(HashMap::::new()); - let regular_cache = Arc::new(RwLock::new(Option::::None)); - let htmx_cache = Arc::new(RwLock::new(Option::::None)); + let uri_map = RwLock::new(HashMap::::new()); + let regular_cache = Arc::new(RwLock::new(HashMap::::new())); + let htmx_cache = Arc::new(RwLock::new(HashMap::::new())); // Parse cache expiration from environment variable, default to never expire let cache_duration = env::var("EXPIRE_CACHE") @@ -366,7 +367,7 @@ pub fn http() -> IODriverFn { loop { interval.tick().await; debug!("invalidating regular response cache"); - *regular_cache.write().await = None; + regular_cache.write().await.clear(); } }) }; @@ -378,7 +379,7 @@ pub fn http() -> IODriverFn { loop { interval.tick().await; debug!("invalidating htmx response cache"); - *htmx_cache.write().await = None; + htmx_cache.write().await.clear(); } }) }; @@ -418,7 +419,7 @@ pub fn http() -> IODriverFn { let cache_to_use = if is_htmx { &htmx_cache } else { ®ular_cache }; let cache_read = cache_to_use.read().await; - if let Some(cached) = &*cache_read { + if let Some(cached) = cache_read.get(&msg.uri.to_string()) { // Check expiration only if cache_duration is set let should_serve = if let Some(cache_duration) = cache_duration { !cached.is_expired(cache_duration) @@ -438,6 +439,7 @@ pub fn http() -> IODriverFn { } channel_map.write().await.insert(msg.id, msg.resp); + uri_map.write().await.insert(msg.id, msg.uri.to_string()); let mut slab = NounSlab::new(); let id = Atom::from_value(&mut slab, msg.id) @@ -484,6 +486,7 @@ pub fn http() -> IODriverFn { error!("Kernel nacked the request for {}", msg.uri); let resp_tx = channel_map.write().await.remove(&msg.id) .ok_or(HttpError::ResponseChannelNotFound(msg.id))?; + uri_map.write().await.remove(&msg.id); let _ = resp_tx.send(Err(StatusCode::BAD_REQUEST)); } @@ -494,6 +497,7 @@ pub fn http() -> IODriverFn { error!("Error processing HTTP request: {}", e); // Try to send error response if we still have the channel if let Some(resp_tx) = channel_map.write().await.remove(&msg.id) { + uri_map.write().await.remove(&msg.id); let _ = resp_tx.send(Err(StatusCode::INTERNAL_SERVER_ERROR)); } } @@ -627,13 +631,15 @@ pub fn http() -> IODriverFn { // Cache logic - determine which cache to use based on effect type if status == StatusCode::OK { - let cached_response = CachedResponse::new(status, header_vec.clone(), body.clone()); - if tag_val == tas!(b"htmx") || tag_val == tas!(b"h-cache") { - debug!("caching HTMX response (htmx or h-cache effect)"); - *htmx_cache.write().await = Some(cached_response); - } else { - debug!("caching regular response (res or cache effect)"); - *regular_cache.write().await = Some(cached_response); + if let Some(request_uri) = uri_map.read().await.get(&id).cloned() { + let cached_response = CachedResponse::new(status, header_vec.clone(), body.clone()); + if tag_val == tas!(b"htmx") || tag_val == tas!(b"h-cache") { + debug!("caching HTMX response for {} (htmx or h-cache effect)", request_uri); + htmx_cache.write().await.insert(request_uri, cached_response); + } else { + debug!("caching regular response for {} (res or cache effect)", request_uri); + regular_cache.write().await.insert(request_uri, cached_response); + } } } @@ -647,6 +653,7 @@ pub fn http() -> IODriverFn { if tag_val == tas!(b"res") || tag_val == tas!(b"htmx") { let resp_tx = channel_map.write().await.remove(&id) .ok_or(HttpError::ResponseChannelNotFound(id))?; + uri_map.write().await.remove(&id); debug!("Sending response back to client for request id: {}", id); let _ = resp_tx.send(resp); } diff --git a/crates/nockapp/src/kernel/form.rs b/crates/nockapp/src/kernel/form.rs index 9cbbc8420..61a11518b 100644 --- a/crates/nockapp/src/kernel/form.rs +++ b/crates/nockapp/src/kernel/form.rs @@ -31,7 +31,7 @@ use crate::utils::{ create_context, current_da, NOCK_STACK_SIZE, NOCK_STACK_SIZE_HUGE, NOCK_STACK_SIZE_LARGE, NOCK_STACK_SIZE_MEDIUM, NOCK_STACK_SIZE_SMALL, NOCK_STACK_SIZE_TINY, }; -use crate::{AtomExt, CrownError, NounExt, Result, ToBytesExt}; +use crate::{AtomExt, CrownError, IndirectAtomExt, NounExt, Result, ToBytesExt}; pub(crate) const STATE_AXIS: u64 = 6; const LOAD_AXIS: u64 = 4; @@ -1152,12 +1152,12 @@ impl Serf { let bytes = random_bytes.as_bytes()?; let eny: Atom = Atom::from_bytes(&mut self.context.stack, &bytes); let our = ::from_value(&mut self.context.stack, 0)?; // Using 0 as default value - let now: Atom = unsafe { - let mut t_vec: Vec = vec![]; - t_vec.write_u128::(current_da().0)?; - IndirectAtom::new_raw_bytes(&mut self.context.stack, 16, t_vec.as_slice().as_ptr()) - .normalize_as_atom() - }; + let mut t_vec: Vec = vec![]; + t_vec.write_u128::(current_da().0)?; + let now: Atom = ::from_bytes( + &mut self.context.stack, + t_vec.as_slice(), + ); let event_num = D(self.event_num.load(Ordering::SeqCst) + 1); let base_wire_noun = wire_to_noun(&mut self.context.stack, &wire); diff --git a/crates/nockapp/src/lib.rs b/crates/nockapp/src/lib.rs index 95bbcfc3d..0684bbb22 100644 --- a/crates/nockapp/src/lib.rs +++ b/crates/nockapp/src/lib.rs @@ -25,7 +25,7 @@ pub use bytes::*; pub use drivers::*; pub use nockapp::*; pub use nockvm::noun::Noun; -pub use noun::{AtomExt, JammedNoun, NounExt}; +pub use noun::{AtomExt, IndirectAtomExt, JammedNoun, NounExt}; pub use utils::bytes::{ToBytes, ToBytesExt}; pub use utils::error::{CrownError, Result}; diff --git a/crates/nockapp/src/nockapp/error.rs b/crates/nockapp/src/nockapp/error.rs index 4b85b917c..8407c5f28 100644 --- a/crates/nockapp/src/nockapp/error.rs +++ b/crates/nockapp/src/nockapp/error.rs @@ -74,6 +74,16 @@ impl From> for NockAppError { } } } +impl From for NockAppError { + fn from(err: std::str::Utf8Error) -> NockAppError { + Self::OtherError(err.to_string()) + } +} +impl From for NockAppError { + fn from(err: noun_serde::NounDecodeError) -> Self { + Self::NounDecodeError(Box::new(err)) + } +} impl From for NockAppError { fn from(err: tokio::sync::broadcast::error::RecvError) -> Self { match err { diff --git a/crates/nockapp/src/noun/extensions.rs b/crates/nockapp/src/noun/extensions.rs index 1324b2f97..be32f3dde 100644 --- a/crates/nockapp/src/noun/extensions.rs +++ b/crates/nockapp/src/noun/extensions.rs @@ -1,63 +1,19 @@ -use core::str; -use std::iter::Iterator; use std::ptr::copy_nonoverlapping; -use bincode::{Decode, Encode}; use bytes::Bytes; use either::Either; -use nockvm::interpreter::Error; -use nockvm::mem::NockStack; +use nockvm::ext::AtomExt as CoreAtomExt; +pub use nockvm::ext::{IndirectAtomExt, JammedNoun, NounExt}; use nockvm::noun::{Atom, Cell, IndirectAtom, NounAllocator, D}; -use nockvm::serialization::{cue, jam}; use crate::noun::slab::NounSlab; use crate::{Noun, Result, ToBytes, ToBytesExt}; -pub trait NounExt { - fn cue_bytes(stack: &mut NockStack, bytes: &Bytes) -> Result; - fn cue_bytes_slice(stack: &mut NockStack, bytes: &[u8]) -> Result; - fn jam_self(self, stack: &mut NockStack) -> JammedNoun; - fn list_iter(self) -> impl Iterator; - fn eq_bytes(self, bytes: impl AsRef<[u8]>) -> bool; -} - -impl NounExt for Noun { - fn cue_bytes(stack: &mut NockStack, bytes: &Bytes) -> Result { - let atom = Atom::from_bytes(stack, bytes); - cue(stack, atom) - } - - // generally, we should be using `cue_bytes`, but if we're not going to be passing it around - // its OK to just cue a byte slice to avoid copying. - fn cue_bytes_slice(stack: &mut NockStack, bytes: &[u8]) -> Result { - let atom = unsafe { - IndirectAtom::new_raw_bytes(stack, bytes.len(), bytes.as_ptr()).normalize_as_atom() - }; - cue(stack, atom) - } - - fn jam_self(self, stack: &mut NockStack) -> JammedNoun { - JammedNoun::from_noun(stack, self) - } - - fn list_iter(self) -> impl Iterator { - NounListIterator(self) - } - - fn eq_bytes(self, bytes: impl AsRef<[u8]>) -> bool { - if let Ok(a) = self.as_atom() { - a.eq_bytes(bytes) - } else { - false - } - } -} - // TODO: This exists largely because nockapp doesn't own the [`Atom`] type from [`nockvm`]. // TODO: The next step for this should be to lower the methods on this trait to a concrete `impl` stanza for [`Atom`] in [`nockvm`]. // TODO: In the course of doing so, we should split out a serialization trait that has only the [`AtomExt::from_value`] method as a public API in [`nockvm`]. // The goal would be to canonicalize the Atom representations of various Rust types. When it needs to be specialized, users can make a newtype. -pub trait AtomExt { +pub trait AtomExt: CoreAtomExt { fn from_bytes(allocator: &mut A, bytes: &Bytes) -> Atom; fn from_value(allocator: &mut A, value: T) -> Result; fn eq_bytes(self, bytes: impl AsRef<[u8]>) -> bool; @@ -68,120 +24,28 @@ pub trait AtomExt { impl AtomExt for Atom { // TODO: This is iffy. What byte representation is it expecting and why? fn from_bytes(allocator: &mut A, bytes: &Bytes) -> Atom { - unsafe { - IndirectAtom::new_raw_bytes(allocator, bytes.len(), bytes.as_ptr()).normalize_as_atom() - } + ::from_bytes(allocator, bytes.as_ref()) } // TODO: This is worth making into a public/supported part of [`nockvm`]'s API. fn from_value(allocator: &mut A, value: T) -> Result { - unsafe { - let data: Bytes = value.as_bytes()?; - Ok( - IndirectAtom::new_raw_bytes(allocator, data.len(), data.as_ptr()) - .normalize_as_atom(), - ) - } + let data: Bytes = value.as_bytes()?; + Ok(::from_bytes(allocator, data.as_ref())) } /** Test for byte equality, ignoring trailing 0s in the Atom representation beyond the length of the bytes compared to */ fn eq_bytes(self, bytes: impl AsRef<[u8]>) -> bool { - let bytes_ref = bytes.as_ref(); - let atom_bytes = self.as_ne_bytes(); - // TODO: Turn this into a match on a cmp? - #[allow(clippy::comparison_chain)] - if bytes_ref.len() > atom_bytes.len() { - false - } else if bytes_ref.len() == atom_bytes.len() { - atom_bytes == bytes_ref - } else { - // check for nul bytes beyond comparing bytestring - for b in &atom_bytes[bytes_ref.len()..] { - if *b != 0u8 { - return false; - } - } - &atom_bytes[0..bytes_ref.len()] == bytes_ref - } + CoreAtomExt::eq_bytes(&self, bytes) } fn to_bytes_until_nul(self) -> Result> { - let bytes = str::from_utf8(self.as_ne_bytes())?; - Ok(bytes.trim_end_matches('\0').as_bytes().to_vec()) + CoreAtomExt::to_bytes_until_nul(&self).map_err(Into::into) } fn into_string(self) -> Result { - let str = str::from_utf8(self.as_ne_bytes())?; - Ok(str.trim_end_matches('\0').to_string()) - } -} - -#[derive(Clone, PartialEq, Debug, Encode, Decode)] -pub struct JammedNoun(#[bincode(with_serde)] pub Bytes); - -impl JammedNoun { - pub fn new(bytes: Bytes) -> Self { - Self(bytes) - } - - pub fn from_noun(stack: &mut NockStack, noun: Noun) -> Self { - let jammed_atom = jam(stack, noun); - JammedNoun(Bytes::copy_from_slice(jammed_atom.as_ne_bytes())) - } - - pub fn cue_self(&self, stack: &mut NockStack) -> Result { - let atom = unsafe { - IndirectAtom::new_raw_bytes(stack, self.0.len(), self.0.as_ptr()).normalize_as_atom() - }; - cue(stack, atom) - } -} - -impl From<&[u8]> for JammedNoun { - fn from(bytes: &[u8]) -> Self { - JammedNoun::new(Bytes::copy_from_slice(bytes)) - } -} - -impl From> for JammedNoun { - fn from(byte_vec: Vec) -> Self { - JammedNoun::new(Bytes::from(byte_vec)) - } -} - -impl AsRef for JammedNoun { - fn as_ref(&self) -> &Bytes { - &self.0 - } -} - -impl AsRef<[u8]> for JammedNoun { - fn as_ref(&self) -> &[u8] { - self.0.as_ref() - } -} - -impl Default for JammedNoun { - fn default() -> Self { - JammedNoun::new(Bytes::new()) - } -} - -pub struct NounListIterator(Noun); - -impl Iterator for NounListIterator { - type Item = Noun; - fn next(&mut self) -> Option { - if let Ok(it) = self.0.as_cell() { - self.0 = it.tail(); - Some(it.head()) - } else if unsafe { self.0.raw_equals(&D(0)) } { - None - } else { - panic!("Improper list terminator: {:?}", self.0) - } + CoreAtomExt::into_string(self).map_err(Into::into) } } @@ -221,17 +85,16 @@ impl IntoNoun for Noun { impl IntoNoun for &str { fn into_noun(self) -> Noun { let mut slab: NounSlab = NounSlab::new(); - let contents_atom = unsafe { - let bytes = self.to_bytes().unwrap_or_else(|err| { - panic!( - "Panicked with {err:?} at {}:{} (git sha: {:?})", - file!(), - line!(), - option_env!("GIT_SHA") - ) - }); - IndirectAtom::new_raw_bytes_ref(&mut slab, bytes.as_slice()).normalize_as_atom() - }; + let bytes = self.to_bytes().unwrap_or_else(|err| { + panic!( + "Panicked with {err:?} at {}:{} (git sha: {:?})", + file!(), + line!(), + option_env!("GIT_SHA") + ) + }); + let contents_atom = + ::from_bytes(&mut slab, bytes.as_slice()); Noun::from_atom(contents_atom) } } diff --git a/crates/nockapp/src/utils/error.rs b/crates/nockapp/src/utils/error.rs index 4b00580d9..87129c4eb 100644 --- a/crates/nockapp/src/utils/error.rs +++ b/crates/nockapp/src/utils/error.rs @@ -1,3 +1,4 @@ +use noun_serde::NounDecodeError; use thiserror::Error; #[derive(Debug, Error)] @@ -85,6 +86,12 @@ impl From for NounDecodeError { + fn from(e: CrownError) -> Self { + NounDecodeError::Custom(e.to_string()) + } +} + #[derive(Debug)] pub struct QueueErrorWrapper(pub yaque::TrySendError>); diff --git a/crates/nockapp/src/utils/mod.rs b/crates/nockapp/src/utils/mod.rs index 96dd090a7..2206173a1 100644 --- a/crates/nockapp/src/utils/mod.rs +++ b/crates/nockapp/src/utils/mod.rs @@ -19,7 +19,7 @@ use nockvm::jets::cold::Cold; use nockvm::jets::hot::{Hot, HotEntry}; use nockvm::jets::warm::Warm; use nockvm::mem::NockStack; -use nockvm::noun::{Atom, IndirectAtom, Noun, NounAllocator, D}; +use nockvm::noun::{Noun, D}; use nockvm::serialization::jam; use nockvm::trace::TraceInfo; use slogger::CrownSlogger; @@ -93,14 +93,7 @@ pub fn current_epoch_ms() -> u128 { .as_millis() } -pub fn make_tas(allocator: &mut A, tas: &str) -> Atom { - let tas_bytes: &[u8] = tas.as_bytes(); - unsafe { - let mut tas_atom = - IndirectAtom::new_raw_bytes(allocator, tas_bytes.len(), tas_bytes.as_ptr()); - tas_atom.normalize_as_atom() - } -} +pub use nockvm::ext::make_tas; // serialize a noun for writing over a socket or a file descriptor pub fn serialize_noun(stack: &mut NockStack, noun: Noun) -> Result> { diff --git a/crates/nockchain-libp2p-io/src/driver.rs b/crates/nockchain-libp2p-io/src/driver.rs index 4f27f0507..397d27c0d 100644 --- a/crates/nockchain-libp2p-io/src/driver.rs +++ b/crates/nockchain-libp2p-io/src/driver.rs @@ -28,7 +28,8 @@ use nockapp::utils::error::{CrownError, ExternalError}; use nockapp::utils::make_tas; use nockapp::utils::scry::*; use nockapp::wire::{Wire, WireRepr}; -use nockapp::{AtomExt, NockAppError, NounExt}; +use nockapp::{AtomExt, NockAppError}; +use nockvm::ext::NounExt; use nockvm::noun::{Atom, Noun, D, T}; use nockvm_macros::tas; use rand::rng; diff --git a/crates/nockchain-libp2p-io/src/messages.rs b/crates/nockchain-libp2p-io/src/messages.rs index 694f484e9..1bd82d4b4 100644 --- a/crates/nockchain-libp2p-io/src/messages.rs +++ b/crates/nockchain-libp2p-io/src/messages.rs @@ -1,6 +1,7 @@ use libp2p::PeerId; use nockapp::noun::slab::NounSlab; -use nockapp::{NockAppError, NounExt}; +use nockapp::NockAppError; +use nockvm::ext::NounExt; use nockvm::noun::{Noun, D}; use nockvm_macros::tas; use serde_bytes::ByteBuf; diff --git a/crates/nockchain-math/Cargo.toml b/crates/nockchain-math/Cargo.toml index 97b7acb6c..e8ba56f83 100644 --- a/crates/nockchain-math/Cargo.toml +++ b/crates/nockchain-math/Cargo.toml @@ -11,7 +11,6 @@ bytes.workspace = true either.workspace = true hex-literal.workspace = true ibig.workspace = true -nockapp.workspace = true nockvm.workspace = true nockvm_macros.workspace = true noun-serde.workspace = true diff --git a/crates/nockchain-math/src/crypto/argon2.rs b/crates/nockchain-math/src/crypto/argon2.rs index f8a8252d5..de2ed91f5 100644 --- a/crates/nockchain-math/src/crypto/argon2.rs +++ b/crates/nockchain-math/src/crypto/argon2.rs @@ -1,9 +1,57 @@ use argon2::{Algorithm, Argon2, AssociatedData, Params, Version}; -use nockapp::utils::bytes::Byts; -use nockapp::utils::make_tas; -use nockapp::{AtomExt, Noun}; +use ibig::UBig; +use nockvm::ext::{make_tas, AtomExt}; use nockvm::jets::cold::{Nounable, NounableResult}; -use nockvm::noun::{NounAllocator, Slots, D, T}; +use nockvm::noun::{Atom, Noun, NounAllocator, Slots, D, T}; + +/// Wrapper for the `$byts` Hoon cell `[wid=@ dat=@]`. +/// `wid` records the bit-width, but the Argon2 jet only needs the big-endian payload, +/// so we store just the bytes here. +/// +/// Note that the conversion does not perfectly roundtrip because leading zeroes +/// are discarded in the rust type. +#[derive(Debug, Clone)] +pub struct Byts(pub Vec); + +impl Byts { + pub fn new(bytes: Vec) -> Self { + Self(bytes) + } +} + +impl Nounable for Byts { + type Target = Self; + fn into_noun(self, stack: &mut A) -> Noun { + let big = UBig::from_be_bytes(&self.0); + let wid = D(self.0.len() as u64); + let dat = Atom::from_ubig(stack, &big).as_noun(); + T(stack, &[wid, dat]) + } + fn from_noun(_stack: &mut A, noun: &Noun) -> NounableResult { + let size = noun.slot(2)?; + let dat = noun.slot(3)?.as_atom()?; + + let wid = size.as_atom()?.as_u64()? as usize; + let mut res = vec![0; wid]; + + let bytes_be = dat.to_be_bytes(); + + // Iterate over the bytes in reverse order + // Start copying at the first non zero value encountered + let mut start_copying = false; + let mut copy_index = 0; + for byte in bytes_be.iter() { + if *byte != 0 { + start_copying = true; + } + if start_copying { + res[copy_index] = *byte; + copy_index += 1; + } + } + Ok(Byts(res)) + } +} #[derive(Debug, Clone)] pub struct Argon2Args { diff --git a/crates/nockchain-math/src/shape.rs b/crates/nockchain-math/src/shape.rs index 96513a06a..d70964d57 100644 --- a/crates/nockchain-math/src/shape.rs +++ b/crates/nockchain-math/src/shape.rs @@ -1,7 +1,6 @@ -use nockapp::Noun; use nockvm::jets::list::util::flop; use nockvm::jets::JetErr; -use nockvm::noun::{NounAllocator, D, T}; +use nockvm::noun::{Noun, NounAllocator, D, T}; use noun_serde::NounEncode; pub fn dyck(stack: &mut A, t: Noun) -> Result { diff --git a/crates/nockchain-math/src/tip5/hash.rs b/crates/nockchain-math/src/tip5/hash.rs index 1d053df9e..e0ab8f915 100644 --- a/crates/nockchain-math/src/tip5/hash.rs +++ b/crates/nockchain-math/src/tip5/hash.rs @@ -1,7 +1,6 @@ -use nockapp::Noun; use nockvm::jets::list::util::{lent, weld}; use nockvm::jets::JetErr; -use nockvm::noun::{NounAllocator, D, T}; +use nockvm::noun::{Noun, NounAllocator, D, T}; use noun_serde::{NounDecode, NounEncode}; use super::*; diff --git a/crates/nockchain-math/src/zoon/zmap.rs b/crates/nockchain-math/src/zoon/zmap.rs index 1efed8af4..16fa639d3 100644 --- a/crates/nockchain-math/src/zoon/zmap.rs +++ b/crates/nockchain-math/src/zoon/zmap.rs @@ -1,5 +1,8 @@ +use nockvm::interpreter::Context; +use nockvm::jets::util::slot; use nockvm::jets::JetErr; use nockvm::noun::{Noun, NounAllocator, D, T}; +use nockvm::site::{site_slam, Site}; use super::common::*; use crate::noun_ext::NounMathExt; @@ -48,3 +51,28 @@ pub fn z_map_put( } } } + +/// Reduce a z-map using the gate's cached `Site`, mirroring Hoon `++rep`. +pub fn z_map_rep(context: &mut Context, map: &Noun, gate: &mut Noun) -> Result { + let prod = slot(*gate, 13)?; + let site = Site::new(context, gate); + let mut reducer = |node: Noun, acc: Noun| -> Result { + let sam = T(&mut context.stack, &[node, acc]); + site_slam(context, &site, sam) + }; + rep_fold(*map, prod, &mut reducer) +} + +fn rep_fold(tree: Noun, acc: Noun, reducer: &mut F) -> Result +where + F: FnMut(Noun, Noun) -> Result, +{ + if unsafe { tree.raw_equals(&D(0)) } { + return Ok(acc); + } + + let [entry, left, right] = tree.uncell()?; + let acc = reducer(entry, acc)?; + let acc = rep_fold(left, acc, reducer)?; + rep_fold(right, acc, reducer) +} diff --git a/crates/nockchain-types/src/tx_engine/v1/tx.rs b/crates/nockchain-types/src/tx_engine/v1/tx.rs index 8536896cf..ef0fea071 100644 --- a/crates/nockchain-types/src/tx_engine/v1/tx.rs +++ b/crates/nockchain-types/src/tx_engine/v1/tx.rs @@ -1,11 +1,10 @@ use nockapp::noun::slab::{NockJammer, NounSlab}; use nockapp::noun::NounAllocatorExt; -use nockapp::utils::make_tas; -use nockapp::AtomExt; use nockchain_math::noun_ext::NounMathExt; use nockchain_math::structs::{HoonList, HoonMapIter}; use nockchain_math::zoon::common::DefaultTipHasher; use nockchain_math::zoon::{zmap, zset}; +use nockvm::ext::{make_tas, AtomExt}; use nockvm::noun::{Noun, NounAllocator, D}; use noun_serde::{NounDecode, NounDecodeError, NounEncode}; diff --git a/crates/nockchain-wallet/src/command.rs b/crates/nockchain-wallet/src/command.rs index 2adab213f..00989c060 100644 --- a/crates/nockchain-wallet/src/command.rs +++ b/crates/nockchain-wallet/src/command.rs @@ -339,7 +339,7 @@ pub enum Commands { /// Create a transaction (use --refund-pkh when spending legacy v0 notes) #[command( name = "create-tx", - override_usage = "nockchain-wallet create-tx --names --recipient --fee [--refund-pkh ] [--include-data ]\n\n# NOTE: --refund-pkh is required when spending from v0 notes. For v1 notes, the refund defaults to the note owner. --include-data defaults to true (pass 'false' to exclude note data).\n\nExamples:\n # Send to a single recipient\n nockchain-wallet create-tx \\\n --names \"[first1 last1],[first2 last2]\" \\\n --recipient \":\" \\\n --fee 10 \\\n --refund-pkh " + override_usage = "nockchain-wallet create-tx --names --recipient --fee [--refund-pkh ] [--include-data ] [--save-raw-tx] \n\n# NOTE: --refund-pkh is required when spending from v0 notes. For v1 notes, the refund defaults to the note owner. --include-data defaults to true (pass 'false' to exclude note data). \n\nExamples:\n # Send to a single recipient\n nockchain-wallet create-tx \\\n --names \"[first1 last1],[first2 last2]\" \\\n --recipient \":\" \\\n --fee 10 \\\n --refund-pkh " )] CreateTx { /// Names of notes to spend (comma-separated) @@ -368,6 +368,10 @@ pub enum Commands { default_value_t = true )] include_data: bool, + /// For debugging purposes. If true, the raw-tx jam will be saved in the + /// txs-debug folder in the current working directory. + #[arg(long, default_value = "false")] + save_raw_tx: bool, }, /// Export a master public key diff --git a/crates/nockchain-wallet/src/main.rs b/crates/nockchain-wallet/src/main.rs index 752de0c5e..c7e00a6b9 100644 --- a/crates/nockchain-wallet/src/main.rs +++ b/crates/nockchain-wallet/src/main.rs @@ -255,6 +255,7 @@ async fn main() -> Result<(), NockAppError> { index, hardened, include_data, + save_raw_tx, } => Wallet::create_tx( names.clone(), recipients.clone(), @@ -263,6 +264,7 @@ async fn main() -> Result<(), NockAppError> { *index, *hardened, *include_data, + *save_raw_tx, ), Commands::SendTx { transaction } => Wallet::send_tx(transaction), Commands::ShowTx { transaction } => Wallet::show_tx(transaction), @@ -827,6 +829,7 @@ impl Wallet { index: Option, hardened: bool, include_data: bool, + save_raw_tx: bool, ) -> CommandNoun { let mut slab = NounSlab::new(); @@ -886,10 +889,14 @@ impl Wallet { SIG }; let include_data_noun = include_data.to_noun(&mut slab); + let save_raw_tx_noun = save_raw_tx.to_noun(&mut slab); Self::wallet( "create-tx", - &[names_noun, order_noun, fee_noun, sign_key_noun, refund_noun, include_data_noun], + &[ + names_noun, order_noun, fee_noun, sign_key_noun, refund_noun, include_data_noun, + save_raw_tx_noun, + ], Operation::Poke, &mut slab, ) @@ -1713,6 +1720,7 @@ mod tests { None, false, true, + false, )?; let wire = WalletWire::Command(Commands::CreateTx { names: names.clone(), @@ -1722,6 +1730,7 @@ mod tests { index: None, hardened: false, include_data: true, + save_raw_tx: false, }) .to_wire(); let spend_result = wallet.app.poke(wire, noun.clone()).await?; @@ -1757,6 +1766,7 @@ mod tests { None, false, true, + false, )?; let wire1 = WalletWire::Command(Commands::ImportKeys { @@ -1777,6 +1787,7 @@ mod tests { index: None, hardened: false, include_data: true, + save_raw_tx: false, }) .to_wire(); let spend_result = wallet.app.poke(wire2, spend_noun.clone()).await?; diff --git a/crates/nockchain/src/config.rs b/crates/nockchain/src/config.rs index 5abf51260..f56e72d36 100644 --- a/crates/nockchain/src/config.rs +++ b/crates/nockchain/src/config.rs @@ -126,8 +126,8 @@ pub struct NockchainCli { pub fakenet_v1_phase: Option, #[arg(long, help = "Path to fake genesis block jam file")] pub fakenet_genesis_jam_path: Option, - #[arg(long, value_parser = clap::value_parser!(std::net::SocketAddr), default_value = "127.0.0.1:5555")] - pub bind_public_grpc_addr: std::net::SocketAddr, + #[arg(long, help = "Public gRPC binding address (off by default), recommended value = \"127.0.0.1:5555\"", value_parser = clap::value_parser!(std::net::SocketAddr))] + pub bind_public_grpc_addr: Option, #[arg(long, default_value = "5555")] pub bind_private_grpc_port: u16, #[arg(long, default_value = "false")] @@ -201,7 +201,7 @@ mod tests { fakenet_v1_phase: None, fakenet_genesis_jam_path: None, fakenet_coinbase_timelock_min: None, - bind_public_grpc_addr: "127.0.0.1:5555".parse().unwrap(), + bind_public_grpc_addr: Some("127.0.0.1:5555".parse().unwrap()), bind_private_grpc_port: 5555, fast_sync: false, } diff --git a/crates/nockchain/src/lib.rs b/crates/nockchain/src/lib.rs index ee8ab9e0e..56f7054f7 100644 --- a/crates/nockchain/src/lib.rs +++ b/crates/nockchain/src/lib.rs @@ -136,12 +136,20 @@ pub mod driver_init { /// can be expanded into a struct if necessary #[derive(Debug, Clone)] pub enum NockchainAPIConfig { - EnablePublicServer, + EnablePublicServer(std::net::SocketAddr), DisablePublicServer, } + impl NockchainAPIConfig { pub fn deploy_public(&self) -> bool { - matches!(self, NockchainAPIConfig::EnablePublicServer) + matches!(self, NockchainAPIConfig::EnablePublicServer(_)) + } + + pub fn addr(&self) -> Option { + match self { + NockchainAPIConfig::EnablePublicServer(addr) => Some(*addr), + NockchainAPIConfig::DisablePublicServer => None, + } } } @@ -498,10 +506,11 @@ pub async fn init_with_kernel( nockapp.add_io_driver(born_driver).await; if server_config.deploy_public() { + let addr = server_config + .addr() + .expect("addr should be Some when deploy_public is true"); nockapp - .add_io_driver(nockapp_grpc::public_nockchain::grpc_server_driver( - cli.bind_public_grpc_addr, - )) + .add_io_driver(nockapp_grpc::public_nockchain::grpc_server_driver(addr)) .await; } nockapp diff --git a/crates/nockchain/src/main.rs b/crates/nockchain/src/main.rs index d2cad6894..04f4a8d94 100644 --- a/crates/nockchain/src/main.rs +++ b/crates/nockchain/src/main.rs @@ -24,13 +24,15 @@ async fn main() -> Result<(), Box> { boot::init_default_tracing(&cli.nockapp_cli); let prover_hot_state = produce_prover_hot_state(); - let mut nockchain: NockApp = nockchain::init_with_kernel( - cli, - KERNEL, - prover_hot_state.as_slice(), - NockchainAPIConfig::DisablePublicServer, - ) - .await?; + + let api_config = if let Some(addr) = cli.bind_public_grpc_addr { + NockchainAPIConfig::EnablePublicServer(addr) + } else { + NockchainAPIConfig::DisablePublicServer + }; + + let mut nockchain: NockApp = + nockchain::init_with_kernel(cli, KERNEL, prover_hot_state.as_slice(), api_config).await?; nockchain.run().await?; Ok(()) } diff --git a/crates/nockchain/src/mining.rs b/crates/nockchain/src/mining.rs index c26c3802b..8397e650d 100644 --- a/crates/nockchain/src/mining.rs +++ b/crates/nockchain/src/mining.rs @@ -6,11 +6,12 @@ use nockapp::nockapp::driver::{IODriverFn, NockAppHandle, PokeResult}; use nockapp::nockapp::wire::Wire; use nockapp::nockapp::NockAppError; use nockapp::noun::slab::NounSlab; -use nockapp::noun::{AtomExt, NounExt}; +use nockapp::noun::AtomExt; use nockapp::save::SaveableCheckpoint; use nockapp::utils::NOCK_STACK_SIZE_TINY; use nockapp::CrownError; use nockchain_libp2p_io::tip5_util::tip5_hash_to_base58; +use nockvm::ext::NounExt; use nockvm::interpreter::NockCancelToken; use nockvm::noun::{Atom, D, NO, T, YES}; use nockvm_macros::tas; diff --git a/crates/nockvm/rust/nockvm/Cargo.toml b/crates/nockvm/rust/nockvm/Cargo.toml index 6ec0f2e95..b810ed5fb 100644 --- a/crates/nockvm/rust/nockvm/Cargo.toml +++ b/crates/nockvm/rust/nockvm/Cargo.toml @@ -14,6 +14,8 @@ murmur3.workspace = true nockvm_crypto = { workspace = true } nockvm_macros.workspace = true +bincode = { workspace = true, features = ["serde"] } +bytes = { workspace = true, features = ["serde"] } bitvec = { workspace = true } either = { workspace = true } intmap = { workspace = true } @@ -24,6 +26,7 @@ memmap2 = { workspace = true } num-derive = { workspace = true } num-traits = { workspace = true } rand = { workspace = true } +serde = { workspace = true } signal-hook = { workspace = true } slotmap = { workspace = true } static_assertions = { workspace = true } diff --git a/crates/nockvm/rust/nockvm/src/ext.rs b/crates/nockvm/rust/nockvm/src/ext.rs new file mode 100644 index 000000000..313dfe147 --- /dev/null +++ b/crates/nockvm/rust/nockvm/src/ext.rs @@ -0,0 +1,179 @@ +use std::str; + +use bincode::{Decode, Encode}; +use bytes::Bytes; + +use crate::interpreter::Error; +use crate::mem::NockStack; +use crate::noun::{Atom, IndirectAtom, Noun, NounAllocator, D}; +use crate::serialization::{cue, jam}; + +/// Convenience helpers for working with `Atom`. +pub trait AtomExt { + fn from_bytes(allocator: &mut A, bytes: &[u8]) -> Atom; + fn eq_bytes>(&self, bytes: B) -> bool; + fn to_bytes_until_nul(&self) -> std::result::Result, str::Utf8Error>; + fn into_string(self) -> std::result::Result; +} + +impl AtomExt for Atom { + fn from_bytes(allocator: &mut A, bytes: &[u8]) -> Atom { + ::from_bytes(allocator, bytes) + } + + fn eq_bytes>(&self, bytes: B) -> bool { + let bytes_ref = bytes.as_ref(); + let atom_bytes = self.as_ne_bytes(); + if bytes_ref.len() > atom_bytes.len() { + return false; + } + if bytes_ref.len() == atom_bytes.len() { + return atom_bytes == bytes_ref; + } + if atom_bytes[bytes_ref.len()..].iter().any(|b| *b != 0) { + return false; + } + &atom_bytes[0..bytes_ref.len()] == bytes_ref + } + + fn to_bytes_until_nul(&self) -> std::result::Result, str::Utf8Error> { + str::from_utf8(self.as_ne_bytes()) + .map(|bytes| bytes.trim_end_matches('\0').as_bytes().to_vec()) + } + + fn into_string(self) -> std::result::Result { + str::from_utf8(self.as_ne_bytes()).map(|string| string.trim_end_matches('\0').to_string()) + } +} + +/// Extension helpers for safely constructing indirect atoms. +pub trait IndirectAtomExt { + fn from_bytes(allocator: &mut A, bytes: &[u8]) -> Atom; + + unsafe fn from_raw_parts( + allocator: &mut A, + size: usize, + data: *const u8, + ) -> Atom; +} + +impl IndirectAtomExt for IndirectAtom { + fn from_bytes(allocator: &mut A, bytes: &[u8]) -> Atom { + unsafe { Self::from_raw_parts(allocator, bytes.len(), bytes.as_ptr()) } + } + + unsafe fn from_raw_parts( + allocator: &mut A, + size: usize, + data: *const u8, + ) -> Atom { + Self::new_raw_bytes(allocator, size, data).normalize_as_atom() + } +} + +/// Helpers for working with nouns directly. +pub trait NounExt { + fn cue_bytes(stack: &mut NockStack, bytes: &Bytes) -> std::result::Result; + fn cue_bytes_slice(stack: &mut NockStack, bytes: &[u8]) -> std::result::Result; + fn jam_self(self, stack: &mut NockStack) -> JammedNoun; + fn list_iter(self) -> NounListIterator; + fn eq_bytes(self, bytes: impl AsRef<[u8]>) -> bool; +} + +impl NounExt for Noun { + fn cue_bytes(stack: &mut NockStack, bytes: &Bytes) -> std::result::Result { + let atom = ::from_bytes(stack, bytes.as_ref()); + cue(stack, atom) + } + + fn cue_bytes_slice(stack: &mut NockStack, bytes: &[u8]) -> std::result::Result { + let atom = ::from_bytes(stack, bytes); + cue(stack, atom) + } + + fn jam_self(self, stack: &mut NockStack) -> JammedNoun { + JammedNoun::from_noun(stack, self) + } + + fn list_iter(self) -> NounListIterator { + NounListIterator(self) + } + + fn eq_bytes(self, bytes: impl AsRef<[u8]>) -> bool { + if let Ok(atom) = self.as_atom() { + atom.eq_bytes(bytes) + } else { + false + } + } +} + +#[derive(Clone, PartialEq, Debug, Encode, Decode)] +pub struct JammedNoun(#[bincode(with_serde)] pub Bytes); + +impl JammedNoun { + pub fn new(bytes: Bytes) -> Self { + Self(bytes) + } + + pub fn from_noun(stack: &mut NockStack, noun: Noun) -> Self { + let jammed_atom = jam(stack, noun); + JammedNoun(Bytes::copy_from_slice(jammed_atom.as_ne_bytes())) + } + + pub fn cue_self(&self, stack: &mut NockStack) -> std::result::Result { + let atom = ::from_bytes(stack, self.0.as_ref()); + cue(stack, atom) + } +} + +impl From<&[u8]> for JammedNoun { + fn from(bytes: &[u8]) -> Self { + JammedNoun::new(Bytes::copy_from_slice(bytes)) + } +} + +impl From> for JammedNoun { + fn from(byte_vec: Vec) -> Self { + JammedNoun::new(Bytes::from(byte_vec)) + } +} + +impl AsRef for JammedNoun { + fn as_ref(&self) -> &Bytes { + &self.0 + } +} + +impl AsRef<[u8]> for JammedNoun { + fn as_ref(&self) -> &[u8] { + self.0.as_ref() + } +} + +impl Default for JammedNoun { + fn default() -> Self { + JammedNoun::new(Bytes::new()) + } +} + +pub struct NounListIterator(Noun); + +impl Iterator for NounListIterator { + type Item = Noun; + + fn next(&mut self) -> Option { + if let Ok(cell) = self.0.as_cell() { + self.0 = cell.tail(); + Some(cell.head()) + } else if unsafe { self.0.raw_equals(&D(0)) } { + None + } else { + panic!("Improper list terminator: {:?}", self.0); + } + } +} + +pub fn make_tas(allocator: &mut A, tas: &str) -> Atom { + ::from_bytes(allocator, tas.as_bytes()) +} diff --git a/crates/nockvm/rust/nockvm/src/lib.rs b/crates/nockvm/rust/nockvm/src/lib.rs index 24e05a55e..5fafe52ac 100644 --- a/crates/nockvm/rust/nockvm/src/lib.rs +++ b/crates/nockvm/rust/nockvm/src/lib.rs @@ -6,6 +6,7 @@ extern crate lazy_static; extern crate num_derive; #[macro_use] extern crate static_assertions; +pub mod ext; mod flog; pub mod hamt; pub mod interpreter; diff --git a/crates/noun-serde-derive/src/lib.rs b/crates/noun-serde-derive/src/lib.rs index c84200914..c51909524 100644 --- a/crates/noun-serde-derive/src/lib.rs +++ b/crates/noun-serde-derive/src/lib.rs @@ -312,10 +312,10 @@ pub fn derive_noun_encode(input: TokenStream) -> TokenStream { // Tagged encoding: [%tag [[%field1 value1] [%field2 value2] ...]] quote! { #name::#variant_name { #(#field_names),* } => { - let tag = ::nockapp::utils::make_tas(allocator, #tag).as_noun(); + let tag = ::nockvm::ext::make_tas(allocator, #tag).as_noun(); let mut field_nouns = Vec::new(); #( - let field_tag = ::nockapp::utils::make_tas(allocator, stringify!(#field_names)).as_noun(); + let field_tag = ::nockvm::ext::make_tas(allocator, stringify!(#field_names)).as_noun(); let field_value = ::noun_serde::NounEncode::to_noun(#field_names, allocator); field_nouns.push(::nockvm::noun::T(allocator, &[field_tag, field_value])); )* @@ -334,7 +334,7 @@ pub fn derive_noun_encode(input: TokenStream) -> TokenStream { // Untagged encoding: [%tag [value1 value2 ...]] quote! { #name::#variant_name { #(#field_names),* } => { - let tag = ::nockapp::utils::make_tas(allocator, #tag).as_noun(); + let tag = ::nockvm::ext::make_tas(allocator, #tag).as_noun(); let mut field_nouns = vec![tag]; #( let field_noun = ::noun_serde::NounEncode::to_noun(#field_names, allocator); @@ -355,7 +355,7 @@ pub fn derive_noun_encode(input: TokenStream) -> TokenStream { let _ty = &fields.unnamed[0].ty; quote! { #name::#variant_name(value) => { - let tag = ::nockapp::utils::make_tas(allocator, #tag).as_noun(); + let tag = ::nockvm::ext::make_tas(allocator, #tag).as_noun(); let data = ::noun_serde::NounEncode::to_noun(value, allocator); ::nockvm::noun::T(allocator, &[tag, data]) } @@ -367,7 +367,7 @@ pub fn derive_noun_encode(input: TokenStream) -> TokenStream { quote! { #name::#variant_name(#(#field_idents),*) => { - let tag = ::nockapp::utils::make_tas(allocator, #tag).as_noun(); + let tag = ::nockvm::ext::make_tas(allocator, #tag).as_noun(); // Build nested cell structure right-to-left let mut data = ::noun_serde::NounEncode::to_noun(#first_field, allocator); #( @@ -381,7 +381,7 @@ pub fn derive_noun_encode(input: TokenStream) -> TokenStream { Fields::Unit => { quote! { #name::#variant_name => { - ::nockapp::utils::make_tas(allocator, #tag).as_noun() + ::nockvm::ext::make_tas(allocator, #tag).as_noun() } } } @@ -722,7 +722,7 @@ pub fn derive_noun_decode(input: TokenStream) -> TokenStream { quote! { #name::#variant_name(#(#field_names),*) => { - let tag = ::nockapp::utils::make_tas(allocator, #tag).as_noun(); + let tag = ::nockvm::ext::make_tas(allocator, #tag).as_noun(); // Build nested cell structure right-to-left let mut data = ::noun_serde::NounEncode::to_noun(#first_field, allocator); #( diff --git a/crates/noun-serde/Cargo.toml b/crates/noun-serde/Cargo.toml index f04d75669..3eea5d859 100644 --- a/crates/noun-serde/Cargo.toml +++ b/crates/noun-serde/Cargo.toml @@ -4,9 +4,9 @@ version = "0.1.0" edition = "2021" [dependencies] -nockapp.workspace = true nockvm.workspace = true noun-serde-derive.workspace = true thiserror.workspace = true tracing.workspace = true ibig.workspace = true +bytes.workspace = true diff --git a/crates/noun-serde/src/lib.rs b/crates/noun-serde/src/lib.rs index 3104a8e11..2674a0fa0 100644 --- a/crates/noun-serde/src/lib.rs +++ b/crates/noun-serde/src/lib.rs @@ -1,14 +1,15 @@ use std::collections::{BTreeMap, HashMap, HashSet}; +use std::str; +use bytes::Bytes; use ibig::UBig; pub mod wallet; -#[allow(unused_imports)] -use nockapp::utils::make_tas; -use nockapp::{AtomExt, NockAppError}; +use nockvm::ext::{make_tas, AtomExt}; use nockvm::jets::util::BAIL_FAIL; use nockvm::jets::JetErr; #[allow(unused_imports)] +#[allow(unused_imports)] use nockvm::noun::{Atom, FullDebugCell, Noun, NounAllocator, Slots, D, T}; use nockvm::noun::{NO, YES}; pub use noun_serde_derive::{NounDecode, NounEncode}; @@ -89,14 +90,14 @@ impl From for JetErr { } } -impl From for NockAppError { - fn from(err: NounDecodeError) -> Self { - NockAppError::NounDecodeError(Box::new(err)) +impl From for NounDecodeError { + fn from(err: nockvm::noun::Error) -> Self { + NounDecodeError::Custom(err.to_string()) } } -impl From for NounDecodeError { - fn from(err: nockvm::noun::Error) -> Self { +impl From for NounDecodeError { + fn from(err: str::Utf8Error) -> Self { NounDecodeError::Custom(err.to_string()) } } @@ -160,7 +161,7 @@ impl NounDecode for u32 { impl NounEncode for String { fn to_noun(&self, allocator: &mut A) -> Noun { - use nockapp::utils::make_tas; + use nockvm::ext::make_tas; make_tas(allocator, self).as_noun() } } @@ -762,12 +763,6 @@ pub fn decode_bool(noun: &Noun) -> Result { } } -impl From for NounDecodeError { - fn from(err: nockapp::CrownError) -> Self { - NounDecodeError::Custom(err.to_string()) - } -} - /// Implements noun encoding for HashSet types. /// /// HashSet is encoded as a hoon `$set`, which is the same as a `$map` but @@ -1479,3 +1474,16 @@ mod btreemap_tests { assert_eq!(decoded.get(&2), Some(&300u64)); } } +impl NounEncode for Bytes { + fn to_noun(&self, allocator: &mut A) -> Noun { + ::from_bytes(allocator, self.as_ref()).as_noun() + } +} + +impl NounDecode for Bytes { + fn from_noun(noun: &Noun) -> Result { + let atom = noun.as_atom().map_err(|_| NounDecodeError::ExpectedAtom)?; + let bytes = atom.as_ne_bytes().to_vec(); + Ok(Bytes::from(bytes)) + } +} diff --git a/crates/noun-serde/src/wallet.rs b/crates/noun-serde/src/wallet.rs index f986e3c5a..bd855a7b9 100644 --- a/crates/noun-serde/src/wallet.rs +++ b/crates/noun-serde/src/wallet.rs @@ -1,7 +1,6 @@ use std::collections::{HashMap, HashSet}; -use nockapp::utils::make_tas; -use nockapp::AtomExt; +use nockvm::ext::{make_tas, AtomExt}; use nockvm::noun::{Noun, NounAllocator, D, T}; use crate::{NounDecode, NounDecodeError, NounEncode}; diff --git a/crates/noun-serde/tests/serde.rs b/crates/noun-serde/tests/serde.rs index 205054ce3..cf8c0c497 100644 --- a/crates/noun-serde/tests/serde.rs +++ b/crates/noun-serde/tests/serde.rs @@ -174,8 +174,7 @@ mod complex_tests { use std::collections::HashMap; use std::fmt::Debug; - use nockapp::utils::make_tas; - use nockapp::AtomExt; + use nockvm::ext::{make_tas, AtomExt}; use nockvm::noun::{FullDebugCell, Noun, NounAllocator, Slots, T}; use noun_serde::{NounDecode, NounDecodeError, NounEncode}; diff --git a/crates/raw-tx-checker/Cargo.toml b/crates/raw-tx-checker/Cargo.toml new file mode 100644 index 000000000..34dca59a3 --- /dev/null +++ b/crates/raw-tx-checker/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "raw-tx-checker" +version = "0.1.0" +edition = "2021" + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive"] } +tracing = { workspace = true } +nockvm = { workspace = true } +zkvm-jetpack = { workspace = true } +noun-serde = { workspace = true } +nockchain-math = { workspace = true } +nockchain-types = { workspace = true } diff --git a/crates/raw-tx-checker/src/main.rs b/crates/raw-tx-checker/src/main.rs new file mode 100644 index 000000000..0651c0250 --- /dev/null +++ b/crates/raw-tx-checker/src/main.rs @@ -0,0 +1,49 @@ +use std::fs; +use std::path::PathBuf; + +use anyhow::{anyhow, Context, Result}; +use clap::Parser; +use nockchain_types::tx_engine::common::Hash; +use nockvm::mem::NockStack; +use nockvm::noun::IndirectAtom; +use nockvm::serialization; +use noun_serde::prelude::*; +use zkvm_jetpack::jets::tip5_jets::hash_hashable; + +const DEFAULT_STACK_WORDS: usize = 8 << 10 << 10; + +#[derive(Parser)] +#[command(author, version, about = "Inspect raw transaction hashable jams")] +struct Args { + /// Path to a jammed hashable noun produced from a raw transaction + #[arg(value_name = "JAM_PATH")] + input: PathBuf, +} + +fn main() -> Result<()> { + let args = Args::parse(); + + let jam_bytes = fs::read(&args.input) + .with_context(|| format!("failed to read {}", args.input.display()))?; + + let mut stack = NockStack::new(DEFAULT_STACK_WORDS, 0); + let jam_atom = unsafe { + IndirectAtom::new_raw_bytes(&mut stack, jam_bytes.len(), jam_bytes.as_ptr()) + .normalize_as_atom() + }; + + let hashable = serialization::cue(&mut stack, jam_atom) + .map_err(|err| anyhow!("failed to cue jammed noun: {err:?}"))?; + let digest_noun = hash_hashable(&mut stack, hashable) + .map_err(|err| anyhow!("hash_hashable jet failed: {err:?}"))?; + + let tip5_hash = Hash::from_noun(&digest_noun)?; + println!("Tip5 limbs (hex):"); + for (idx, limb) in tip5_hash.0.iter().enumerate() { + let belt_u64 = limb.0; + println!(" belt_u64[{idx}]: 0x{belt_u64:016x}"); + } + println!("Base58: {}", tip5_hash.to_base58()); + + Ok(()) +} diff --git a/crates/zkvm-jetpack/Cargo.toml b/crates/zkvm-jetpack/Cargo.toml index 081f36038..244576faf 100644 --- a/crates/zkvm-jetpack/Cargo.toml +++ b/crates/zkvm-jetpack/Cargo.toml @@ -11,7 +11,6 @@ bytes.workspace = true either.workspace = true hex-literal.workspace = true ibig.workspace = true -nockapp.workspace = true nockchain-math.workspace = true nockvm.workspace = true nockvm_macros.workspace = true diff --git a/crates/zkvm-jetpack/src/jets/cheetah_jets.rs b/crates/zkvm-jetpack/src/jets/cheetah_jets.rs index f9f2404c9..6b11ac649 100644 --- a/crates/zkvm-jetpack/src/jets/cheetah_jets.rs +++ b/crates/zkvm-jetpack/src/jets/cheetah_jets.rs @@ -1,5 +1,5 @@ use ibig::UBig; -use nockapp::NounExt; +use nockvm::ext::NounExt; use nockvm::interpreter::Context; use nockvm::jets::util::BAIL_FAIL; use nockvm::jets::JetErr; diff --git a/crates/zkvm-jetpack/src/jets/compute_table_jets_v2.rs b/crates/zkvm-jetpack/src/jets/compute_table_jets_v2.rs index a060905f1..6b31a8757 100644 --- a/crates/zkvm-jetpack/src/jets/compute_table_jets_v2.rs +++ b/crates/zkvm-jetpack/src/jets/compute_table_jets_v2.rs @@ -1,8 +1,7 @@ -use nockapp::Noun; use nockvm::interpreter::Context; use nockvm::jets::util::{slot, BAIL_EXIT, BAIL_FAIL}; use nockvm::jets::JetErr; -use nockvm::noun::{Atom, IndirectAtom, D, T}; +use nockvm::noun::{Atom, IndirectAtom, Noun, D, T}; use nockvm_macros::tas; use tracing::debug; diff --git a/crates/zkvm-jetpack/src/jets/crypto_jets.rs b/crates/zkvm-jetpack/src/jets/crypto_jets.rs index da49bca5f..5474b279d 100644 --- a/crates/zkvm-jetpack/src/jets/crypto_jets.rs +++ b/crates/zkvm-jetpack/src/jets/crypto_jets.rs @@ -1,11 +1,11 @@ use bytes::Bytes; -use nockapp::utils::bytes::Byts; -use nockapp::{AtomExt, Noun}; +use nockchain_math::crypto::argon2::Byts; +use nockvm::ext::AtomExt; use nockvm::interpreter::Context; use nockvm::jets::cold::Nounable; use nockvm::jets::util::{slot, BAIL_EXIT}; use nockvm::jets::JetErr; -use nockvm::noun::Atom; +use nockvm::noun::{Atom, Noun}; use crate::form::crypto::argon2::{argon2_hook, Argon2Args}; @@ -34,7 +34,7 @@ pub fn argon2_jet(context: &mut Context, subject: Noun) -> Result pub mod test { use hex_literal::hex; use ibig::UBig; - use nockapp::utils::make_tas; + use nockvm::ext::make_tas; use nockvm::jets::util::test::{assert_jet_door, init_context}; use nockvm::noun::{D, T}; diff --git a/crates/zkvm-jetpack/src/jets/memory_table_jets_v2.rs b/crates/zkvm-jetpack/src/jets/memory_table_jets_v2.rs index 68cb141a7..ab599fd0a 100644 --- a/crates/zkvm-jetpack/src/jets/memory_table_jets_v2.rs +++ b/crates/zkvm-jetpack/src/jets/memory_table_jets_v2.rs @@ -1,12 +1,11 @@ use std::collections::VecDeque; -use nockapp::Noun; use nockvm::hamt::MutHamt; use nockvm::interpreter::Context; use nockvm::jets::util::{slot, BAIL_FAIL}; use nockvm::jets::JetErr; use nockvm::mem::NockStack; -use nockvm::noun::{Atom, IndirectAtom, D, T}; +use nockvm::noun::{Atom, IndirectAtom, Noun, D, T}; use nockvm_macros::tas; use tracing::debug; diff --git a/crates/zkvm-jetpack/src/jets/table_utils.rs b/crates/zkvm-jetpack/src/jets/table_utils.rs index 27466dd83..7fb3138dd 100644 --- a/crates/zkvm-jetpack/src/jets/table_utils.rs +++ b/crates/zkvm-jetpack/src/jets/table_utils.rs @@ -1,5 +1,5 @@ -use nockapp::Noun; use nockvm::jets::JetErr; +use nockvm::noun::Noun; use crate::form::belt::Belt; use crate::form::felt::*; diff --git a/crates/zkvm-jetpack/src/jets/trace_gen_jets.rs b/crates/zkvm-jetpack/src/jets/trace_gen_jets.rs index 85259a6cf..d5197d959 100644 --- a/crates/zkvm-jetpack/src/jets/trace_gen_jets.rs +++ b/crates/zkvm-jetpack/src/jets/trace_gen_jets.rs @@ -1,8 +1,7 @@ -use nockapp::Noun; use nockvm::interpreter::Context; use nockvm::jets::util::{slot, BAIL_FAIL}; use nockvm::jets::JetErr; -use nockvm::noun::{IndirectAtom, T}; +use nockvm::noun::{IndirectAtom, Noun, T}; use tracing::error; use crate::form::felt::Felt; diff --git a/hoon/apps/wallet/lib/types.hoon b/hoon/apps/wallet/lib/types.hoon index 80b71755b..389118dd9 100644 --- a/hoon/apps/wallet/lib/types.hoon +++ b/hoon/apps/wallet/lib/types.hoon @@ -388,6 +388,8 @@ :: if the lock is not a standard 1-of-1 pkh or coinbase, the wallet won't :: be able to guess it, so the funds could be lost forever if the user. :: doesn't keep track of the lock. + save-raw-tx=? :: if %.y, saves jams of the raw-tx and its hashable into a txs-debug folder + :: in the current working directory == [%list-active-addresses ~] [%list-notes ~] @@ -399,7 +401,11 @@ [%update-balance-grpc balance=*] [%set-active-master-address address-b58=@t] [%list-master-addresses ~] - [%file %write path=@t contents=@t success=?] + [%file file-cause] + == + +$ file-cause + $% [%write path=@t contents=@t success=?] + [%batch-write result=(list [path=@t contents=@t success=?])] == :: :: $seed-mask: tracks which fields of a $seed:transact have been set @@ -451,9 +457,9 @@ == :: +$ file-effect - $% - [%file %read path=@t] - [%file %write path=@t contents=@] + $% [%file %read path=@t] + [%file %write path=@t contents=@] + [%file %batch-write files=(list [path=@t contents=@])] == :: +$ grpc-effect diff --git a/hoon/apps/wallet/wallet.hoon b/hoon/apps/wallet/wallet.hoon index 1db4f0830..6684b177c 100644 --- a/hoon/apps/wallet/wallet.hoon +++ b/hoon/apps/wallet/wallet.hoon @@ -205,7 +205,7 @@ (do-grpc-bind cause tag.wir) [effs state] :: - [%poke ?(%one-punch %sys %wallet) ver=@ *] + [%poke ?(%one-punch %sys %wallet %file) ver=@ *] ?+ -.cause ~|("unsupported cause: {<-.cause>}" !!) %show (show:utils state path.cause) %keygen (do-keygen cause) @@ -230,16 +230,18 @@ %show-tx (do-show-tx cause) %list-active-addresses (do-list-active-addresses cause) %show-seed-phrase (do-show-seed-phrase cause) - :: TODO: replace with show-zpub %show-master-zpub (do-show-master-zpub cause) - :: TODO: replace with show-zprv %show-master-zprv (do-show-master-zprv cause) %list-master-addresses (do-list-master-addresses cause) %set-active-master-address (do-set-active-master-address cause) :: %file - ?> ?=(%write +<.cause) - [[%exit 0]~ state] + ::?> ?=(%write +<.cause) + ::[[%exit 0]~ state] + ?- +<.cause + %write [[%exit 0]~ state] + %batch-write [[%exit 0]~ state] + == == == :: @@ -991,13 +993,28 @@ :: jam inputs and save as transaction =/ =transaction:wt [transaction-name spends] =/ transaction-jam (jam transaction) - =/ path=@t + =/ tx-path=@t %- crip "./txs/{(trip name.transaction)}.tx" %- (debug "saving transaction to {}") - =/ =effect:wt [%file %write path transaction-jam] + =/ write-effect=effect:wt + ?. save-raw-tx.cause + [%file %write tx-path transaction-jam] + =/ hashable-path=@t + %- crip + "./txs-debug/{(trip name.transaction)}-hashable.jam" + =/ raw-tx-path=@t + %- crip + "./txs-debug/{(trip name.transaction)}.jam" + :* %file + %batch-write + :~ [hashable-path (jam [leaf+%1 (hashable:spends:transact spends)])] + [tx-path transaction-jam] + [raw-tx-path (jam raw-tx)] + == + == :_ state - ~[effect [%markdown markdown-text] [%exit 0]] + ~[write-effect [%markdown markdown-text]] :: %.n =/ msg=@t From ebbaf23425acfd8fde9900a1b75848c6849c7d07 Mon Sep 17 00:00:00 2001 From: tacryt-socryp <1377557+tacryt-socryp@users.noreply.github.com> Date: Sat, 8 Nov 2025 21:47:42 -0600 Subject: [PATCH 20/99] nockchain-wallet: use tiny stack size --- crates/nockchain-wallet/src/main.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/nockchain-wallet/src/main.rs b/crates/nockchain-wallet/src/main.rs index c7e00a6b9..e9abf9630 100644 --- a/crates/nockchain-wallet/src/main.rs +++ b/crates/nockchain-wallet/src/main.rs @@ -16,7 +16,7 @@ use command::WalletWire; use command::{ClientType, CommandNoun, Commands, WalletCli}; use kernels::wallet::KERNEL; use nockapp::driver::*; -use nockapp::kernel::boot; +use nockapp::kernel::boot::{self, NockStackSize}; use nockapp::noun::slab::{NockJammer, NounSlab}; use nockapp::utils::bytes::Byts; use nockapp::utils::make_tas; @@ -47,7 +47,9 @@ async fn main() -> Result<(), NockAppError> { .install_default() .expect("default provider already set elsewhere"); - let cli = WalletCli::parse(); + let mut cli = WalletCli::parse(); + // Use a smaller stack size for the wallet + cli.boot.stack_size = NockStackSize::Tiny; boot::init_default_tracing(&cli.boot.clone()); // Init tracing early if let Commands::TxAccepted { tx_id } = &cli.command { From c5b13b6bf1808eb28b5a5019f9d73907fc02dee2 Mon Sep 17 00:00:00 2001 From: tacryt-socryp <1377557+tacryt-socryp@users.noreply.github.com> Date: Mon, 17 Nov 2025 13:18:48 -0600 Subject: [PATCH 21/99] nockchain-api: release gRPC API for reading block and transaction data from the chain --- Cargo.toml | 8 +- Makefile | 10 +- crates/kernels/src/nockchain_peek.rs | 2 +- .../proto/nockchain/public/v2/nockchain.proto | 97 + crates/nockapp-grpc/src/error.rs | 32 + .../public_nockchain/v2/block_explorer.rs | 1175 ++++++ .../services/public_nockchain/v2/metrics.rs | 78 +- .../src/services/public_nockchain/v2/mod.rs | 1 + .../services/public_nockchain/v2/server.rs | 535 ++- crates/nockapp/src/drivers/file.rs | 2 +- crates/nockchain-api/Cargo.toml | 23 + crates/nockchain-api/README.md | 43 + crates/nockchain-api/src/main.rs | 43 + crates/nockchain-explorer-tui/Cargo.toml | 25 + crates/nockchain-explorer-tui/README.md | 86 + crates/nockchain-explorer-tui/src/main.rs | 3275 +++++++++++++++++ crates/noun-serde-derive/Cargo.toml | 1 + crates/noun-serde-derive/src/lib.rs | 129 +- hoon/apps/dumbnet/inner.hoon | 38 + 19 files changed, 5565 insertions(+), 38 deletions(-) create mode 100644 crates/nockapp-grpc/src/services/public_nockchain/v2/block_explorer.rs create mode 100644 crates/nockchain-api/Cargo.toml create mode 100644 crates/nockchain-api/README.md create mode 100644 crates/nockchain-api/src/main.rs create mode 100644 crates/nockchain-explorer-tui/Cargo.toml create mode 100644 crates/nockchain-explorer-tui/README.md create mode 100644 crates/nockchain-explorer-tui/src/main.rs diff --git a/Cargo.toml b/Cargo.toml index b496a6ecc..1554c1ea0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ resolver = "2" [workspace] -members = ["crates/equix-latency", "crates/hoon", "crates/hoonc", "crates/kernels", "crates/nockapp", "crates/nockapp-grpc", "crates/nockchain", "crates/nockchain-libp2p-io", "crates/nockchain-math", "crates/nockchain-peek", "crates/nockchain-types", "crates/nockchain-wallet", "crates/nockvm/rust/ibig", "crates/nockvm/rust/murmur3", "crates/nockvm/rust/nockvm", "crates/nockvm/rust/nockvm_macros", "crates/noun-serde", "crates/noun-serde-derive", "crates/zkvm-jetpack"] +members = ["crates/nockchain-api", "crates/nockchain-explorer-tui", "crates/nockchain-peek", "crates/equix-latency", "crates/hoon", "crates/hoonc", "crates/kernels", "crates/nockapp", "crates/nockapp-grpc", "crates/nockapp-grpc-proto", "crates/nockchain", "crates/nockchain-libp2p-io", "crates/nockchain-math", "crates/nockchain-types", "crates/nockchain-wallet", "crates/raw-tx-checker", "crates/nockvm/rust/ibig", "crates/nockvm/rust/murmur3", "crates/nockvm/rust/nockvm", "crates/nockvm/rust/nockvm_macros", "crates/noun-serde", "crates/noun-serde-derive", "crates/zkvm-jetpack"] [workspace.package] version = "0.1.0" @@ -116,6 +116,7 @@ termimad = "0.33.0" testcontainers = { git = "https://github.com/bitemyapp/testcontainers-rs.git", rev = "54851fd9faf9b9cded9d681b46f87c056880d870" } thiserror = "2.0.11" tikv-jemallocator = { version = "0.6" } +tiny-keccak = { version = "2", features = ["keccak"] } tokio = { version = "1.32", features = [ "fs", "io-util", @@ -130,11 +131,11 @@ tokio-rustls = "0.26.0" tokio-stream = "0.1" tokio-util = "0.7.11" tonic = { version = "0.14.0", features = ["tls-webpki-roots"] } -tonic-health = "0.14.2" tonic-build = "0.14" -tonic-reflection = "0.14.0" +tonic-health = "0.14.2" tonic-prost = "0.14.0" tonic-prost-build = "0.14.0" +tonic-reflection = "0.14.0" tower-http = { version = "0.6", features = ["fs"] } tracing = "0.1.41" tracing-core = "0.1" @@ -199,6 +200,7 @@ path = "crates/nockchain-wallet" [workspace.dependencies.nockvm] path = "crates/nockvm/rust/nockvm" + [workspace.dependencies.nockvm_crypto] path = "crates/nockvm/rust/nockvm_crypto" diff --git a/Makefile b/Makefile index 7b4d32aa8..d1f0997d7 100644 --- a/Makefile +++ b/Makefile @@ -69,7 +69,7 @@ install-nockchain-wallet: assets/wal.jam cargo install --locked --force --path crates/nockchain-wallet --bin nockchain-wallet .PHONY: install-nockchain-peek -install-nockchain-peek: assets/nockchain-peek.jam +install-nockchain-peek: assets/peek.jam $(call show_env_vars) cargo install --locked --force --path crates/nockchain-peek --bin nockchain-peek @@ -84,7 +84,7 @@ build-trivial: ensure-dirs echo '%trivial' > hoon/trivial.hoon hoonc --arbitrary hoon/trivial.hoon -HOON_TARGETS=assets/dumb.jam assets/wal.jam assets/miner.jam assets/nockchain-peek.jam +HOON_TARGETS=assets/dumb.jam assets/wal.jam assets/miner.jam assets/peek.jam .PHONY: nuke-hoonc-data nuke-hoonc-data: @@ -131,8 +131,8 @@ assets/miner.jam: ensure-dirs hoon/apps/dumbnet/miner.hoon $(HOON_SRCS) mv out.jam assets/miner.jam ## Build peek.jam with hoonc -assets/nockchain-peek.jam: ensure-dirs hoon/apps/peek/peek.hoon $(HOON_SRCS) +assets/peek.jam: ensure-dirs hoon/apps/peek/peek.hoon $(HOON_SRCS) $(call show_env_vars) - rm -f assets/nockchain-peek.jam + rm -f assets/peek.jam hoonc hoon/apps/peek/peek.hoon hoon - mv out.jam assets/nockchain-peek.jam + mv out.jam assets/peek.jam diff --git a/crates/kernels/src/nockchain_peek.rs b/crates/kernels/src/nockchain_peek.rs index 522dd3781..041c0af07 100644 --- a/crates/kernels/src/nockchain_peek.rs +++ b/crates/kernels/src/nockchain_peek.rs @@ -4,5 +4,5 @@ pub static KERNEL: &[u8] = include_bytes!(env!("NOCKCHAIN_PEEK_JAM_PATH")); #[cfg(not(feature = "bazel_build"))] pub const KERNEL: &[u8] = include_bytes!(concat!( env!("CARGO_MANIFEST_DIR"), - "/../../assets/nockchain-peek.jam" + "/../../assets/peek.jam" )); diff --git a/crates/nockapp-grpc-proto/proto/nockchain/public/v2/nockchain.proto b/crates/nockapp-grpc-proto/proto/nockchain/public/v2/nockchain.proto index 5f0402d56..e54cec4a2 100644 --- a/crates/nockapp-grpc-proto/proto/nockchain/public/v2/nockchain.proto +++ b/crates/nockapp-grpc-proto/proto/nockchain/public/v2/nockchain.proto @@ -57,3 +57,100 @@ message TransactionAcceptedResponse { common.v1.ErrorStatus error = 2; } } + +service NockchainBlockService { + rpc GetBlocks(GetBlocksRequest) + returns (GetBlocksResponse); + rpc GetTransactionBlock(GetTransactionBlockRequest) + returns (GetTransactionBlockResponse); + rpc GetTransactionDetails(GetTransactionDetailsRequest) + returns (GetTransactionDetailsResponse); +} + +message GetBlocksRequest { + common.v1.PageRequest page = 1; +} + +message GetBlocksResponse { + oneof result { + BlocksData blocks = 1; + common.v1.ErrorStatus error = 2; + } +} + +message BlocksData { + repeated BlockEntry blocks = 1; + uint64 current_height = 2; + common.v1.PageResponse page = 3; +} + +message BlockEntry { + common.v1.Hash block_id = 1; + uint64 height = 2; + common.v1.Hash parent = 3; + uint64 timestamp = 4; + repeated common.v1.Base58Hash tx_ids = 5; +} + +message GetTransactionBlockRequest { + common.v1.Base58Hash tx_id = 1; +} + +message GetTransactionBlockResponse { + oneof result { + TransactionBlockData block = 1; + TransactionPending pending = 2; + common.v1.ErrorStatus error = 3; + } +} + +message TransactionBlockData { + common.v1.Hash block_id = 1; + uint64 height = 2; + common.v1.Hash parent = 3; + uint64 timestamp = 4; +} + +message TransactionPending { + // Transaction exists in mempool but not yet in a block +} + +message GetTransactionDetailsRequest { + common.v1.Base58Hash tx_id = 1; +} + +message GetTransactionDetailsResponse { + oneof result { + TransactionDetails details = 1; + TransactionPending pending = 2; + common.v1.ErrorStatus error = 3; + } +} + +message TransactionDetails { + string tx_id = 1; + common.v1.Hash block_id = 2; + uint64 height = 3; + uint64 timestamp = 4; + uint64 version = 5; + uint64 size_bytes = 6; + common.v1.Nicks total_input = 7; + common.v1.Nicks total_output = 8; + common.v1.Nicks fee = 9; + repeated TransactionInput inputs = 10; + repeated TransactionOutput outputs = 11; + common.v1.Hash parent = 12; +} + +message TransactionInput { + string note_name_b58 = 1; + common.v1.Nicks amount = 2; + string source_tx_id = 3; + bool coinbase = 4; +} + +message TransactionOutput { + string note_name_b58 = 1; + common.v1.Nicks amount = 2; + string lock_summary = 3; +} diff --git a/crates/nockapp-grpc/src/error.rs b/crates/nockapp-grpc/src/error.rs index c095b8779..6e84b9c98 100644 --- a/crates/nockapp-grpc/src/error.rs +++ b/crates/nockapp-grpc/src/error.rs @@ -39,6 +39,18 @@ pub enum NockAppGrpcError { #[error("Serialization error: {0}")] Serialization(String), + + #[error("Transaction pending")] + TxPending, + + #[error("Transaction not found")] + NotFound, + + #[error("Transaction prefix matched multiple transactions: {0}")] + TxPrefixAmbiguous(String), + + #[error("Transaction prefix too short (minimum {0} characters)")] + TxPrefixTooShort(usize), } impl From for tonic::Status { @@ -110,6 +122,26 @@ impl From for tonic::Status { format!("Serialization error: {}", msg), ErrorCode::InternalError, ), + TxPending => ( + tonic::Code::FailedPrecondition, + "Transaction pending".to_string(), + ErrorCode::PeekReturnedNoData, + ), + NotFound => ( + tonic::Code::NotFound, + "Transaction not found".to_string(), + ErrorCode::NotFound, + ), + TxPrefixAmbiguous(matches) => ( + tonic::Code::InvalidArgument, + format!("Transaction prefix is ambiguous; matches: {}", matches), + ErrorCode::InvalidRequest, + ), + TxPrefixTooShort(min) => ( + tonic::Code::InvalidArgument, + format!("Transaction prefix too short (minimum {} characters)", min), + ErrorCode::InvalidRequest, + ), }; let status = tonic::Status::new(code, message); diff --git a/crates/nockapp-grpc/src/services/public_nockchain/v2/block_explorer.rs b/crates/nockapp-grpc/src/services/public_nockchain/v2/block_explorer.rs new file mode 100644 index 000000000..eff06c0d7 --- /dev/null +++ b/crates/nockapp-grpc/src/services/public_nockchain/v2/block_explorer.rs @@ -0,0 +1,1175 @@ +use std::collections::{BTreeMap, HashMap}; +use std::convert::TryFrom; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use nockapp::noun::slab::NounSlab; +use nockapp_grpc_proto::pb::public::v2::{TransactionDetails, TransactionInput, TransactionOutput}; +use nockchain_math::noun_ext::NounMathExt; +use nockchain_math::structs::HoonMapIter; +use nockchain_types::tx_engine::common::{BlockHeight, Hash, Name}; +use nockchain_types::tx_engine::v0::{Lock, NoteV0, RawTx}; +use nockvm::noun::{Noun, SIG}; +use noun_serde::{NounDecode, NounDecodeError, NounEncode}; +use tokio::sync::{RwLock, Semaphore}; +use tracing::{debug, info, warn}; + +use crate::error::{NockAppGrpcError, Result as GrpcResult}; +use crate::pb::common::v1 as pb_common; +use crate::public_nockchain::v2::metrics::NockchainGrpcApiMetrics; +use crate::public_nockchain::v2::server::BalanceHandle; + +/// Minimal block metadata for block explorer +#[derive(Debug, Clone)] +pub struct BlockMetadata { + pub height: u64, + pub block_id: Hash, + pub parent_id: Hash, + pub timestamp: u64, + pub tx_ids: Vec, +} + +/// Block explorer cache that maintains indexes over the heaviest chain +pub struct BlockExplorerCache { + /// Blocks indexed by height (for pagination) + blocks_by_height: Arc>>, + + /// Blocks indexed by block ID (for lookups) + blocks_by_id: Arc>>, + + /// Transaction lookup: tx_id → (block_id, height) + tx_to_block: Arc>>, + + /// Base58 string index for prefix lookups + tx_b58_index: Arc>>, + + /// Current maximum height + max_height: Arc, + + /// Last update timestamp + last_updated: Arc>, + + metrics: Arc, + seed_ready: Arc, + backfill_resume: Arc>>, + chunk_semaphore: Arc, +} + +impl BlockExplorerCache { + const RANGE_CHUNK: u64 = 8; + const INITIAL_SEED_RETRY_DELAY: Duration = Duration::from_secs(2); + const INITIAL_SEED_MAX_WAIT: Duration = Duration::from_secs(120); + const MIN_TX_PREFIX_LEN: usize = 8; + const MAX_PREFIX_MATCHES: usize = 16; + pub fn new(metrics: Arc) -> Self { + Self { + blocks_by_height: Arc::new(RwLock::new(BTreeMap::new())), + blocks_by_id: Arc::new(RwLock::new(HashMap::new())), + tx_to_block: Arc::new(RwLock::new(HashMap::new())), + tx_b58_index: Arc::new(RwLock::new(BTreeMap::new())), + max_height: Arc::new(AtomicU64::new(0)), + last_updated: Arc::new(RwLock::new(Instant::now())), + metrics, + seed_ready: Arc::new(AtomicBool::new(false)), + backfill_resume: Arc::new(RwLock::new(None)), + chunk_semaphore: Arc::new(Semaphore::new(1)), + } + } + + /// Initialize cache by scraping the entire blockchain + #[tracing::instrument(name = "block_explorer_cache.initialize", skip(self, handle))] + pub async fn initialize(self: Arc, handle: Arc) -> GrpcResult<()> { + debug!("Initializing block explorer cache"); + let deadline = Instant::now() + Self::INITIAL_SEED_MAX_WAIT; + loop { + let started = Instant::now(); + let result = self.clone().initialize_inner(handle.clone()).await; + self.metrics + .block_explorer_cache_initialize_time + .add_timing(&started.elapsed()); + match result { + Ok(true) => return Ok(()), + Ok(false) => { + if Instant::now() >= deadline { + warn!("Block explorer cache still empty after waiting; continuing startup so incremental refresh can populate it later"); + return Ok(()); + } + tracing::info!( + "Block explorer cache waiting for heaviest chain data before serving" + ); + tokio::time::sleep(Self::INITIAL_SEED_RETRY_DELAY).await; + } + Err(err) => { + self.metrics + .block_explorer_cache_initialize_error + .increment(); + return Err(err); + } + } + } + } + + #[tracing::instrument( + name = "block_explorer_cache.initialize_inner", + skip(self, handle), + fields(max_height = tracing::field::Empty) + )] + async fn initialize_inner(self: Arc, handle: Arc) -> GrpcResult { + // Get current height + let (max_height, _tip_block_id) = match self.peek_heaviest_chain(&handle).await { + Ok(val) => val, + Err(NockAppGrpcError::PeekReturnedNoData) => { + debug!("Heaviest chain is empty; skipping cache initialization"); + // Ensure we expose an empty cache instead of bubbling an error + { + *self.blocks_by_height.write().await = BTreeMap::new(); + *self.blocks_by_id.write().await = HashMap::new(); + *self.tx_to_block.write().await = HashMap::new(); + *self.tx_b58_index.write().await = BTreeMap::new(); + self.max_height.store(0, Ordering::Release); + } + return Ok(false); + } + Err(err) => return Err(err), + }; + tracing::Span::current().record("max_height", &tracing::field::display(max_height)); + info!( + max_height, + "Detected heaviest chain height for block explorer cache seed" + ); + + if max_height == 0 { + debug!("Empty blockchain, no blocks to cache"); + return Ok(false); + } + + { + *self.blocks_by_height.write().await = BTreeMap::new(); + *self.blocks_by_id.write().await = HashMap::new(); + *self.tx_to_block.write().await = HashMap::new(); + *self.tx_b58_index.write().await = BTreeMap::new(); + self.max_height.store(0, Ordering::Release); + } + + let first_start = max_height.saturating_sub(Self::RANGE_CHUNK - 1); + let first_chunk = match self + .peek_blocks_range(&handle, first_start, max_height) + .await + { + Ok(chunk) => chunk, + Err(NockAppGrpcError::PeekReturnedNoData) => { + debug!( + "Heaviest chain range {}..={} returned no blocks; cache seed will retry", + first_start, max_height + ); + return Ok(false); + } + Err(err) => return Err(err), + }; + let inserted = first_chunk.len(); + self.insert_blocks(first_chunk).await; + + debug!( + "Inserted initial {} blocks ({}..={})", + inserted, first_start, max_height + ); + info!( + inserted_blocks = inserted, + range_start = first_start, + range_end = max_height, + "Seeded block explorer cache with initial range" + ); + + if first_start > 0 { + let resume_height = first_start - 1; + info!(resume_height, "Queueing block explorer cache backfill task"); + *self.backfill_resume.write().await = Some(resume_height); + } else { + *self.backfill_resume.write().await = None; + } + + info!( + next_backfill_start = first_start.saturating_sub(1), + current_height = self.max_height.load(Ordering::Acquire), + "Block explorer cache initialization complete" + ); + self.seed_ready.store(true, Ordering::Release); + Ok(true) + } + + /// Refresh cache with new blocks + #[tracing::instrument(name = "block_explorer_cache.refresh", skip(self, handle))] + pub async fn refresh(&self, handle: &Arc) -> GrpcResult<()> { + let started = Instant::now(); + let result = self.refresh_inner(handle).await; + self.metrics + .block_explorer_cache_refresh_time + .add_timing(&started.elapsed()); + if result.is_err() { + self.metrics.block_explorer_cache_refresh_error.increment(); + } + result + } + + #[tracing::instrument( + name = "block_explorer_cache.refresh_inner", + skip(self, handle), + fields(last_height = tracing::field::Empty, current_height = tracing::field::Empty) + )] + async fn refresh_inner(&self, handle: &Arc) -> GrpcResult<()> { + let (current_height, _) = match self.peek_heaviest_chain(handle).await { + Ok(val) => val, + Err(NockAppGrpcError::PeekReturnedNoData) => { + debug!("Heaviest chain is empty; skipping cache refresh"); + return Ok(()); + } + Err(err) => return Err(err), + }; + tracing::Span::current().record("current_height", &tracing::field::display(current_height)); + let last_height = self.max_height.load(Ordering::Acquire); + tracing::Span::current().record("last_height", &tracing::field::display(last_height)); + + if current_height <= last_height { + debug!( + "No new blocks to fetch (current: {}, cached: {})", + current_height, last_height + ); + return Ok(()); + } + + debug!( + "Fetching new blocks {} to {}", + last_height + 1, + current_height + ); + info!( + start_height = last_height + 1, + end_height = current_height, + "Refreshing block explorer cache with new blocks" + ); + let new_blocks = match self + .peek_blocks_range_chunked(handle, last_height + 1, current_height) + .await + { + Ok(blocks) => blocks, + Err(NockAppGrpcError::PeekReturnedNoData) => { + debug!( + "No block data available yet for range {}-{}; deferring refresh", + last_height + 1, + current_height + ); + return Ok(()); + } + Err(err) => return Err(err), + }; + if new_blocks.is_empty() { + debug!( + "Block explorer refresh range {}-{} returned no entries; waiting for data", + last_height + 1, + current_height + ); + return Ok(()); + } + let count = new_blocks.len(); + self.insert_blocks(new_blocks).await; + + debug!( + "Block explorer cache refreshed to height {}, inserted {} blocks", + current_height, count + ); + Ok(()) + } + /// Get paginated blocks (descending by height) + #[tracing::instrument( + name = "block_explorer_cache.get_blocks_page", + skip(self), + fields(start_height = tracing::field::Empty) + )] + pub async fn get_blocks_page( + &self, + cursor: Option, + limit: usize, + ) -> (Vec, Option) { + let blocks = self.blocks_by_height.read().await; + let start_height = cursor.unwrap_or(self.max_height.load(Ordering::Acquire)); + tracing::Span::current().record("start_height", &tracing::field::display(start_height)); + + let page: Vec<_> = blocks + .range(..=start_height) + .rev() + .take(limit) + .map(|(_, block)| block.clone()) + .collect(); + + let next_cursor = page.last().map(|b| b.height.saturating_sub(1)); + (page, next_cursor.filter(|h| *h > 0)) + } + + /// Lookup block for transaction + #[tracing::instrument(name = "block_explorer_cache.get_block_for_tx", skip(self))] + pub async fn get_block_for_tx(&self, tx_id: &Hash) -> Option { + let tx_index = self.tx_to_block.read().await; + let (block_id, _height) = tx_index.get(tx_id)?; + + let blocks = self.blocks_by_id.read().await; + blocks.get(block_id).cloned() + } + + /// Get current max height + pub fn get_max_height(&self) -> u64 { + self.max_height.load(Ordering::Acquire) + } + + #[tracing::instrument( + name = "block_explorer_cache.resolve_tx_id", + skip(self), + fields(input_len = tracing::field::Empty) + )] + pub async fn resolve_tx_id(&self, input: &str) -> GrpcResult { + let trimmed = input.trim(); + tracing::Span::current().record("input_len", &tracing::field::display(trimmed.len())); + if trimmed.is_empty() { + return Err(NockAppGrpcError::InvalidRequest( + "tx_id prefix cannot be empty".into(), + )); + } + + if let Ok(hash) = Hash::from_base58(trimmed) { + if hash.to_base58() == trimmed { + return Ok(hash); + } + } + + self.lookup_tx_by_prefix(trimmed).await + } + + #[tracing::instrument(name = "block_explorer_cache.lookup_tx_by_prefixes", skip(self))] + pub async fn lookup_tx_by_prefixes(&self, prefix: &str) -> GrpcResult> { + if prefix.len() < Self::MIN_TX_PREFIX_LEN { + return Err(NockAppGrpcError::TxPrefixTooShort(Self::MIN_TX_PREFIX_LEN)); + } + + let index = self.tx_b58_index.read().await; + let mut iter = index.range(prefix.to_string()..); + let mut first_match: Option<(String, Hash)> = None; + let mut conflicts: Vec = Vec::new(); + + while let Some((key, hash)) = iter.next() { + if !key.starts_with(prefix) { + break; + } + + if first_match.is_some() { + if conflicts.len() >= Self::MAX_PREFIX_MATCHES - 1 { + break; + } + conflicts.push(key.clone()); + } else { + first_match = Some((key.clone(), hash.clone())); + } + } + + if conflicts.is_empty() { + first_match + .map(|(key, hash)| vec![(key, hash)]) + .ok_or(NockAppGrpcError::NotFound) + } else if let Some((key, hash)) = first_match { + let mut results = Vec::new(); + results.push((key, hash)); + for conflict in conflicts { + if let Some(tx_hash) = index.get(&conflict) { + results.push((conflict, tx_hash.clone())); + } + } + Ok(results) + } else { + Err(NockAppGrpcError::NotFound) + } + } + + #[tracing::instrument(name = "block_explorer_cache.lookup_tx_by_prefix", skip(self))] + async fn lookup_tx_by_prefix(&self, prefix: &str) -> GrpcResult { + let matches = self.lookup_tx_by_prefixes(prefix).await?; + match matches.len() { + 1 => Ok(matches[0].1.clone()), + n if n > 1 => Err(NockAppGrpcError::TxPrefixAmbiguous( + matches + .into_iter() + .map(|(s, _)| s) + .collect::>() + .join(", "), + )), + _ => Err(NockAppGrpcError::NotFound), + } + } + + #[tracing::instrument( + name = "block_explorer_cache.load_transaction_details", + skip(self, handle), + fields(tx_id = tracing::field::Empty) + )] + pub async fn load_transaction_details( + &self, + handle: &Arc, + tx_id: &Hash, + ) -> GrpcResult { + tracing::Span::current().record("tx_id", &tracing::field::display(tx_id.to_base58())); + // First check cache for confirmed block metadata + if let Some(block_meta) = self.get_block_for_tx(tx_id).await { + let tx_details = self + .fetch_transaction_from_block(handle, &block_meta, tx_id) + .await?; + return Ok(tx_details); + } + + // If not found, see if it's pending + if self.transaction_pending(handle, tx_id).await? { + return Err(NockAppGrpcError::TxPending); + } + + Err(NockAppGrpcError::NotFound) + } + + #[tracing::instrument( + name = "block_explorer_cache.fetch_transaction_from_block", + skip(self, handle, meta), + fields(height = tracing::field::Empty, tx_id = tracing::field::Empty) + )] + async fn fetch_transaction_from_block( + &self, + handle: &Arc, + meta: &BlockMetadata, + tx_id: &Hash, + ) -> GrpcResult { + tracing::Span::current().record("height", &tracing::field::display(meta.height)); + tracing::Span::current().record("tx_id", &tracing::field::display(tx_id.to_base58())); + let block = self + .load_block_with_transactions(handle, meta.height) + .await?; + + let metadata = block.metadata.clone(); + let (hash, tx) = block + .txs + .into_iter() + .find(|(hash, _)| hash == tx_id) + .ok_or(NockAppGrpcError::NotFound)?; + + Ok(build_transaction_details(&metadata, &hash, tx)) + } + + /// Peek /heaviest-chain ~ to get current tip + #[tracing::instrument(name = "block_explorer_cache.peek_heaviest_chain", skip(self, handle))] + async fn peek_heaviest_chain( + &self, + handle: &Arc, + ) -> GrpcResult<(u64, Hash)> { + let mut path_slab = NounSlab::new(); + let tag = nockapp::utils::make_tas(&mut path_slab, "heaviest-chain").as_noun(); + let path_noun = nockvm::noun::T(&mut path_slab, &[tag, SIG]); + path_slab.set_root(path_noun); + + let result = handle + .peek(path_slab) + .await + .map_err(NockAppGrpcError::from)? + .ok_or(NockAppGrpcError::PeekFailed)?; + + let result_noun = unsafe { result.root() }; + + // Decode Option> + let opt: Option> = + NounDecode::from_noun(&result_noun).map_err(NockAppGrpcError::NounDecode)?; + + let (height, hash) = opt.flatten().ok_or(NockAppGrpcError::PeekReturnedNoData)?; + + Ok((height.0 .0, hash)) // Extract u64 from BlockHeight(Belt) + } + + #[tracing::instrument( + name = "block_explorer_cache.transaction_pending", + skip(self, handle), + fields(tx_id = tracing::field::Empty) + )] + async fn transaction_pending( + &self, + handle: &Arc, + tx_id: &Hash, + ) -> GrpcResult { + tracing::Span::current().record("tx_id", &tracing::field::display(tx_id.to_base58())); + let mut path_slab = NounSlab::new(); + let tag = nockapp::utils::make_tas(&mut path_slab, "raw-transaction").as_noun(); + let tx_id_b58 = tx_id.to_base58(); + let tx_id_noun = tx_id_b58.to_noun(&mut path_slab); + let path_noun = nockvm::noun::T(&mut path_slab, &[tag, tx_id_noun, SIG]); + path_slab.set_root(path_noun); + + match handle.peek(path_slab).await { + Ok(Some(_)) => Ok(true), + Ok(None) => Ok(false), + Err(e) => Err(NockAppGrpcError::from(e)), + } + } + + #[tracing::instrument( + name = "block_explorer_cache.load_block_with_transactions", + skip(self, handle), + fields(height = tracing::field::Empty) + )] + async fn load_block_with_transactions( + &self, + handle: &Arc, + height: u64, + ) -> GrpcResult { + tracing::Span::current().record("height", &tracing::field::display(height)); + let mut path_slab = NounSlab::new(); + let tag = nockapp::utils::make_tas(&mut path_slab, "heaviest-chain-blocks-range").as_noun(); + let start_noun = nockvm::noun::D(height); + let end_noun = nockvm::noun::D(height); + let path_noun = nockvm::noun::T(&mut path_slab, &[tag, start_noun, end_noun, SIG]); + path_slab.set_root(path_noun); + + let result = handle + .peek(path_slab) + .await + .map_err(NockAppGrpcError::from)? + .ok_or(NockAppGrpcError::PeekFailed)?; + + let result_noun = unsafe { result.root() }; + let opt: Option>> = + NounDecode::from_noun(&result_noun).map_err(NockAppGrpcError::NounDecode)?; + let entries = opt.flatten().ok_or(NockAppGrpcError::PeekReturnedNoData)?; + + let mut parsed = Vec::new(); + for entry in entries { + parsed.push(BlockEntryWithTxs::try_from(entry).map_err(NockAppGrpcError::NounDecode)?); + } + + parsed + .into_iter() + .find(|entry| entry.metadata.height == height) + .ok_or(NockAppGrpcError::PeekReturnedNoData) + } + + /// Peek /heaviest-chain-blocks-range/[start]/[end] ~ + /// Returns: Vec<(height, block_id, parent_id, timestamp, tx_ids)> + #[tracing::instrument( + name = "block_explorer_cache.peek_blocks_range_chunked", + skip(self, handle) + )] + async fn peek_blocks_range_chunked( + &self, + handle: &Arc, + start: u64, + end: u64, + ) -> GrpcResult)>> { + if start > end { + return Ok(Vec::new()); + } + + let mut acc = Vec::new(); + let mut chunk_start = start; + while chunk_start <= end { + let chunk_end = (chunk_start + Self::RANGE_CHUNK - 1).min(end); + match self.peek_blocks_range(handle, chunk_start, chunk_end).await { + Ok(mut chunk) => acc.append(&mut chunk), + Err(NockAppGrpcError::PeekReturnedNoData) => { + // Log at debug level and continue to next chunk. + debug!( + "No data for heaviest-chain range {}-{}, skipping", + chunk_start, chunk_end + ); + return Err(NockAppGrpcError::PeekReturnedNoData); + } + Err(err) => return Err(err), + } + + if chunk_end == u64::MAX { + break; + } + tokio::time::sleep(Self::INITIAL_SEED_RETRY_DELAY).await; + chunk_start = chunk_end.saturating_add(1); + } + + Ok(acc) + } + + #[tracing::instrument(name = "block_explorer_cache.backfill_older", skip(self, handle))] + pub async fn backfill_older( + self: Arc, + handle: Arc, + mut upper: u64, + ) -> GrpcResult<()> { + while !self.seed_ready.load(Ordering::Acquire) { + debug!( + "Backfill waiting for initial seed before starting; sleeping {:?}", + Self::INITIAL_SEED_RETRY_DELAY + ); + tokio::time::sleep(Self::INITIAL_SEED_RETRY_DELAY).await; + } + loop { + if upper == u64::MAX { + break; + } + let start = upper.saturating_sub(Self::RANGE_CHUNK - 1); + let chunk = match self.peek_blocks_range(&handle, start, upper).await { + Ok(blocks) => blocks, + Err(NockAppGrpcError::PeekReturnedNoData) => { + debug!( + "No data for heaviest-chain range {}-{} during backfill; backing off for {:?}", + start, + upper, + Self::INITIAL_SEED_RETRY_DELAY + ); + tokio::time::sleep(Self::INITIAL_SEED_RETRY_DELAY).await; + continue; + } + Err(err) => { + self.metrics.block_explorer_cache_backfill_error.increment(); + return Err(err); + } + }; + + if chunk.is_empty() { + if start == 0 { + break; + } else { + upper = start.saturating_sub(1); + continue; + } + } + + let inserted = chunk.len(); + self.insert_blocks(chunk).await; + info!( + range_start = start, + range_end = upper, + inserted_blocks = inserted, + "Backfilled block explorer cache range" + ); + tokio::time::sleep(Self::INITIAL_SEED_RETRY_DELAY).await; + + if start == 0 { + break; + } + upper = start.saturating_sub(1); + } + + Ok(()) + } + + pub async fn take_backfill_resume(&self) -> Option { + self.backfill_resume.write().await.take() + } + + #[tracing::instrument( + name = "block_explorer_cache.insert_blocks", + skip(self, blocks), + fields(batch_size = tracing::field::Empty) + )] + async fn insert_blocks(&self, blocks: Vec<(u64, Hash, Hash, u64, Vec)>) { + if blocks.is_empty() { + return; + } + let batch_size = blocks.len(); + tracing::Span::current().record("batch_size", &tracing::field::display(batch_size)); + + let mut by_height_guard = self.blocks_by_height.write().await; + let mut by_id_guard = self.blocks_by_id.write().await; + let mut tx_index_guard = self.tx_to_block.write().await; + let mut tx_b58_guard = self.tx_b58_index.write().await; + + let mut max_in_batch = 0; + for (height, block_id, parent_id, timestamp, tx_ids) in blocks { + if height > max_in_batch { + max_in_batch = height; + } + + let metadata = BlockMetadata { + height, + block_id: block_id.clone(), + parent_id, + timestamp, + tx_ids: tx_ids.clone(), + }; + + by_height_guard.insert(height, metadata.clone()); + by_id_guard.insert(block_id.clone(), metadata); + + for tx_id in tx_ids { + tx_index_guard.insert(tx_id.clone(), (block_id.clone(), height)); + tx_b58_guard.insert(tx_id.to_base58(), tx_id); + } + } + + drop(tx_index_guard); + drop(tx_b58_guard); + drop(by_id_guard); + drop(by_height_guard); + + self.max_height.fetch_max(max_in_batch, Ordering::Release); + *self.last_updated.write().await = Instant::now(); + debug!( + batch_size, + highest_seen = max_in_batch, + "Inserted blocks into block explorer cache" + ); + } + + #[tracing::instrument(name = "block_explorer_cache.peek_blocks_range", skip(self, handle))] + async fn peek_blocks_range( + &self, + handle: &Arc, + start: u64, + end: u64, + ) -> GrpcResult)>> { + let _permit = self + .chunk_semaphore + .acquire() + .await + .expect("chunk semaphore closed"); + let mut path_slab = NounSlab::new(); + let tag = nockapp::utils::make_tas(&mut path_slab, "heaviest-chain-blocks-range").as_noun(); + let start_noun = nockvm::noun::D(start); + let end_noun = nockvm::noun::D(end); + let path_noun = nockvm::noun::T(&mut path_slab, &[tag, start_noun, end_noun, SIG]); + path_slab.set_root(path_noun); + + let result = handle + .peek(path_slab) + .await + .map_err(NockAppGrpcError::from)? + .ok_or(NockAppGrpcError::PeekFailed)?; + + let result_noun = unsafe { result.root() }; + + // Decode Option>> + // We need to extract fields from the page and txs + let opt: Option>> = + NounDecode::from_noun(&result_noun).map_err(NockAppGrpcError::NounDecode)?; + + let entries = opt.flatten().ok_or(NockAppGrpcError::PeekReturnedNoData)?; + if entries.is_empty() { + return Err(NockAppGrpcError::PeekReturnedNoData); + } + let entries: Vec = entries + .into_iter() + .map(BlockRangeEntry::try_from) + .collect::, _>>() + .map_err(NockAppGrpcError::NounDecode)?; + + // Extract the data we need from each entry + let result = entries + .into_iter() + .map(|entry| { + ( + entry.height, entry.block_id, entry.parent_id, entry.timestamp, entry.tx_ids, + ) + }) + .collect(); + + Ok(result) + } +} + +/// Intermediate type for decoding the block range peek result +/// This matches the Hoon type: [page-number block-id page (z-map tx-id tx)] +#[derive(Debug, Clone)] +struct BlockRangeEntry { + height: u64, + block_id: Hash, + parent_id: Hash, + timestamp: u64, + tx_ids: Vec, +} + +#[derive(Debug, Clone, NounDecode)] +struct BlockRangeEntryNoun { + height: BlockHeight, + tail: BlockRangeEntryTail, +} + +#[derive(Debug, Clone, NounDecode)] +struct BlockRangeEntryTail { + block_id: Hash, + tail: PageAndTxs, +} + +#[derive(Debug, Clone, NounDecode)] +struct PageAndTxs { + page: PageNoun, + txs: Noun, +} + +#[derive(Debug, Clone, NounDecode)] +struct PageNoun { + _digest: Hash, + _pow: Noun, + parent: Hash, + _tx_ids: Noun, + _coinbase: Noun, + timestamp: Noun, + _epoch_counter: Noun, + _target: Noun, + _accumulated_work: Noun, + _height: BlockHeight, + _msg: Noun, +} + +impl TryFrom for BlockRangeEntry { + type Error = NounDecodeError; + + fn try_from(raw: BlockRangeEntryNoun) -> std::result::Result { + let BlockRangeEntryNoun { height, tail } = raw; + let BlockRangeEntryTail { block_id, tail } = tail; + let PageAndTxs { page, txs } = tail; + + let parent_id = page.parent; + let timestamp = u64::from_noun(&page.timestamp)?; + let tx_ids = extract_tx_ids_from_map(&txs)?; + + Ok(Self { + height: height.0 .0, + block_id, + parent_id, + timestamp, + tx_ids, + }) + } +} + +/// Extract transaction IDs from transactions map (z-map tx-id tx) +fn extract_tx_ids_from_map( + txs_noun: &Noun, +) -> std::result::Result, noun_serde::NounDecodeError> { + // Check if it's an empty map (atom 0) + if let Ok(atom) = txs_noun.as_atom() { + if atom.as_u64()? == 0 { + return Ok(Vec::new()); + } + } + + // Iterate over the z-map and collect keys (tx-ids) + let tx_ids: Vec = HoonMapIter::from(*txs_noun) + .filter(|entry| entry.is_cell()) + .filter_map(|entry| { + let [key, _value] = entry.uncell().ok()?; + Hash::from_noun(&key).ok() + }) + .collect(); + + Ok(tx_ids) +} + +struct BlockEntryWithTxs { + metadata: BlockMetadata, + txs: Vec<(Hash, TxV0)>, +} + +impl TryFrom for BlockEntryWithTxs { + type Error = NounDecodeError; + + fn try_from(raw: BlockRangeEntryNoun) -> std::result::Result { + let BlockRangeEntryNoun { height, tail } = raw; + let BlockRangeEntryTail { block_id, tail } = tail; + let PageAndTxs { page, txs } = tail; + + let parent_id = page.parent; + let timestamp = u64::from_noun(&page.timestamp)?; + let txs_full = extract_transactions_from_map(&txs)?; + let tx_ids = txs_full.iter().map(|(hash, _)| hash.clone()).collect(); + + Ok(Self { + metadata: BlockMetadata { + height: height.0 .0, + block_id, + parent_id, + timestamp, + tx_ids, + }, + txs: txs_full, + }) + } +} + +fn extract_transactions_from_map( + txs_noun: &Noun, +) -> std::result::Result, noun_serde::NounDecodeError> { + if let Ok(atom) = txs_noun.as_atom() { + if atom.as_u64()? == 0 { + return Ok(Vec::new()); + } + } + + let mut txs = Vec::new(); + for entry in HoonMapIter::from(*txs_noun) { + if !entry.is_cell() { + continue; + } + let [key, value] = entry.uncell().map_err(|_| NounDecodeError::ExpectedCell)?; + let hash = Hash::from_noun(&key)?; + let tx = TxV0::from_noun(&value)?; + txs.push((hash, tx)); + } + + Ok(txs) +} + +#[derive(Debug, Clone)] +struct TxV0 { + version: u64, + raw_tx: RawTx, + total_size: u64, + outputs: Vec, +} + +#[derive(Debug, Clone)] +struct TxOutput { + lock: Lock, + note: NoteV0, +} + +impl NounDecode for TxV0 { + fn from_noun(noun: &Noun) -> Result { + let cell = noun.as_cell()?; + let version = u64::from_noun(&cell.head())?; + + let tail = cell.tail(); + let cell = tail.as_cell()?; + let raw_tx = RawTx::from_noun(&cell.head())?; + + let tail = cell.tail(); + let cell = tail.as_cell()?; + let total_size = u64::from_noun(&cell.head())?; + let outputs = decode_outputs(&cell.tail())?; + + Ok(Self { + version, + raw_tx, + total_size, + outputs, + }) + } +} + +fn decode_outputs(noun: &Noun) -> Result, NounDecodeError> { + if let Ok(atom) = noun.as_atom() { + if atom.as_u64()? == 0 { + return Ok(Vec::new()); + } + } + + let mut outputs = Vec::new(); + for entry in HoonMapIter::from(*noun) { + if !entry.is_cell() { + continue; + } + let [key, value] = entry.uncell().map_err(|_| NounDecodeError::ExpectedCell)?; + let lock = Lock::from_noun(&key)?; + let value_cell = value.as_cell().map_err(|_| NounDecodeError::ExpectedCell)?; + let note = NoteV0::from_noun(&value_cell.head())?; + outputs.push(TxOutput { lock, note }); + } + + Ok(outputs) +} + +fn build_transaction_details( + metadata: &BlockMetadata, + tx_hash: &Hash, + tx: TxV0, +) -> TransactionDetails { + let TxV0 { + version, + raw_tx, + total_size, + outputs, + } = tx; + + let mut total_input = 0u64; + let mut inputs = Vec::new(); + for (name, input) in &raw_tx.inputs.0 { + let amount = input.note.tail.assets.0 as u64; + total_input += amount; + inputs.push(TransactionInput { + note_name_b58: note_name_to_b58(&name), + amount: Some(pb_common::Nicks { value: amount }), + source_tx_id: input.note.tail.source.hash.to_base58(), + coinbase: input.note.tail.source.is_coinbase, + }); + } + + let mut total_output = 0u64; + let mut outputs_proto = Vec::new(); + for output in outputs { + let amount = output.note.tail.assets.0 as u64; + total_output += amount; + outputs_proto.push(TransactionOutput { + note_name_b58: note_name_to_b58(&output.note.tail.name), + amount: Some(pb_common::Nicks { value: amount }), + lock_summary: lock_summary(&output.lock), + }); + } + + TransactionDetails { + tx_id: tx_hash.to_base58(), + block_id: Some(hash_to_proto(&metadata.block_id)), + parent: Some(hash_to_proto(&metadata.parent_id)), + height: metadata.height, + timestamp: metadata.timestamp, + version, + size_bytes: total_size, + total_input: Some(pb_common::Nicks { value: total_input }), + total_output: Some(pb_common::Nicks { + value: total_output, + }), + fee: Some(pb_common::Nicks { + value: raw_tx.total_fees.0 as u64, + }), + inputs, + outputs: outputs_proto, + } +} + +fn hash_to_proto(hash: &Hash) -> pb_common::Hash { + pb_common::Hash { + belt_1: Some(pb_common::Belt { value: hash.0[0].0 }), + belt_2: Some(pb_common::Belt { value: hash.0[1].0 }), + belt_3: Some(pb_common::Belt { value: hash.0[2].0 }), + belt_4: Some(pb_common::Belt { value: hash.0[3].0 }), + belt_5: Some(pb_common::Belt { value: hash.0[4].0 }), + } +} + +fn note_name_to_b58(name: &Name) -> String { + name.first.to_base58() +} + +fn lock_summary(lock: &Lock) -> String { + let keys: Vec = lock + .pubkeys + .iter() + .filter_map(|key| key.to_base58().ok()) + .collect(); + if keys.is_empty() { + format!("{}-of-{}", lock.keys_required, lock.pubkeys.len()) + } else { + format!( + "{}-of-{} [{}]", + lock.keys_required, + lock.pubkeys.len(), + keys.join(", ") + ) + } +} + +#[cfg(test)] +mod tests { + use nockchain_math::belt::Belt; + use nockchain_types::tx_engine::common::{BlockHeight, Hash}; + use noun_serde::{NounDecode, NounEncode}; + + use super::*; + + #[test] + fn test_decode_blockheight_as_atom() { + let mut slab: NounSlab = NounSlab::new(); + + // Test that BlockHeight encodes as a simple atom + let height = BlockHeight(Belt(105)); + let height_noun = height.to_noun(&mut slab); + + // Verify it's an atom + assert!(height_noun.is_atom(), "BlockHeight should encode as atom"); + + // Try to decode as u64 directly + let result_u64 = u64::from_noun(&height_noun); + match result_u64 { + Ok(val) => { + println!("BlockHeight decoded as u64: {}", val); + assert_eq!(val, 105); + } + Err(e) => { + panic!("Failed to decode BlockHeight as u64: {:?}", e); + } + } + + // Try to decode as BlockHeight + let result_height = BlockHeight::from_noun(&height_noun); + match result_height { + Ok(h) => { + println!("BlockHeight decoded correctly: {:?}", h); + assert_eq!(h.0 .0, 105); + } + Err(e) => { + panic!("Failed to decode as BlockHeight: {:?}", e); + } + } + } + + #[test] + fn test_decode_block_range_entry_minimal() { + let mut slab: NounSlab = NounSlab::new(); + + // Create a minimal BlockRangeEntry structure + // [page-number block-id page (z-map tx-id tx)] + + // page-number as Belt (atom) + let height = BlockHeight(Belt(42)); + let height_noun = height.to_noun(&mut slab); + + // block-id as Hash [Belt; 5] + let block_id = Hash([Belt(1), Belt(2), Belt(3), Belt(4), Belt(5)]); + let block_id_noun = block_id.to_noun(&mut slab); + + // page structure: [digest pow parent tx-ids coinbase timestamp epoch-counter target accumulated-work height msg] + let digest = Hash([Belt(10), Belt(11), Belt(12), Belt(13), Belt(14)]); + let pow = nockvm::noun::D(0); // empty unit + let tx_ids_set = nockvm::noun::D(0); // empty z-set + let coinbase = nockvm::noun::D(0); + let timestamp = Belt(1234567890); + let epoch_counter = Belt(0); + let target = Belt(100); + let accumulated_work = Belt(500); + let page_height = Belt(42); + let parent = Hash([Belt(20), Belt(21), Belt(22), Belt(23), Belt(24)]); + let msg = nockvm::noun::D(0); + + // Create all nouns first to avoid borrow checker issues + let digest_noun = digest.to_noun(&mut slab); + let parent_noun = parent.to_noun(&mut slab); + let timestamp_noun = timestamp.to_noun(&mut slab); + let epoch_counter_noun = epoch_counter.to_noun(&mut slab); + let target_noun = target.to_noun(&mut slab); + let accumulated_work_noun = accumulated_work.to_noun(&mut slab); + let page_height_noun = page_height.to_noun(&mut slab); + + let page_noun = nockvm::noun::T( + &mut slab, + &[ + digest_noun, pow, parent_noun, tx_ids_set, coinbase, timestamp_noun, + epoch_counter_noun, target_noun, accumulated_work_noun, page_height_noun, msg, + ], + ); + + // Empty z-map (atom 0) + let txs_map_noun = nockvm::noun::D(0); + + // Build inner cells first + let page_txs_cell = nockvm::noun::T(&mut slab, &[page_noun, txs_map_noun]); + let block_page_cell = nockvm::noun::T(&mut slab, &[block_id_noun, page_txs_cell]); + + // Build the entry: [height [block-id [page txs-map]]] + let entry_noun = nockvm::noun::T(&mut slab, &[height_noun, block_page_cell]); + + // Try to decode it + let raw = BlockRangeEntryNoun::from_noun(&entry_noun).expect("decode raw entry"); + let entry = BlockRangeEntry::try_from(raw).expect("convert raw entry"); + + assert_eq!(entry.height, 42); + assert_eq!(entry.block_id, block_id); + assert_eq!(entry.parent_id, parent); + assert_eq!(entry.timestamp, 1234567890); + assert_eq!(entry.tx_ids.len(), 0); + } +} diff --git a/crates/nockapp-grpc/src/services/public_nockchain/v2/metrics.rs b/crates/nockapp-grpc/src/services/public_nockchain/v2/metrics.rs index 5d845b027..9fa1f32de 100644 --- a/crates/nockapp-grpc/src/services/public_nockchain/v2/metrics.rs +++ b/crates/nockapp-grpc/src/services/public_nockchain/v2/metrics.rs @@ -108,7 +108,83 @@ metrics_struct![ (balance_cache_address_hit, "nockchain_public_grpc.balance_cache_address_hit", Count), (balance_cache_address_miss, "nockchain_public_grpc.balance_cache_address_miss", Count), (balance_cache_first_name_hit, "nockchain_public_grpc.balance_cache_first_name_hit", Count), - (balance_cache_first_name_miss, "nockchain_public_grpc.balance_cache_first_name_miss", Count) + (balance_cache_first_name_miss, "nockchain_public_grpc.balance_cache_first_name_miss", Count), + ( + block_explorer_cache_initialize_time, + "nockchain_public_grpc.block_explorer.cache_initialize_time", TimingCount + ), + ( + block_explorer_cache_initialize_error, + "nockchain_public_grpc.block_explorer.cache_initialize_error", Count + ), + ( + block_explorer_cache_refresh_time, + "nockchain_public_grpc.block_explorer.cache_refresh_time", TimingCount + ), + ( + block_explorer_cache_refresh_error, + "nockchain_public_grpc.block_explorer.cache_refresh_error", Count + ), + ( + block_explorer_cache_backfill_error, + "nockchain_public_grpc.block_explorer.cache_backfill_error", Count + ), + ( + block_explorer_get_blocks_success, + "nockchain_public_grpc.block_explorer.get_blocks.success", TimingCount + ), + ( + block_explorer_get_blocks_error, "nockchain_public_grpc.block_explorer.get_blocks.error", + TimingCount + ), + ( + block_explorer_get_blocks_error_invalid_request, + "nockchain_public_grpc.block_explorer.get_blocks.error.invalid_request", Count + ), + ( + block_explorer_get_blocks_error_internal, + "nockchain_public_grpc.block_explorer.get_blocks.error.internal", Count + ), + ( + block_explorer_get_transaction_block_success, + "nockchain_public_grpc.block_explorer.get_transaction_block.success", TimingCount + ), + ( + block_explorer_get_transaction_block_error, + "nockchain_public_grpc.block_explorer.get_transaction_block.error", TimingCount + ), + ( + block_explorer_get_transaction_block_not_found, + "nockchain_public_grpc.block_explorer.get_transaction_block.not_found", Count + ), + ( + block_explorer_get_transaction_block_pending, + "nockchain_public_grpc.block_explorer.get_transaction_block.pending", Count + ), + ( + block_explorer_get_transaction_block_invalid_request, + "nockchain_public_grpc.block_explorer.get_transaction_block.invalid_request", Count + ), + ( + block_explorer_get_transaction_details_success, + "nockchain_public_grpc.block_explorer.get_transaction_details.success", TimingCount + ), + ( + block_explorer_get_transaction_details_error, + "nockchain_public_grpc.block_explorer.get_transaction_details.error", TimingCount + ), + ( + block_explorer_get_transaction_details_not_found, + "nockchain_public_grpc.block_explorer.get_transaction_details.not_found", Count + ), + ( + block_explorer_get_transaction_details_pending, + "nockchain_public_grpc.block_explorer.get_transaction_details.pending", Count + ), + ( + block_explorer_get_transaction_details_invalid_request, + "nockchain_public_grpc.block_explorer.get_transaction_details.invalid_request", Count + ) ]; static METRICS: OnceCell> = OnceCell::new(); diff --git a/crates/nockapp-grpc/src/services/public_nockchain/v2/mod.rs b/crates/nockapp-grpc/src/services/public_nockchain/v2/mod.rs index a673b51c0..81bb4a2ce 100644 --- a/crates/nockapp-grpc/src/services/public_nockchain/v2/mod.rs +++ b/crates/nockapp-grpc/src/services/public_nockchain/v2/mod.rs @@ -1,3 +1,4 @@ +pub mod block_explorer; mod cache; pub mod client; pub mod driver; diff --git a/crates/nockapp-grpc/src/services/public_nockchain/v2/server.rs b/crates/nockapp-grpc/src/services/public_nockchain/v2/server.rs index 609bf4990..72d4f0551 100644 --- a/crates/nockapp-grpc/src/services/public_nockchain/v2/server.rs +++ b/crates/nockapp-grpc/src/services/public_nockchain/v2/server.rs @@ -17,12 +17,16 @@ use tonic::{Request, Response, Status}; use tonic_reflection::server::Builder as ReflectionBuilder; use tracing::{debug, info, warn}; +use super::block_explorer::BlockExplorerCache; use super::cache::{ AddressBalanceCache, DEFAULT_PAGE_BYTES, DEFAULT_PAGE_SIZE, MAX_PAGE_BYTES, MAX_PAGE_SIZE, }; use super::metrics::{init_metrics, NockchainGrpcApiMetrics}; use crate::error::{NockAppGrpcError, Result}; use crate::pb::common::v1::{Acknowledged, ErrorCode, ErrorStatus}; +use crate::pb::public::v2::nockchain_block_service_server::{ + NockchainBlockService, NockchainBlockServiceServer, +}; use crate::pb::public::v2::nockchain_service_server::{NockchainService, NockchainServiceServer}; use crate::pb::public::v2::*; use crate::public_nockchain::v2::cache::{ @@ -76,6 +80,7 @@ pub struct PublicNockchainGrpcServer { handle: Arc, cache_by_address: AddressBalanceCache, cache_by_first_name: FirstNameBalanceCache, + block_explorer_cache: Arc, metrics: Arc, heaviest_chain: Arc>>, } @@ -89,32 +94,47 @@ struct HeaviestChainSnapshot { impl PublicNockchainGrpcServer { pub fn new(handle: NockAppHandle) -> Self { + let metrics = init_metrics(); + let block_explorer_cache = Arc::new(BlockExplorerCache::new(metrics.clone())); Self { handle: Arc::new(NockAppBalanceHandle(handle)), cache_by_address: AddressBalanceCache::new(), cache_by_first_name: FirstNameBalanceCache::new(), - metrics: init_metrics(), + block_explorer_cache, + metrics, heaviest_chain: Arc::new(RwLock::new(None)), } } #[cfg(test)] pub(crate) fn with_handle(handle: Arc) -> Self { + let metrics = init_metrics(); + let block_explorer_cache = Arc::new(BlockExplorerCache::new(metrics.clone())); Self { handle, cache_by_address: AddressBalanceCache::new(), cache_by_first_name: FirstNameBalanceCache::new(), - metrics: init_metrics(), + block_explorer_cache, + metrics, heaviest_chain: Arc::new(RwLock::new(None)), } } + #[tracing::instrument( + name = "grpc.public_nockchain.serve", + skip(self), + fields(addr = tracing::field::Empty) + )] pub async fn serve(self, addr: SocketAddr) -> Result<()> { + tracing::Span::current().record("addr", &tracing::field::display(addr)); info!("Starting PublicNockchain gRPC server on {}", addr); - let (health_reporter, health_service) = tonic_health::server::health_reporter(); + let (mut health_reporter, health_service) = tonic_health::server::health_reporter(); health_reporter .set_serving::>() .await; + health_reporter + .set_not_serving::>() + .await; let reflection_service_v1 = ReflectionBuilder::configure() .register_encoded_file_descriptor_set(nockapp_grpc_proto::pb::FILE_DESCRIPTOR_SET) .build_v1() @@ -126,11 +146,27 @@ impl PublicNockchainGrpcServer { warn!("Failed to seed heaviest chain cache: {}", err); } self.start_heaviest_chain_refresh(); - let nockchain_api = NockchainServiceServer::new(self); + + // Initialize block explorer cache + // We need to get the raw handle for initialization + // Since self.handle is Arc, we need to work around this + // For now, we'll initialize in the background task + self.start_block_explorer_refresh(health_reporter.clone()); + + let nockchain_api = NockchainServiceServer::new(self.clone()); + + // Create block explorer service + let block_explorer_api = NockchainBlockServiceServer::new(NockchainBlockServer::new( + self.handle.clone(), + self.block_explorer_cache.clone(), + self.metrics.clone(), + )); + Server::builder() .add_service(health_service) .add_service(reflection_service_v1) .add_service(nockchain_api) + .add_service(block_explorer_api) .serve(addr) .await .map_err(NockAppGrpcError::Transport)?; @@ -155,6 +191,7 @@ impl PublicNockchainGrpcServer { T::from(error_status) } + #[tracing::instrument(name = "public_nockchain.peek_heaviest_chain_path", skip(self))] async fn peek_heaviest_chain(&self) -> Result> { let metrics = &self.metrics; @@ -197,12 +234,78 @@ impl PublicNockchainGrpcServer { }); } + fn start_block_explorer_refresh( + &self, + mut health_reporter: tonic_health::server::HealthReporter, + ) { + let server = self.clone(); + tokio::spawn(async move { + health_reporter + .set_not_serving::>() + .await; + // Initialize on first run + let cache = server.block_explorer_cache.clone(); + let handle = server.handle.clone(); + info!("Block explorer init worker starting"); + if let Err(err) = cache.clone().initialize(handle.clone()).await { + warn!("Failed to initialize block explorer cache: {}", err); + // Continue anyway, will retry on next refresh + health_reporter + .set_not_serving::>() + .await; + return; + } else { + info!("Block explorer cache initialized successfully"); + health_reporter + .set_serving::>() + .await; + } + + let refresh_cache = cache.clone(); + let refresh_handle = handle.clone(); + tokio::spawn(async move { + info!("Block explorer refresh worker starting"); + let mut interval = time::interval(Duration::from_secs(15)); + loop { + interval.tick().await; + if let Err(err) = refresh_cache.refresh(&refresh_handle).await { + warn!("Failed to refresh block explorer cache: {}", err); + } + } + }); + + if let Some(resume_height) = cache.take_backfill_resume().await { + let backfill_cache = cache.clone(); + let backfill_handle = handle.clone(); + tokio::spawn(async move { + info!(resume_height, "Block explorer backfill worker starting"); + match backfill_cache + .backfill_older(backfill_handle, resume_height) + .await + { + Ok(_) => info!("Block explorer backfill worker finished"), + Err(err) => warn!("Block explorer backfill failed: {}", err), + } + }); + } else { + info!("Block explorer backfill worker not required"); + } + }); + } + + #[tracing::instrument( + name = "grpc.heaviest_chain.refresh", + skip(self), + fields(new_height = tracing::field::Empty) + )] async fn refresh_heaviest_chain(&self) -> Result<()> { match self.peek_heaviest_chain().await? { Some((height, block_id)) => { tracing::debug!("refreshed heaviest chain"); let mut guard = self.heaviest_chain.write().await; let new_height_value = height.0 .0; + tracing::Span::current() + .record("new_height", &tracing::field::display(new_height_value)); let should_update = guard .as_ref() .map(|current| new_height_value >= current.height.0 .0) @@ -242,6 +345,28 @@ impl PublicNockchainGrpcServer { } } +/// Separate service for block explorer APIs +#[derive(Clone)] +pub struct NockchainBlockServer { + handle: Arc, + block_explorer_cache: Arc, + metrics: Arc, +} + +impl NockchainBlockServer { + pub fn new( + handle: Arc, + cache: Arc, + metrics: Arc, + ) -> Self { + Self { + handle, + block_explorer_cache: cache, + metrics, + } + } +} + fn timed_return(metric: &TimingCount, started: Instant, value: T) -> T { metric.add_timing(&started.elapsed()); value @@ -253,9 +378,11 @@ impl NockchainService for PublicNockchainGrpcServer { &self, request: Request, ) -> std::result::Result, Status> { + let remote_addr = request.remote_addr(); let req = request.into_inner(); let request_start = Instant::now(); let metrics = &self.metrics; + info!("WalletGetBalance client_ip={:?}", remote_addr); let WalletGetBalanceRequest { selector, page, .. } = req; if selector.is_none() { @@ -422,6 +549,10 @@ impl NockchainService for PublicNockchainGrpcServer { let path_noun = path.to_noun(&mut path_slab); path_slab.set_root(path_noun); + info!( + "peek path=balance-by-pubkey address={} client_ip={:?}", + address.key, remote_addr + ); let peek_start = Instant::now(); let peek_result = self.handle.peek(path_slab).await; self.metrics @@ -640,11 +771,14 @@ impl NockchainService for PublicNockchainGrpcServer { } self.metrics.balance_cache_first_name_miss.increment(); + info!( + "peek path=balance-by-first-name first_name={} client_ip={:?}", + first_name_str.hash, remote_addr + ); let path = vec!["balance-by-first-name".to_string(), first_name_str.hash]; let mut path_slab = NounSlab::new(); let path_noun = path.to_noun(&mut path_slab); path_slab.set_root(path_noun); - let peek_start = Instant::now(); let peek_result = self.handle.peek(path_slab).await; self.metrics @@ -755,10 +889,14 @@ impl NockchainService for PublicNockchainGrpcServer { &self, request: Request, ) -> std::result::Result, Status> { + let remote_addr = request.remote_addr(); let req = request.into_inner(); let request_start = Instant::now(); let metrics = &self.metrics; - debug!("WalletSendTransaction tx_id={:?}", req.tx_id); + debug!( + "WalletSendTransaction tx_id={:?} client_ip={:?}", + req.tx_id, remote_addr + ); let tx_id_pb = match req.tx_id.clone() { Some(id) => id, None => { @@ -919,10 +1057,14 @@ impl NockchainService for PublicNockchainGrpcServer { &self, request: Request, ) -> std::result::Result, Status> { + let remote_addr = request.remote_addr(); let req = request.into_inner(); let request_start = Instant::now(); let metrics = &self.metrics; - debug!("TransactionAccepted tx_id={:?}", req.tx_id); + debug!( + "TransactionAccepted tx_id={:?} client_ip={:?}", + req.tx_id, remote_addr + ); let Some(pb_hash) = req.tx_id else { self.metrics @@ -1024,6 +1166,385 @@ impl NockchainService for PublicNockchainGrpcServer { } } +#[tonic::async_trait] +impl NockchainBlockService for NockchainBlockServer { + #[tracing::instrument( + name = "grpc.block_explorer.get_blocks", + skip(self, request), + fields( + page_token_len = tracing::field::Empty, + limit = tracing::field::Empty, + cursor = tracing::field::Empty + ) + )] + async fn get_blocks( + &self, + request: Request, + ) -> std::result::Result, Status> { + let span = tracing::Span::current(); + let req = request.into_inner(); + let metrics = &self.metrics; + let request_start = Instant::now(); + + // Parse pagination parameters + let page_req = match req.page { + Some(page) => page, + None => { + metrics + .block_explorer_get_blocks_error_invalid_request + .increment(); + metrics + .block_explorer_get_blocks_error + .add_timing(&request_start.elapsed()); + return Err(Status::invalid_argument("page is required")); + } + }; + span.record( + "page_token_len", + &tracing::field::display(page_req.page_token.len()), + ); + + let limit = if page_req.client_page_items_limit == 0 { + DEFAULT_PAGE_SIZE + } else { + std::cmp::min(page_req.client_page_items_limit as usize, MAX_PAGE_SIZE) + }; + span.record("limit", &tracing::field::display(limit)); + + // Decode cursor (height as u64) from page token + let cursor = if page_req.page_token.is_empty() { + None + } else { + // Parse hex-encoded u64 + match u64::from_str_radix(&page_req.page_token, 16) { + Ok(height) => Some(height), + Err(_) => { + metrics + .block_explorer_get_blocks_error_invalid_request + .increment(); + metrics + .block_explorer_get_blocks_error + .add_timing(&request_start.elapsed()); + return Err(Status::invalid_argument("invalid page token")); + } + } + }; + let cursor_repr = cursor + .map(|height| height.to_string()) + .unwrap_or_else(|| "tip".into()); + span.record("cursor", &tracing::field::display(cursor_repr)); + + info!( + limit, + cursor = cursor.unwrap_or_default(), + "Serving GetBlocks request" + ); + + // Get blocks from cache + let (blocks, next_cursor) = self + .block_explorer_cache + .get_blocks_page(cursor, limit) + .await; + + // Convert to proto + use crate::pb::common::v1 as pb_common; + let block_entries: Vec = blocks + .into_iter() + .map(|b| BlockEntry { + block_id: Some(pb_common::Hash { + belt_1: Some(pb_common::Belt { + value: b.block_id.0[0].0, + }), + belt_2: Some(pb_common::Belt { + value: b.block_id.0[1].0, + }), + belt_3: Some(pb_common::Belt { + value: b.block_id.0[2].0, + }), + belt_4: Some(pb_common::Belt { + value: b.block_id.0[3].0, + }), + belt_5: Some(pb_common::Belt { + value: b.block_id.0[4].0, + }), + }), + height: b.height, + parent: Some(pb_common::Hash { + belt_1: Some(pb_common::Belt { + value: b.parent_id.0[0].0, + }), + belt_2: Some(pb_common::Belt { + value: b.parent_id.0[1].0, + }), + belt_3: Some(pb_common::Belt { + value: b.parent_id.0[2].0, + }), + belt_4: Some(pb_common::Belt { + value: b.parent_id.0[3].0, + }), + belt_5: Some(pb_common::Belt { + value: b.parent_id.0[4].0, + }), + }), + timestamp: b.timestamp, + tx_ids: b + .tx_ids + .iter() + .map(|tx_id| pb_common::Base58Hash { + hash: tx_id.to_base58(), + }) + .collect(), + }) + .collect(); + + // Encode next cursor as hex + let next_page_token = next_cursor.map(|h| format!("{:x}", h)).unwrap_or_default(); + + let response = BlocksData { + blocks: block_entries, + current_height: self.block_explorer_cache.get_max_height(), + page: Some(pb_common::PageResponse { next_page_token }), + }; + + info!( + returned = response.blocks.len(), + height = response.current_height, + "Responding to GetBlocks request" + ); + + timed_return( + &metrics.block_explorer_get_blocks_success, + request_start, + Ok(Response::new(GetBlocksResponse { + result: Some(get_blocks_response::Result::Blocks(response)), + })), + ) + } + + #[tracing::instrument( + name = "grpc.block_explorer.get_transaction_block", + skip(self, request), + fields(tx_id = tracing::field::Empty) + )] + async fn get_transaction_block( + &self, + request: Request, + ) -> std::result::Result, Status> { + let span = tracing::Span::current(); + let req = request.into_inner(); + let metrics = &self.metrics; + let request_start = Instant::now(); + + let tx_id_b58 = match req.tx_id { + Some(id) => id.hash, + None => { + metrics + .block_explorer_get_transaction_block_invalid_request + .increment(); + metrics + .block_explorer_get_transaction_block_error + .add_timing(&request_start.elapsed()); + return Err(Status::invalid_argument("tx_id is required")); + } + }; + span.record("tx_id", &tracing::field::display(tx_id_b58.as_str())); + + info!( + tx_id = tx_id_b58.as_str(), + "Serving GetTransactionBlock request" + ); + + let tx_id = match self.block_explorer_cache.resolve_tx_id(&tx_id_b58).await { + Ok(hash) => hash, + Err(err) => { + metrics + .block_explorer_get_transaction_block_invalid_request + .increment(); + metrics + .block_explorer_get_transaction_block_error + .add_timing(&request_start.elapsed()); + return Err(Status::invalid_argument(err.to_string())); + } + }; + + // Check cache for confirmed tx + if let Some(block) = self.block_explorer_cache.get_block_for_tx(&tx_id).await { + use crate::pb::common::v1 as pb_common; + return timed_return( + &metrics.block_explorer_get_transaction_block_success, + request_start, + Ok(Response::new(GetTransactionBlockResponse { + result: Some(get_transaction_block_response::Result::Block( + TransactionBlockData { + block_id: Some(pb_common::Hash { + belt_1: Some(pb_common::Belt { + value: block.block_id.0[0].0, + }), + belt_2: Some(pb_common::Belt { + value: block.block_id.0[1].0, + }), + belt_3: Some(pb_common::Belt { + value: block.block_id.0[2].0, + }), + belt_4: Some(pb_common::Belt { + value: block.block_id.0[3].0, + }), + belt_5: Some(pb_common::Belt { + value: block.block_id.0[4].0, + }), + }), + height: block.height, + parent: Some(pb_common::Hash { + belt_1: Some(pb_common::Belt { + value: block.parent_id.0[0].0, + }), + belt_2: Some(pb_common::Belt { + value: block.parent_id.0[1].0, + }), + belt_3: Some(pb_common::Belt { + value: block.parent_id.0[2].0, + }), + belt_4: Some(pb_common::Belt { + value: block.parent_id.0[3].0, + }), + belt_5: Some(pb_common::Belt { + value: block.parent_id.0[4].0, + }), + }), + timestamp: block.timestamp, + }, + )), + })), + ); + } + + // Check if tx is in mempool (not yet in block) + let mut path_slab = NounSlab::new(); + let tag = nockapp::utils::make_tas(&mut path_slab, "raw-transaction").as_noun(); + let tx_id_b58_noun = tx_id_b58.to_noun(&mut path_slab); + let path_noun = nockvm::noun::T(&mut path_slab, &[tag, tx_id_b58_noun, SIG]); + path_slab.set_root(path_noun); + + match self.handle.peek(path_slab).await { + Ok(Some(_)) => { + // Tx exists in raw-txs, not yet in block + metrics + .block_explorer_get_transaction_block_pending + .increment(); + timed_return( + &metrics.block_explorer_get_transaction_block_success, + request_start, + Ok(Response::new(GetTransactionBlockResponse { + result: Some(get_transaction_block_response::Result::Pending( + TransactionPending {}, + )), + })), + ) + } + Ok(None) | Err(_) => { + // Tx doesn't exist anywhere + metrics + .block_explorer_get_transaction_block_not_found + .increment(); + metrics + .block_explorer_get_transaction_block_error + .add_timing(&request_start.elapsed()); + Err(Status::not_found("Transaction not found")) + } + } + } + + #[tracing::instrument( + name = "grpc.block_explorer.get_transaction_details", + skip(self, request), + fields(tx_id = tracing::field::Empty) + )] + async fn get_transaction_details( + &self, + request: Request, + ) -> std::result::Result, Status> { + let span = tracing::Span::current(); + let req = request.into_inner(); + let metrics = &self.metrics; + let request_start = Instant::now(); + + let tx_id_b58 = match req.tx_id { + Some(id) => id.hash, + None => { + metrics + .block_explorer_get_transaction_details_invalid_request + .increment(); + metrics + .block_explorer_get_transaction_details_error + .add_timing(&request_start.elapsed()); + return Err(Status::invalid_argument("tx_id is required")); + } + }; + span.record("tx_id", &tracing::field::display(tx_id_b58.as_str())); + + info!( + tx_id = tx_id_b58.as_str(), + "Serving GetTransactionDetails request" + ); + + let tx_hash = match self.block_explorer_cache.resolve_tx_id(&tx_id_b58).await { + Ok(hash) => hash, + Err(err) => { + metrics + .block_explorer_get_transaction_details_invalid_request + .increment(); + metrics + .block_explorer_get_transaction_details_error + .add_timing(&request_start.elapsed()); + return Err(Status::invalid_argument(err.to_string())); + } + }; + + match self + .block_explorer_cache + .load_transaction_details(&self.handle, &tx_hash) + .await + { + Ok(details) => timed_return( + &metrics.block_explorer_get_transaction_details_success, + request_start, + Ok(Response::new(GetTransactionDetailsResponse { + result: Some(get_transaction_details_response::Result::Details(details)), + })), + ), + Err(NockAppGrpcError::TxPending) => { + metrics + .block_explorer_get_transaction_details_pending + .increment(); + timed_return( + &metrics.block_explorer_get_transaction_details_success, + request_start, + Ok(Response::new(GetTransactionDetailsResponse { + result: Some(get_transaction_details_response::Result::Pending( + TransactionPending {}, + )), + })), + ) + } + Err(NockAppGrpcError::NotFound) => { + metrics + .block_explorer_get_transaction_details_not_found + .increment(); + metrics + .block_explorer_get_transaction_details_error + .add_timing(&request_start.elapsed()); + Err(Status::not_found("Transaction not found")) + } + Err(err) => { + metrics + .block_explorer_get_transaction_details_error + .add_timing(&request_start.elapsed()); + Err(Status::internal(err.to_string())) + } + } + } +} + #[cfg(test)] mod tests { use std::sync::atomic::{AtomicUsize, Ordering}; diff --git a/crates/nockapp/src/drivers/file.rs b/crates/nockapp/src/drivers/file.rs index c00007939..c89f98a19 100644 --- a/crates/nockapp/src/drivers/file.rs +++ b/crates/nockapp/src/drivers/file.rs @@ -52,7 +52,7 @@ async fn prepare_file_write(path: &str, contents: &[u8]) -> std::io::Result std::io::Result<()> { debug!("file driver: writing {} bytes to: {}", contents.len(), path); - let mut file = prepare_file_write(path, contents).await?; + let file = prepare_file_write(path, contents).await?; file.sync_all().await } diff --git a/crates/nockchain-api/Cargo.toml b/crates/nockchain-api/Cargo.toml new file mode 100644 index 000000000..0633ee84f --- /dev/null +++ b/crates/nockchain-api/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "nockchain-api" +version.workspace = true +edition.workspace = true + +[features] +default = [] +tracing-heap = ["tracy-client"] +malloc = [] + +[dependencies] +kernels = { workspace = true, features = ["dumb"] } +nockapp.workspace = true +nockchain.workspace = true +nockvm.workspace = true +zkvm-jetpack.workspace = true + +clap.workspace = true +tikv-jemallocator = { workspace = true, features = [ + "unprefixed_malloc_on_supported_platforms", +] } +tokio.workspace = true +tracy-client = { workspace = true, optional = true } diff --git a/crates/nockchain-api/README.md b/crates/nockchain-api/README.md new file mode 100644 index 000000000..b12d39b75 --- /dev/null +++ b/crates/nockchain-api/README.md @@ -0,0 +1,43 @@ +# nockchain-api (ALPHA) + +## ALPHA/TESTING GRADE SOFTWARE, TURN BACK YOU ARE NOT SUPPOSED TO BE HERE. + +**This is pre-release/alpha infrastructure. If you aren't already comfortable debugging Nockapp/Nockchain in production, turn back now.** + +---- + +No really go away. This is pre-alpha software. We made it public but we're not going to be able to support it or answer questions from the public until it's in a much more complete and stable state. + +## What it does + +`nockchain-api` is the public-facing NockApp gRPC API binary: it boots the standard `nockapp` runtime, loads the `nockchain` kernel, and exposes the gRPC services (`NockchainService` and `NockchainBlockService`) that depend on the live node state. This is the binary to run when you need the API surface enabled. + +This is distinct from the regular `nockchain` binary and NockApps more generally: they only expose the private gRPC by default for private peeks and pokes. + +__This comes with a considerably different risk surface area and requires expert use and thoughtful configuration, deployment, and monitoring__ + +## Minimum config to make it useful + +1. Provide the normal Nockchain CLI flags (genesis, mining, peers, etc.) exactly as you would for any full node. +2. Add **both**: + - `--bind /ip4/…/udp/…/quic-v1` (the libp2p listen multiaddr for the node itself), and + - `--bind-public-grpc-addr host:port` (the socket the gRPC API will bind to). +3. Start it the usual way (`cargo run --release --bin nockchain-api -- ` or `make run-nockchain-api`). + +That’s it—the API surface piggybacks on the running node; there is no separate config file. + +## Security posture (none) + +- There is **no authentication, authorization, or rate limiting** in the public gRPC service today. +- If you expose `--bind-public-grpc-addr` directly to the Internet you are doing so entirely **at your own risk**. +- Until auth lands, run the API behind whatever you trust (VPN, SSH tunnel, mTLS proxy, private network). Do not put this on an open port. + +## Critical operational notes + +- The Block Explorer endpoints (`GetBlocks`, `GetTransactionBlock`, `GetTransactionDetails`) are backed by an in-memory cache of the heaviest chain. They do **not** stream mempool contents; pending transactions are only reported as “pending”. +- Cache warm-up: on first start only the newest ~64 blocks are available; backfill runs in the background. Plan for a brief window where pagination returns nothing until backfill finishes. +- Reorgs: the cache follows the reported heaviest chain but does not yet prune orphaned entries, so short-lived stale data can appear after a reorg. +- Observability: gnort metrics (prefixed `nockchain_public_grpc.*`) emit cache timings, heaviest-chain freshness, and RPC success/error counts. Use them to verify your deployment is healthy. +- This binary shares the same hot prover state (`zkvm-jetpack::produce_prover_hot_state`) as every other Nockchain node; make sure the host has enough RAM for the prover plus the gRPC caches. + +Deployments today are integration testbeds, not hardened services. Control access, scrape the metrics, and expect breaking changes until we tag an official release. diff --git a/crates/nockchain-api/src/main.rs b/crates/nockchain-api/src/main.rs new file mode 100644 index 000000000..2ac9907b1 --- /dev/null +++ b/crates/nockchain-api/src/main.rs @@ -0,0 +1,43 @@ +use std::error::Error; + +use clap::Parser; +use kernels::dumb::KERNEL; +use nockapp::kernel::boot; +use nockchain::NockchainAPIConfig; +use zkvm_jetpack::hot::produce_prover_hot_state; + +// Disable jemalloc when we're running Miri +#[cfg(all(not(miri), not(feature = "tracing-heap"), not(feature = "malloc")))] +#[global_allocator] +static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; + +#[cfg(feature = "tracing-heap")] +#[global_allocator] +static ALLOC: tracy_client::ProfiledAllocator = + tracy_client::ProfiledAllocator::new(tikv_jemallocator::Jemalloc, 100); + +#[cfg(feature = "tracing-heap")] +#[global_allocator] +static ALLOC: tracy_client::ProfiledAllocator = + tracy_client::ProfiledAllocator::new(tikv_jemallocator::Jemalloc, 100); + +#[tokio::main] +async fn main() -> Result<(), Box> { + nockvm::check_endian(); + let mut cli = nockchain::NockchainCli::parse(); + cli.nockapp_cli.color = clap::ColorChoice::Never; + boot::init_default_tracing(&cli.nockapp_cli); + + let prover_hot_state = produce_prover_hot_state(); + + let api_config = if let Some(addr) = cli.bind_public_grpc_addr { + NockchainAPIConfig::EnablePublicServer(addr) + } else { + NockchainAPIConfig::DisablePublicServer + }; + + let mut nockchain: nockapp::NockApp = + nockchain::init_with_kernel(cli, KERNEL, prover_hot_state.as_slice(), api_config).await?; + nockchain.run().await?; + Ok(()) +} diff --git a/crates/nockchain-explorer-tui/Cargo.toml b/crates/nockchain-explorer-tui/Cargo.toml new file mode 100644 index 000000000..b50ee6235 --- /dev/null +++ b/crates/nockchain-explorer-tui/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "nockchain-explorer-tui" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "nockchain-explorer-tui" +path = "src/main.rs" + +[dependencies] +anyhow = { workspace = true } +clap = { workspace = true, features = ["derive"] } +chrono = { workspace = true } +crossterm = { workspace = true } +nockapp-grpc = { workspace = true } +nockapp-grpc-proto = { workspace = true } +nockchain-types = { workspace = true } +nockchain-math = { workspace = true } +ratatui = "0.28" +tokio = { workspace = true, features = ["full"] } +tonic = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true, features = ["env-filter"] } +tracing-tracy = { workspace = true } +arboard = "3.3" diff --git a/crates/nockchain-explorer-tui/README.md b/crates/nockchain-explorer-tui/README.md new file mode 100644 index 000000000..f5dab6d07 --- /dev/null +++ b/crates/nockchain-explorer-tui/README.md @@ -0,0 +1,86 @@ +# Nockchain Block Explorer TUI + +A terminal user interface for exploring the Nockchain blockchain via the gRPC Block Explorer API. + +## ALPHA SOFTWARE, IF YOU ARE NOT AN ADEPT USER YOU WILL NOT FIND THIS USEFUL + +**This is pre-release/alpha/pre-alpha grade stuff. If you don't know how to write code without an LLM doing most of the work turn back now.** + +## OK you made it this far + +- There is no public instance for this to connect to. +- No, not Zorp's public API instance that the wallet uses either. +- You must run your own instance. We will not help you. Ask the community and they may have time to help you. We do not. +- If the state gets hinky, just restart the TUI app +- This is mostly for enabling systems integrators to debug the gRPC blocks and transactions endpoints +- I also like to use it because it makes me happy. YMMV. + +## Features + +- **Blocks List View**: Browse blocks in descending order by height with pagination +- **Block Details View**: Drill down into individual blocks to see all transactions +- **Transaction Search**: Look up transactions by ID to see if they're confirmed or pending +- **Auto-refresh**: Automatically updates the blockchain state every 10 seconds + +## Usage + +```bash +# Connect to default server (localhost:50051) +cargo run --release -p nockchain-explorer-tui + +# Connect to custom server +cargo run --release -p nockchain-explorer-tui -- --server http://my-server:50051 + +# Or use the binary directly +./target/release/nockchain-explorer-tui --server http://localhost:50051 +``` + +## Key Bindings + +### Blocks List View +- `↑/k`: Move up +- `↓/j`: Move down +- `Enter`: View block details +- `t`: Search for a transaction +- `r`: Manually refresh blocks +- `a`: Toggle auto-refresh on/off +- `n`: Load next page of blocks +- `q`: Quit + +### Block Details View +- `↑/k`: Navigate to previous block +- `↓/j`: Navigate to next block +- `ESC`: Return to blocks list +- `q`: Quit + +### Transaction Search View +- Type: Enter transaction ID (base58-encoded) +- `Enter`: Perform search +- `Ctrl+C`: Clear input and results +- `Backspace`: Delete character +- `ESC`: Return to blocks list +- `q`: Quit + +## Example + +``` +┌Info─────────────────────────────────────────────────────────────┐ +│Nockchain Block Explorer | Height: 12345 │ +│Server: http://localhost:50051 │ +└─────────────────────────────────────────────────────────────────┘ +┌Blocks (50)──────────────────────────────────────────────────────┐ +│>> Height: 12345 | TXs: 3 | Block ID: 0001020...0304050607 │ +│ Height: 12344 | TXs: 1 | Block ID: 0011121...1314151617 │ +│ Height: 12343 | TXs: 5 | Block ID: 0021222...2324252627 │ +│ ... │ +└─────────────────────────────────────────────────────────────────┘ +┌Status───────────────────────────────────────────────────────────┐ +│Ready | Auto-refresh: ON (last: 3s ago) | More pages available │ +└─────────────────────────────────────────────────────────────────┘ +↑/k: Up | ↓/j: Down | Enter: Details | t: Search TX | q: Quit +``` + +## Requirements + +- Running nockchain-api server with block explorer API enabled +- gRPC server must be accessible at the specified URI diff --git a/crates/nockchain-explorer-tui/src/main.rs b/crates/nockchain-explorer-tui/src/main.rs new file mode 100644 index 000000000..1145a71d3 --- /dev/null +++ b/crates/nockchain-explorer-tui/src/main.rs @@ -0,0 +1,3275 @@ +use std::cmp::Ordering; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::time::{Duration, Instant}; +use std::{env, io}; + +use anyhow::{anyhow, Result}; +use arboard::Clipboard; +use chrono::{Duration as ChronoDuration, TimeZone, Utc}; +use clap::Parser; +use crossterm::event::{ + self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyModifiers, +}; +use crossterm::execute; +use crossterm::terminal::{ + disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen, +}; +use nockapp_grpc_proto::pb::common::v1::{self as pb_common, Base58Hash, PageRequest}; +use nockapp_grpc_proto::pb::public::v2::nockchain_block_service_client::NockchainBlockServiceClient; +use nockapp_grpc_proto::pb::public::v2::{ + get_blocks_response, get_transaction_block_response, get_transaction_details_response, + BlockEntry, GetBlocksRequest, GetTransactionBlockRequest, GetTransactionDetailsRequest, + TransactionBlockData, TransactionDetails as RpcTransactionDetails, +}; +use nockchain_math::belt::Belt; +use nockchain_types::tx_engine::common::Hash as Tip5Hash; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap}; +use ratatui::{Frame, Terminal}; +use tokio::sync::mpsc::error::TryRecvError; +use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; +use tonic::Request; +use tracing::{info, warn}; +use tracing_subscriber::prelude::*; +use tracing_subscriber::{fmt, EnvFilter}; +use tracing_tracy::TracyLayer; + +#[derive(Parser, Debug)] +#[command(name = "nockchain-explorer-tui")] +#[command(about = "Block Explorer TUI for Nockchain", long_about = None)] +struct Args { + /// gRPC server URI (e.g., http://localhost:50051) + #[arg(short, long, default_value = "http://localhost:50051")] + server: String, + + /// Fail immediately if cannot connect to server (old behavior) + #[arg(long)] + fail_fast: bool, +} + +#[derive(Debug, Clone)] +enum View { + BlocksList, + TransactionsList, + WalletsList, + BlockDetails(usize), // index in blocks list + TransactionDetails { block_idx: usize, tx_idx: usize }, + TransactionSearch, + Help, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +enum ConnectionStatus { + NeverConnected, + Connected, + Disconnected, + Reconnecting, +} + +const PAGE_JUMP: usize = 20; +const AUTO_REFRESH_IDLE_GRACE: Duration = Duration::from_secs(3); +const EMPTY_CACHE_BACKOFF: Duration = Duration::from_secs(30); +const ERROR_REFRESH_BACKOFF: Duration = Duration::from_secs(5); +const WALLET_INDEX_CHUNK: usize = 64; +const NICKS_PER_NOCK: u64 = 65_536; + +struct App { + client: Option>, + blocks: Vec, + cached_blocks: BTreeMap, + current_height: u64, + list_state: ListState, + view: View, + next_page_token: Option, + has_more_pages: bool, + loading: bool, + error_message: Option, + status_message: Option, + clear_status_on_input: bool, + last_refresh: Instant, + next_allowed_refresh: Instant, + auto_refresh_enabled: bool, + tx_search_input: String, + tx_search_result: Option, + server_uri: String, + previous_view: Option, + tx_list_state: ListState, + tx_detail: Option, + transactions: Vec, + transaction_index: HashMap<(usize, usize), usize>, + tx_overview_state: ListState, + wallets: Vec, + wallet_map: HashMap, + wallet_list_state: ListState, + wallet_indexed_txs: HashSet, + wallet_inflight_txs: HashSet, + wallet_indexing: bool, + wallet_index_message: Option, + wallet_worker_tx: UnboundedSender, + wallet_worker_rx: UnboundedReceiver, + wallet_sort_key: WalletSortKey, + wallet_sort_ascending: bool, + wallet_index_highest_synced: u64, + clipboard: Option, + block_focus: BlockDetailsFocus, + last_user_action: Instant, + help_scroll: u16, + help_max_scroll: u16, + active_tab: usize, + + // Connection state + connection_status: ConnectionStatus, + last_successful_connection: Option, + last_connection_attempt: Instant, + last_connection_error: Option, + fail_fast: bool, +} + +#[derive(Debug, Clone)] +enum TxSearchResult { + Found(TransactionBlockData), + Pending, + NotFound, + Error(String), +} + +#[derive(Debug, Clone)] +enum TxDetailStatus { + Confirmed(RpcTransactionDetails), + Pending, + NotFound, + Error(String), +} + +#[derive(Debug, Clone)] +struct TxDetailState { + tx_id: String, + status: TxDetailStatus, + pane_focus: TxDetailPane, + inputs_scroll: u16, + outputs_scroll: u16, +} + +#[derive(Debug, Clone)] +struct TransactionSummary { + tx_id: String, + block_height: u64, + block_idx: usize, + tx_idx: usize, +} + +#[derive(Debug, Clone)] +struct WalletSummary { + address: String, + total_received: u64, + total_sent: u64, + tx_count: usize, +} + +#[derive(Debug, Default, Clone)] +struct WalletTally { + total_received: u64, + total_sent: u64, + tx_count: usize, +} + +#[derive(Debug, Clone)] +struct WalletIndexTask { + tx_id: String, + block_height: u64, +} + +#[derive(Debug, Clone)] +struct WalletDelta { + address: String, + received: u64, + sent: u64, + tx_count: usize, +} + +#[derive(Debug)] +enum WalletWorkerCommand { + IndexTransactions { + range_start: u64, + range_end: u64, + tasks: Vec, + }, +} + +#[derive(Debug)] +enum WalletWorkerResult { + ChunkComplete { + tx_ids: Vec, + deltas: Vec, + range_start: u64, + range_end: u64, + }, + Error { + tx_ids: Vec, + message: String, + }, + Status(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WalletSortKey { + Balance, + TotalReceived, + TotalSent, + TxCount, +} + +impl WalletSortKey { + fn label(self) -> &'static str { + match self { + WalletSortKey::Balance => "Balance", + WalletSortKey::TotalReceived => "Total Received", + WalletSortKey::TotalSent => "Total Sent", + WalletSortKey::TxCount => "Transactions", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BlockDetailsFocus { + Block, + Transactions, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TxDetailPane { + Inputs, + Outputs, +} + +impl App { + #[tracing::instrument(name = "tui.block_explorer.app_new")] + async fn new(server_uri: String, fail_fast: bool) -> Result { + // Try to connect, but don't fail if we can't (unless fail_fast is set) + let (client, connection_status, connection_error) = + match NockchainBlockServiceClient::connect(server_uri.clone()).await { + Ok(client) => (Some(client), ConnectionStatus::Connected, None), + Err(e) => { + if fail_fast { + return Err(anyhow!("Failed to connect to gRPC server").context(e)); + } + warn!("Initial connection failed: {}, will retry in background", e); + (None, ConnectionStatus::NeverConnected, Some(e.to_string())) + } + }; + + let (wallet_cmd_tx, wallet_cmd_rx) = mpsc::unbounded_channel(); + let (wallet_res_tx, wallet_res_rx) = mpsc::unbounded_channel(); + let wallet_worker_uri = server_uri.clone(); + tokio::spawn(async move { + wallet_index_worker(wallet_worker_uri, wallet_cmd_rx, wallet_res_tx).await; + }); + + let mut app = Self { + client, + blocks: Vec::new(), + cached_blocks: BTreeMap::new(), + current_height: 0, + list_state: ListState::default(), + view: View::BlocksList, + next_page_token: None, + has_more_pages: true, + loading: false, + error_message: connection_error.clone(), + status_message: None, + clear_status_on_input: false, + last_refresh: Instant::now(), + next_allowed_refresh: Instant::now(), + auto_refresh_enabled: true, + tx_search_input: String::new(), + tx_search_result: None, + server_uri, + previous_view: None, + tx_list_state: ListState::default(), + tx_detail: None, + transactions: Vec::new(), + transaction_index: HashMap::new(), + tx_overview_state: ListState::default(), + wallets: Vec::new(), + wallet_map: HashMap::new(), + wallet_list_state: ListState::default(), + wallet_indexed_txs: HashSet::new(), + wallet_inflight_txs: HashSet::new(), + wallet_indexing: false, + wallet_index_message: None, + wallet_worker_tx: wallet_cmd_tx, + wallet_worker_rx: wallet_res_rx, + wallet_sort_key: WalletSortKey::Balance, + wallet_sort_ascending: false, + wallet_index_highest_synced: 0, + clipboard: Clipboard::new().ok(), + block_focus: BlockDetailsFocus::Block, + last_user_action: Instant::now(), + help_scroll: 0, + help_max_scroll: 0, + active_tab: 0, + + // Connection state + connection_status, + last_successful_connection: if connection_status == ConnectionStatus::Connected { + Some(Instant::now()) + } else { + None + }, + last_connection_attempt: Instant::now(), + last_connection_error: connection_error, + fail_fast, + }; + + // Only try to load blocks if connected + if app.connection_status == ConnectionStatus::Connected { + let _ = app.load_blocks(None).await; // Don't fail if this errors + } + + Ok(app) + } + + fn set_view(&mut self, view: View) { + let tab = match &view { + View::BlocksList | View::BlockDetails(_) => 0, + View::TransactionsList | View::TransactionDetails { .. } | View::TransactionSearch => 1, + View::WalletsList => 2, + View::Help => self.active_tab, + }; + self.active_tab = tab; + self.view = view; + } + + fn activate_tab(&mut self, tab: usize) { + match tab % 3 { + 0 => self.set_view(View::BlocksList), + 1 => { + self.set_view(View::TransactionsList); + if self.transactions.is_empty() { + self.status_message = Some("No transactions cached yet".into()); + self.clear_status_on_input = true; + } + } + 2 => { + self.set_view(View::WalletsList); + if self.wallets.is_empty() { + self.status_message = + Some("Wallet index empty; waiting for cached data…".into()); + self.clear_status_on_input = true; + } + } + _ => {} + } + } + + fn cycle_tabs(&mut self, delta: i32) { + let total_tabs = 3; + let idx = (self.active_tab as i32 + delta).rem_euclid(total_tabs as i32) as usize; + self.activate_tab(idx); + } + + #[tracing::instrument( + name = "tui.block_explorer.load_blocks", + skip(self), + fields(page_token = tracing::field::Empty) + )] + async fn load_blocks(&mut self, page_token: Option) -> Result<()> { + let span = tracing::Span::current(); + if let Some(ref token) = page_token { + span.record("page_token", &tracing::field::display(token.as_str())); + } else { + span.record("page_token", &tracing::field::display("tip")); + } + let Some(ref mut client) = self.client else { + self.error_message = Some("Not connected to server".into()); + self.defer_auto_refresh(ERROR_REFRESH_BACKOFF); + return Ok(()); + }; + + self.loading = true; + self.error_message = None; + + let request = GetBlocksRequest { + page: Some(PageRequest { + page_token: page_token.clone().unwrap_or_default(), + client_page_items_limit: 50, + max_bytes: 0, + }), + }; + + match client.get_blocks(Request::new(request)).await { + Ok(response) => { + // Mark as connected on successful response + if self.connection_status != ConnectionStatus::Connected { + self.connection_status = ConnectionStatus::Connected; + self.last_successful_connection = Some(Instant::now()); + } + + let resp = response.into_inner(); + match resp.result { + Some(get_blocks_response::Result::Blocks(blocks_data)) => { + if !blocks_data.blocks.is_empty() { + self.integrate_blocks(blocks_data.blocks); + } else if page_token.is_some() { + self.has_more_pages = false; + self.next_page_token = None; + } else if self.blocks.is_empty() { + self.cached_blocks.clear(); + self.rebuild_blocks(None); + } + + self.current_height = blocks_data.current_height; + self.record_refresh(); + if self.current_height == 0 { + self.status_message = + Some("Waiting for server to sync with the network…".into()); + self.clear_status_on_input = false; + self.defer_auto_refresh(EMPTY_CACHE_BACKOFF); + } else if matches!( + self.status_message.as_deref(), + Some(msg) if msg.contains("Waiting for server to sync") + ) { + self.status_message = None; + } + } + Some(get_blocks_response::Result::Error(err)) => { + self.error_message = Some(format!("API Error: {}", err.message)); + self.defer_auto_refresh(ERROR_REFRESH_BACKOFF); + } + None => { + self.error_message = Some("Empty response from server".to_string()); + self.defer_auto_refresh(ERROR_REFRESH_BACKOFF); + } + } + } + Err(e) => { + // Mark as disconnected on error + self.connection_status = ConnectionStatus::Disconnected; + self.last_connection_error = Some(e.to_string()); + self.error_message = Some(format!("gRPC Error: {}", e)); + self.defer_auto_refresh(ERROR_REFRESH_BACKOFF); + } + } + + self.loading = false; + Ok(()) + } + + #[tracing::instrument(name = "tui.block_explorer.load_next_page", skip(self))] + async fn load_next_page(&mut self) -> Result<()> { + if let Some(token) = self.next_page_token.clone() { + self.load_blocks(Some(token)).await?; + } + Ok(()) + } + + #[tracing::instrument(name = "tui.block_explorer.refresh", skip(self))] + async fn refresh(&mut self) -> Result<()> { + self.load_blocks(None).await + } + + #[tracing::instrument(name = "tui.block_explorer.attempt_reconnect", skip(self))] + async fn attempt_reconnect(&mut self) -> Result<()> { + self.last_connection_attempt = Instant::now(); + self.connection_status = ConnectionStatus::Reconnecting; + + match NockchainBlockServiceClient::connect(self.server_uri.clone()).await { + Ok(client) => { + self.client = Some(client); + self.connection_status = ConnectionStatus::Connected; + self.last_successful_connection = Some(Instant::now()); + self.last_connection_error = None; + self.status_message = Some("Connected to server!".into()); + info!("Reconnected to server at {}", self.server_uri); + + // Try to load initial blocks + let _ = self.load_blocks(None).await; + Ok(()) + } + Err(e) => { + self.connection_status = if self.last_successful_connection.is_some() { + ConnectionStatus::Disconnected + } else { + ConnectionStatus::NeverConnected + }; + self.last_connection_error = Some(e.to_string()); + Err(anyhow!("Reconnection failed: {}", e)) + } + } + } + + fn should_retry_connection(&self) -> bool { + matches!( + self.connection_status, + ConnectionStatus::NeverConnected | ConnectionStatus::Disconnected + ) && self.last_connection_attempt.elapsed() >= Duration::from_secs(5) + } + + #[tracing::instrument( + name = "tui.block_explorer.search_transaction", + skip(self), + fields(query_len = tracing::field::Empty) + )] + async fn search_transaction(&mut self, tx_id: &str) -> Result<()> { + let Some(ref mut client) = self.client else { + self.tx_search_result = Some(TxSearchResult::Error("Not connected to server".into())); + return Ok(()); + }; + + self.loading = true; + self.error_message = None; + self.tx_search_result = None; + + let trimmed = tx_id.trim(); + tracing::Span::current().record("query_len", &tracing::field::display(trimmed.len())); + if trimmed.is_empty() { + self.tx_search_result = Some(TxSearchResult::Error( + "Please enter at least one character".into(), + )); + self.loading = false; + return Ok(()); + } + + let request = GetTransactionBlockRequest { + tx_id: Some(Base58Hash { + hash: trimmed.to_string(), + }), + }; + + match client.get_transaction_block(Request::new(request)).await { + Ok(response) => { + // Mark as connected on successful response + if self.connection_status != ConnectionStatus::Connected { + self.connection_status = ConnectionStatus::Connected; + self.last_successful_connection = Some(Instant::now()); + } + + let resp = response.into_inner(); + self.tx_search_result = Some(Self::map_tx_response(resp.result)); + } + Err(e) => { + // Mark as disconnected on error + self.connection_status = ConnectionStatus::Disconnected; + self.last_connection_error = Some(e.to_string()); + self.tx_search_result = Some(TxSearchResult::Error(format!("gRPC Error: {}", e))); + } + } + + self.loading = false; + Ok(()) + } + + #[tracing::instrument( + name = "tui.block_explorer.open_transaction_detail", + skip(self), + fields(tx_id = tracing::field::Empty) + )] + async fn open_transaction_detail(&mut self, block_idx: usize, tx_idx: usize) -> Result<()> { + let Some(ref mut client) = self.client else { + self.error_message = Some("Not connected to server".into()); + return Ok(()); + }; + + let tx_id = { + let block = self + .blocks + .get(block_idx) + .ok_or_else(|| anyhow!("Block index out of range"))?; + block + .tx_ids + .get(tx_idx) + .ok_or_else(|| anyhow!("Transaction index out of range"))? + .hash + .clone() + }; + tracing::Span::current().record("tx_id", &tracing::field::display(tx_id.as_str())); + + self.loading = true; + self.error_message = None; + + let request = GetTransactionDetailsRequest { + tx_id: Some(Base58Hash { + hash: tx_id.clone(), + }), + }; + + match client.get_transaction_details(Request::new(request)).await { + Ok(response) => { + // Mark as connected on successful response + if self.connection_status != ConnectionStatus::Connected { + self.connection_status = ConnectionStatus::Connected; + self.last_successful_connection = Some(Instant::now()); + } + + let resp = response.into_inner(); + self.tx_detail = Some(TxDetailState { + tx_id: tx_id.clone(), + status: Self::map_tx_detail_response(resp.result), + pane_focus: TxDetailPane::Inputs, + inputs_scroll: 0, + outputs_scroll: 0, + }); + self.set_view(View::TransactionDetails { block_idx, tx_idx }); + self.block_focus = BlockDetailsFocus::Transactions; + self.set_transaction_overview_selection(block_idx, tx_idx); + } + Err(e) => { + // Mark as disconnected on error + self.connection_status = ConnectionStatus::Disconnected; + self.last_connection_error = Some(e.to_string()); + self.error_message = Some(format!("gRPC Error: {}", e)); + } + } + + self.loading = false; + Ok(()) + } + + fn map_tx_response(result: Option) -> TxSearchResult { + match result { + Some(get_transaction_block_response::Result::Block(block_data)) => { + TxSearchResult::Found(block_data) + } + Some(get_transaction_block_response::Result::Pending(_)) => TxSearchResult::Pending, + Some(get_transaction_block_response::Result::Error(err)) => { + if err.message.contains("not found") { + TxSearchResult::NotFound + } else { + TxSearchResult::Error(err.message) + } + } + None => TxSearchResult::Error("Empty response".to_string()), + } + } + + fn map_tx_detail_response( + result: Option, + ) -> TxDetailStatus { + match result { + Some(get_transaction_details_response::Result::Details(details)) => { + TxDetailStatus::Confirmed(details) + } + Some(get_transaction_details_response::Result::Pending(_)) => TxDetailStatus::Pending, + Some(get_transaction_details_response::Result::Error(err)) => { + if err.message.contains("not found") { + TxDetailStatus::NotFound + } else { + TxDetailStatus::Error(err.message) + } + } + None => TxDetailStatus::Error("Empty response".to_string()), + } + } + + fn selected_height(&self) -> Option { + self.list_state + .selected() + .and_then(|idx| self.blocks.get(idx)) + .map(|b| b.height) + } + + fn integrate_blocks(&mut self, new_blocks: Vec) { + let preferred_height = self.selected_height(); + for block in new_blocks { + self.cached_blocks.insert(block.height, block); + } + self.rebuild_blocks(preferred_height); + } + + fn rebuild_blocks(&mut self, preferred_height: Option) { + self.blocks = self + .cached_blocks + .iter() + .rev() + .map(|(_, block)| block.clone()) + .collect(); + + let target_height = preferred_height.or_else(|| self.blocks.first().map(|b| b.height)); + if let Some(height) = target_height { + if let Some(idx) = self.blocks.iter().position(|b| b.height == height) { + self.list_state.select(Some(idx)); + } else if !self.blocks.is_empty() { + self.list_state.select(Some(0)); + } else { + self.list_state.select(None); + } + } else if !self.blocks.is_empty() { + self.list_state.select(Some(0)); + } else { + self.list_state.select(None); + } + + self.update_pagination_tokens(); + + if matches!( + self.view, + View::BlockDetails(_) | View::TransactionDetails { .. } + ) { + if let Some(idx) = self.list_state.selected() { + self.set_view(View::BlockDetails(idx)); + } else { + self.set_view(View::BlocksList); + } + } + self.sync_tx_list_selection(); + self.rebuild_transactions_list(); + } + + fn update_pagination_tokens(&mut self) { + if let Some(oldest) = self.blocks.last() { + if oldest.height > 0 { + self.next_page_token = Some(format!("{:x}", oldest.height - 1)); + self.has_more_pages = true; + } else { + self.next_page_token = None; + self.has_more_pages = false; + } + } else { + self.next_page_token = None; + self.has_more_pages = false; + } + } + + fn rebuild_transactions_list(&mut self) { + let mut summaries = Vec::new(); + let mut index = HashMap::new(); + for (block_idx, block) in self.blocks.iter().enumerate() { + for (tx_idx, tx) in block.tx_ids.iter().enumerate() { + let entry = TransactionSummary { + tx_id: tx.hash.clone(), + block_height: block.height, + block_idx, + tx_idx, + }; + index.insert((block_idx, tx_idx), summaries.len()); + summaries.push(entry); + } + } + self.transactions = summaries; + self.transaction_index = index; + if self.transactions.is_empty() { + self.tx_overview_state.select(None); + } else { + let current = self + .tx_overview_state + .selected() + .unwrap_or(0) + .min(self.transactions.len() - 1); + self.tx_overview_state.select(Some(current)); + } + self.queue_wallet_index_work(); + } + + fn queue_wallet_index_work(&mut self) { + if self.wallet_worker_tx.is_closed() { + return; + } + let mut pending = Vec::new(); + for summary in self.transactions.clone() { + if self.wallet_indexed_txs.contains(&summary.tx_id) + || self.wallet_inflight_txs.contains(&summary.tx_id) + { + continue; + } + self.wallet_inflight_txs.insert(summary.tx_id.clone()); + pending.push(WalletIndexTask { + tx_id: summary.tx_id.clone(), + block_height: summary.block_height, + }); + if pending.len() >= WALLET_INDEX_CHUNK { + let chunk = std::mem::take(&mut pending); + self.dispatch_wallet_chunk(chunk); + } + } + if !pending.is_empty() { + self.dispatch_wallet_chunk(pending); + } + self.wallet_indexing = !self.wallet_inflight_txs.is_empty(); + if self.wallet_indexing { + self.wallet_index_message = Some(format!( + "Indexing wallets… {} tx queued (max height {})", + self.wallet_inflight_txs.len(), + self.wallet_index_highest_synced + )); + } + } + + fn dispatch_wallet_chunk(&mut self, tasks: Vec) { + if tasks.is_empty() { + return; + } + let (min_height, max_height) = tasks.iter().fold((u64::MAX, 0), |(min_h, max_h), task| { + (min_h.min(task.block_height), max_h.max(task.block_height)) + }); + let ids: Vec = tasks.iter().map(|t| t.tx_id.clone()).collect(); + let command = WalletWorkerCommand::IndexTransactions { + range_start: if min_height == u64::MAX { + 0 + } else { + min_height + }, + range_end: max_height, + tasks, + }; + if self.wallet_worker_tx.send(command).is_err() { + self.wallet_index_message = Some("Wallet indexer worker unavailable".to_string()); + // Revert inflight markers since worker did not accept work. + for tx_id in ids { + self.wallet_inflight_txs.remove(&tx_id); + } + self.wallet_indexing = !self.wallet_inflight_txs.is_empty(); + } + } + + fn move_selection_down(&mut self) -> Option { + if self.blocks.is_empty() { + self.list_state.select(None); + return None; + } + let len = self.blocks.len(); + let current = self.list_state.selected().unwrap_or(0); + let new_idx = if current + 1 < len { + current + 1 + } else { + current + }; + self.list_state.select(Some(new_idx)); + Some(new_idx) + } + + fn move_selection_up(&mut self) -> Option { + if self.blocks.is_empty() { + self.list_state.select(None); + return None; + } + let current = self.list_state.selected().unwrap_or(0); + let new_idx = current.saturating_sub(1); + self.list_state.select(Some(new_idx)); + Some(new_idx) + } + + fn select_first_block(&mut self) -> Option { + if self.blocks.is_empty() { + self.list_state.select(None); + None + } else { + self.list_state.select(Some(0)); + Some(0) + } + } + + fn select_last_block(&mut self) -> Option { + if self.blocks.is_empty() { + self.list_state.select(None); + None + } else { + let idx = self.blocks.len() - 1; + self.list_state.select(Some(idx)); + Some(idx) + } + } + + fn move_wallet_selection(&mut self, delta: i32) { + if self.wallets.is_empty() { + self.wallet_list_state.select(None); + return; + } + let len = self.wallets.len() as i32; + let current = self.wallet_list_state.selected().unwrap_or(0) as i32; + let new_idx = (current + delta).clamp(0, len.saturating_sub(1)) as usize; + self.wallet_list_state.select(Some(new_idx)); + } + + fn poll_wallet_worker(&mut self) { + loop { + match self.wallet_worker_rx.try_recv() { + Ok(result) => self.handle_wallet_worker_result(result), + Err(TryRecvError::Empty) => break, + Err(TryRecvError::Disconnected) => { + self.wallet_index_message = Some("Wallet indexer stopped unexpectedly".into()); + break; + } + } + } + } + + fn handle_wallet_worker_result(&mut self, result: WalletWorkerResult) { + match result { + WalletWorkerResult::ChunkComplete { + tx_ids, + deltas, + range_start, + range_end, + } => { + self.wallet_index_highest_synced = self.wallet_index_highest_synced.max(range_end); + for tx_id in tx_ids { + self.wallet_inflight_txs.remove(&tx_id); + self.wallet_indexed_txs.insert(tx_id); + } + if !deltas.is_empty() { + self.apply_wallet_deltas(deltas); + } + self.wallet_indexing = !self.wallet_inflight_txs.is_empty(); + if self.wallet_indexing { + self.wallet_index_message = Some(format!( + "Indexed wallet chunk {}-{} ({} remaining, max height {})", + range_start, + range_end, + self.wallet_inflight_txs.len(), + self.wallet_index_highest_synced + )); + } else { + self.wallet_index_message = Some(format!( + "Wallet index up to height {}", + self.wallet_index_highest_synced + )); + } + if self.wallet_indexing { + self.queue_wallet_index_work(); + } + } + WalletWorkerResult::Error { tx_ids, message } => { + for tx_id in tx_ids { + self.wallet_inflight_txs.remove(&tx_id); + } + self.wallet_indexing = !self.wallet_inflight_txs.is_empty(); + self.wallet_index_message = Some(format!("Wallet index error: {}", message)); + self.queue_wallet_index_work(); + } + WalletWorkerResult::Status(msg) => { + self.wallet_index_message = Some(msg); + } + } + } + + fn apply_wallet_deltas(&mut self, deltas: Vec) { + for delta in deltas { + let entry = self + .wallet_map + .entry(delta.address.clone()) + .or_insert_with(WalletTally::default); + entry.total_received = entry.total_received.saturating_add(delta.received); + entry.total_sent = entry.total_sent.saturating_add(delta.sent); + entry.tx_count += delta.tx_count; + } + self.rebuild_wallets(); + } + + fn rebuild_wallets(&mut self) { + let mut list: Vec = self + .wallet_map + .iter() + .map(|(address, tally)| WalletSummary { + address: address.clone(), + total_received: tally.total_received, + total_sent: tally.total_sent, + tx_count: tally.tx_count, + }) + .collect(); + list.sort_by(|a, b| self.compare_wallets(a, b)); + self.wallets = list; + if self.wallets.is_empty() { + self.wallet_list_state.select(None); + } else { + let current = self + .wallet_list_state + .selected() + .unwrap_or(0) + .min(self.wallets.len() - 1); + self.wallet_list_state.select(Some(current)); + } + } + + fn compare_wallets(&self, a: &WalletSummary, b: &WalletSummary) -> Ordering { + let balance = |w: &WalletSummary| w.total_received.saturating_sub(w.total_sent); + let primary = match self.wallet_sort_key { + WalletSortKey::Balance => balance(a).cmp(&balance(b)), + WalletSortKey::TotalReceived => a.total_received.cmp(&b.total_received), + WalletSortKey::TotalSent => a.total_sent.cmp(&b.total_sent), + WalletSortKey::TxCount => a.tx_count.cmp(&b.tx_count), + }; + let primary = if self.wallet_sort_ascending { + primary + } else { + primary.reverse() + }; + if primary == Ordering::Equal { + a.address.cmp(&b.address) + } else { + primary + } + } + + fn page_wallet_selection(&mut self, pages: i32) { + let delta = pages.saturating_mul(PAGE_JUMP as i32); + self.move_wallet_selection(delta); + } + + fn select_first_wallet(&mut self) { + if self.wallets.is_empty() { + self.wallet_list_state.select(None); + } else { + self.wallet_list_state.select(Some(0)); + } + } + + fn select_last_wallet(&mut self) { + if self.wallets.is_empty() { + self.wallet_list_state.select(None); + } else { + self.wallet_list_state.select(Some(self.wallets.len() - 1)); + } + } + + fn set_wallet_sort_key(&mut self, key: WalletSortKey) { + if self.wallet_sort_key != key { + self.wallet_sort_key = key; + self.wallet_sort_ascending = false; + self.rebuild_wallets(); + } + } + + fn toggle_wallet_sort_order(&mut self) { + self.wallet_sort_ascending = !self.wallet_sort_ascending; + self.rebuild_wallets(); + } + + fn cycle_tx_detail_focus(&mut self) { + if let Some(state) = self.tx_detail.as_mut() { + state.pane_focus = match state.pane_focus { + TxDetailPane::Inputs => TxDetailPane::Outputs, + TxDetailPane::Outputs => TxDetailPane::Inputs, + }; + } + } + + fn adjust_tx_pane_scroll(&mut self, delta: i32) { + if let Some(state) = self.tx_detail.as_mut() { + let scroll = match state.pane_focus { + TxDetailPane::Inputs => &mut state.inputs_scroll, + TxDetailPane::Outputs => &mut state.outputs_scroll, + }; + if delta < 0 { + let amount = (-delta).min(i32::from(u16::MAX)) as u16; + *scroll = scroll.saturating_sub(amount); + } else { + let amount = (delta as u32).min(u16::MAX as u32) as u16; + *scroll = scroll.saturating_add(amount); + } + } + } + + fn page_tx_pane_scroll(&mut self, pages: i32) { + let step = (PAGE_JUMP as i32) * pages; + self.adjust_tx_pane_scroll(step); + } + + fn home_tx_pane(&mut self) { + if let Some(state) = self.tx_detail.as_mut() { + match state.pane_focus { + TxDetailPane::Inputs => state.inputs_scroll = 0, + TxDetailPane::Outputs => state.outputs_scroll = 0, + } + } + } + + fn end_tx_pane(&mut self) { + if let Some(state) = self.tx_detail.as_mut() { + match state.pane_focus { + TxDetailPane::Inputs => state.inputs_scroll = u16::MAX, + TxDetailPane::Outputs => state.outputs_scroll = u16::MAX, + } + } + } + + fn move_transaction_list_selection(&mut self, delta: i32) -> Option { + if self.transactions.is_empty() { + self.tx_overview_state.select(None); + return None; + } + let len = self.transactions.len(); + let current = self.tx_overview_state.selected().unwrap_or(0); + let new_idx = (current as i32 + delta).clamp(0, (len - 1) as i32) as usize; + self.tx_overview_state.select(Some(new_idx)); + Some(new_idx) + } + + fn page_transaction_list_selection(&mut self, delta: i32) -> Option { + let step = (PAGE_JUMP as i32) * delta; + self.move_transaction_list_selection(step) + } + + fn select_first_transaction(&mut self) -> Option { + if self.transactions.is_empty() { + self.tx_overview_state.select(None); + None + } else { + self.tx_overview_state.select(Some(0)); + Some(0) + } + } + + fn select_last_transaction(&mut self) -> Option { + if self.transactions.is_empty() { + self.tx_overview_state.select(None); + None + } else { + let idx = self.transactions.len() - 1; + self.tx_overview_state.select(Some(idx)); + Some(idx) + } + } + + fn current_transaction_global_index(&self) -> Option { + match self.view { + View::TransactionsList => self.tx_overview_state.selected(), + View::TransactionDetails { block_idx, tx_idx } => { + self.transaction_index.get(&(block_idx, tx_idx)).copied() + } + View::BlockDetails(_) => { + if let (Some(block_idx), Some(tx_idx)) = + (self.list_state.selected(), self.selected_tx_index()) + { + self.transaction_index.get(&(block_idx, tx_idx)).copied() + } else { + None + } + } + _ => None, + } + } + + fn set_transaction_overview_selection(&mut self, block_idx: usize, tx_idx: usize) { + if let Some(idx) = self.transaction_index.get(&(block_idx, tx_idx)).copied() { + self.tx_overview_state.select(Some(idx)); + } + } + + async fn open_transaction_from_global_index(&mut self, idx: usize) -> Result<()> { + if let Some(summary) = self.transactions.get(idx).cloned() { + self.list_state.select(Some(summary.block_idx)); + self.block_focus = BlockDetailsFocus::Transactions; + self.sync_tx_list_selection(); + if let Some(block) = self.blocks.get(summary.block_idx) { + if summary.tx_idx < block.tx_ids.len() { + self.tx_list_state.select(Some(summary.tx_idx)); + } + } + self.tx_overview_state.select(Some(idx)); + self.open_transaction_detail(summary.block_idx, summary.tx_idx) + .await?; + } + Ok(()) + } + + async fn navigate_transaction_delta(&mut self, delta: i32) -> Result<()> { + if self.transactions.is_empty() { + return Ok(()); + } + if let Some(current) = self.current_transaction_global_index() { + let len = self.transactions.len(); + let new_idx = (current as i32 + delta).clamp(0, (len - 1) as i32) as usize; + self.open_transaction_from_global_index(new_idx).await?; + } + Ok(()) + } + + async fn sync_all_blocks(&mut self) -> Result<()> { + while self.has_more_pages { + self.load_next_page().await?; + } + self.status_message = Some("Synced all available pages".into()); + self.clear_status_on_input = true; + Ok(()) + } + + fn scroll_help(&mut self, delta: i32) { + let max = self.help_max_scroll; + let new = if delta < 0 { + self.help_scroll + .saturating_sub(delta.unsigned_abs().min(u16::MAX as u32) as u16) + } else { + let inc = (delta as u32).min(u16::MAX as u32) as u16; + self.help_scroll.saturating_add(inc) + }; + self.help_scroll = new.min(max); + } + + fn reset_help_scroll(&mut self) { + self.help_scroll = 0; + } + + fn end_help_scroll(&mut self) { + self.help_scroll = self.help_max_scroll; + } + + fn sync_tx_list_selection(&mut self) { + self.tx_list_state = ListState::default(); + if let View::BlockDetails(idx) = self.view { + if let Some(block) = self.blocks.get(idx) { + if !block.tx_ids.is_empty() { + self.tx_list_state.select(Some(0)); + } + } + } + } + + fn viewing_tip(&self) -> bool { + if self.blocks.is_empty() { + true + } else { + self.list_state + .selected() + .map(|idx| idx == 0) + .unwrap_or(true) + } + } + + fn should_auto_refresh_blocks(&self, interval: Duration) -> bool { + self.auto_refresh_enabled + && Instant::now() >= self.next_allowed_refresh + && matches!(self.view, View::BlocksList) + && self.viewing_tip() + && self.last_refresh.elapsed() >= interval + && self.can_auto_refresh(AUTO_REFRESH_IDLE_GRACE) + } + + fn is_at_bottom(&self) -> bool { + match self.list_state.selected() { + Some(idx) if !self.blocks.is_empty() => idx == self.blocks.len() - 1, + _ => false, + } + } + + fn should_auto_fetch_more(&self) -> bool { + self.has_more_pages && !self.loading && self.is_at_bottom() + } + + fn page_down(&mut self) -> Option { + if self.blocks.is_empty() { + self.list_state.select(None); + return None; + } + let len = self.blocks.len(); + let current = self.list_state.selected().unwrap_or(0); + let jump = PAGE_JUMP.min(len.saturating_sub(1)); + let new_idx = (current + jump).min(len - 1); + self.list_state.select(Some(new_idx)); + Some(new_idx) + } + + fn page_up(&mut self) -> Option { + if self.blocks.is_empty() { + self.list_state.select(None); + return None; + } + let current = self.list_state.selected().unwrap_or(0); + let jump = PAGE_JUMP.min(current); + let new_idx = current.saturating_sub(jump); + self.list_state.select(Some(new_idx)); + Some(new_idx) + } + + fn current_block(&self) -> Option<&BlockEntry> { + self.list_state + .selected() + .and_then(|idx| self.blocks.get(idx)) + } + + fn selected_tx_index(&self) -> Option { + self.tx_list_state.selected() + } + + fn selected_tx_id(&self) -> Option { + match (self.current_block(), self.selected_tx_index()) { + (Some(block), Some(idx)) => block.tx_ids.get(idx).map(|tx| tx.hash.clone()), + _ => None, + } + } + + fn select_first_tx(&mut self) { + if let Some(block) = self.current_block() { + if block.tx_ids.is_empty() { + self.tx_list_state.select(None); + } else { + self.tx_list_state.select(Some(0)); + } + } else { + self.tx_list_state.select(None); + } + } + + fn select_last_tx(&mut self) { + if let Some(block) = self.current_block() { + if block.tx_ids.is_empty() { + self.tx_list_state.select(None); + } else { + self.tx_list_state.select(Some(block.tx_ids.len() - 1)); + } + } else { + self.tx_list_state.select(None); + } + } + + fn copy_tx_id(&mut self, tx_id: &str) { + self.copy_text_to_clipboard(tx_id, "Copied transaction id to clipboard"); + } + + fn copy_selected_block_id(&mut self) { + let block = match self.current_block() { + Some(block) => block, + None => { + self.error_message = Some("No block selected".into()); + self.status_message = None; + self.clear_status_on_input = false; + return; + } + }; + + match hash_option_to_base58(&block.block_id) { + Some(id) => self.copy_text_to_clipboard(&id, "Copied block id to clipboard"), + None => { + self.error_message = Some("Block id unavailable".into()); + self.status_message = None; + self.clear_status_on_input = false; + } + } + } + + fn copy_text_to_clipboard(&mut self, value: &str, success_message: &str) { + self.error_message = None; + match self.clipboard.as_mut() { + Some(clip) => { + if let Err(e) = clip.set_text(value.to_string()) { + self.error_message = Some(format!("Failed to copy: {}", e)); + self.status_message = None; + self.clear_status_on_input = false; + } else { + self.status_message = Some(success_message.into()); + self.clear_status_on_input = true; + self.error_message = None; + } + } + None => match Clipboard::new() { + Ok(mut clip) => { + if let Err(e) = clip.set_text(value.to_string()) { + self.error_message = Some(format!("Failed to copy: {}", e)); + self.status_message = None; + self.clear_status_on_input = false; + } else { + self.status_message = Some(success_message.into()); + self.clear_status_on_input = true; + self.error_message = None; + self.clipboard = Some(clip); + } + } + Err(e) => { + self.error_message = Some(format!("Clipboard unavailable: {}", e)); + self.status_message = None; + self.clear_status_on_input = false; + } + }, + } + } + + fn read_clipboard_text(&mut self) -> Option { + match self.clipboard.as_mut() { + Some(clip) => clip.get_text().ok(), + None => match Clipboard::new() { + Ok(mut clip) => match clip.get_text() { + Ok(text) => { + self.clipboard = Some(clip); + Some(text) + } + Err(_) => None, + }, + Err(_) => None, + }, + } + } + + fn clear_status_if_needed(&mut self) { + if self.clear_status_on_input { + self.status_message = None; + self.clear_status_on_input = false; + } + } + + fn move_tx_selection(&mut self, delta: i32) { + let len = match self.current_block() { + Some(block) => block.tx_ids.len(), + None => return, + }; + if len == 0 { + self.tx_list_state.select(None); + return; + } + let current = self.tx_list_state.selected().unwrap_or(0) as i32; + let new_idx = (current + delta).clamp(0, (len - 1) as i32) as usize; + self.tx_list_state.select(Some(new_idx)); + } + + fn note_user_action(&mut self) { + self.last_user_action = Instant::now(); + } + + fn record_refresh(&mut self) { + let now = Instant::now(); + self.last_refresh = now; + self.next_allowed_refresh = now; + } + + fn defer_auto_refresh(&mut self, delay: Duration) { + self.next_allowed_refresh = Instant::now() + delay; + } + + fn can_auto_refresh(&self, interval: Duration) -> bool { + self.last_user_action.elapsed() >= interval + } + fn open_help(&mut self) { + if !matches!(self.view, View::Help) { + self.previous_view = Some(self.view.clone()); + self.set_view(View::Help); + self.help_scroll = 0; + } + } + + fn close_help(&mut self) { + if let Some(prev) = self.previous_view.take() { + self.set_view(prev); + } else { + self.set_view(View::BlocksList); + } + } +} + +fn ui(f: &mut Frame, app: &mut App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), // Header + Constraint::Length(3), // Tabs + Constraint::Min(0), // Main content + Constraint::Length(3), // Status bar + Constraint::Length(2), // Help + ]) + .split(f.area()); + + render_header(f, chunks[0], app); + render_tabs(f, chunks[1], app); + + match &app.view { + View::BlocksList => render_blocks_list(f, chunks[2], app), + View::TransactionsList => render_transactions_list(f, chunks[2], app), + View::WalletsList => render_wallets_list(f, chunks[2], app), + View::BlockDetails(idx) => render_block_details(f, chunks[2], app, *idx), + View::TransactionDetails { block_idx, tx_idx } => { + render_transaction_details(f, chunks[2], app, *block_idx, *tx_idx) + } + View::TransactionSearch => render_transaction_search(f, chunks[2], app), + View::Help => render_help_menu(f, chunks[2], app), + } + + render_status_bar(f, chunks[3], app); + render_help(f, chunks[4], app); +} + +fn render_header(f: &mut Frame, area: Rect, app: &App) { + let connection_indicator = match app.connection_status { + ConnectionStatus::Connected => { + let age = app + .last_successful_connection + .map(|t| t.elapsed().as_secs()) + .unwrap_or(0); + Span::styled( + format!("● Connected ({}s ago)", age), + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ) + } + ConnectionStatus::Disconnected => Span::styled( + "● Disconnected", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + ConnectionStatus::Reconnecting => Span::styled( + "● Reconnecting...", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + ConnectionStatus::NeverConnected => Span::styled( + "● Never Connected", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + }; + + let title = Paragraph::new(vec![ + Line::from(vec![ + Span::styled( + "Nockchain Block Explorer", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" | "), + connection_indicator, + Span::raw(" | "), + Span::styled( + format!("Height: {}", app.current_height), + Style::default().fg(Color::Yellow), + ), + ]), + Line::from(vec![ + Span::raw("Server: "), + Span::styled(&app.server_uri, Style::default().fg(Color::Green)), + ]), + ]) + .block(Block::default().borders(Borders::ALL).title("Info")); + f.render_widget(title, area); +} + +fn render_tabs(f: &mut Frame, area: Rect, app: &App) { + let titles = ["Blocks", "Transactions", "Wallets"] + .iter() + .map(|title| Line::from(Span::styled(*title, Style::default().fg(Color::Cyan)))) + .collect::>(); + let tabs = Tabs::new(titles) + .block(Block::default().borders(Borders::ALL).title("Views")) + .highlight_style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) + .select(app.active_tab); + f.render_widget(tabs, area); +} + +fn render_blocks_list(f: &mut Frame, area: Rect, app: &mut App) { + let items: Vec = app + .blocks + .iter() + .map(|block| { + let tx_count = block.tx_ids.len(); + let content = vec![Line::from(vec![ + Span::styled( + format!("Height: {:6}", block.height), + Style::default().fg(Color::Yellow), + ), + Span::raw(" | "), + Span::styled( + format!("TXs: {:3}", tx_count), + Style::default().fg(Color::Cyan), + ), + Span::raw(" | "), + Span::styled( + format!("Block ID: {}", hash_display(&block.block_id)), + Style::default().fg(Color::Green), + ), + ])]; + ListItem::new(content) + }) + .collect(); + + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!("Blocks ({})", app.blocks.len())), + ) + .highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(">> "); + + f.render_stateful_widget(list, area, &mut app.list_state); +} + +fn render_transactions_list(f: &mut Frame, area: Rect, app: &mut App) { + let items: Vec = app + .transactions + .iter() + .map(|entry| { + ListItem::new(Line::from(vec![ + Span::styled( + format!("Height: {:6}", entry.block_height), + Style::default().fg(Color::Yellow), + ), + Span::raw(" | "), + Span::styled( + format!("Tx: {}", entry.tx_id), + Style::default().fg(Color::Green), + ), + ])) + }) + .collect(); + + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .title(format!("Transactions ({})", app.transactions.len())), + ) + .highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ); + + f.render_stateful_widget(list, area, &mut app.tx_overview_state); +} + +fn render_wallets_list(f: &mut Frame, area: Rect, app: &mut App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0)]) + .split(area); + + let status_message = app.wallet_index_message.clone().unwrap_or_else(|| { + if app.wallet_index_highest_synced > 0 { + format!( + "Wallet index up to height {}", + app.wallet_index_highest_synced + ) + } else { + "Wallet indexer idle".to_string() + } + }); + let sort_indicator = format!( + "Sorting by {} {}", + app.wallet_sort_key.label(), + if app.wallet_sort_ascending { + '↑' + } else { + '↓' + } + ); + let status = Paragraph::new(format!("{} | {}", status_message, sort_indicator)) + .alignment(Alignment::Left) + .block(Block::default().borders(Borders::ALL).title("Wallet Index")); + f.render_widget(status, chunks[0]); + + if app.wallets.is_empty() { + let empty = Paragraph::new("No wallet data indexed yet") + .block(Block::default().borders(Borders::ALL).title("Wallets")); + f.render_widget(empty, chunks[1]); + return; + } + + let items: Vec = app + .wallets + .iter() + .map(|wallet| { + let balance = wallet.total_received.saturating_sub(wallet.total_sent); + ListItem::new(Line::from(vec![ + Span::styled( + format!("{:>6} tx | ", wallet.tx_count), + Style::default().fg(Color::Yellow), + ), + Span::styled( + format!("{:<20}", short_hash_str(&wallet.address)), + Style::default().fg(Color::Green), + ), + Span::raw(" | Balance "), + Span::styled( + format_wallet_nocks(balance), + Style::default().fg(Color::Magenta), + ), + Span::raw(" | Recv "), + Span::styled( + format_wallet_nocks(wallet.total_received), + Style::default().fg(Color::Cyan), + ), + Span::raw(" | Sent "), + Span::styled( + format_wallet_nocks(wallet.total_sent), + Style::default().fg(Color::LightMagenta), + ), + ])) + }) + .collect(); + + let list = List::new(items) + .block(Block::default().borders(Borders::ALL).title(format!( + "Wallets ({}) – {} {}", + app.wallets.len(), + app.wallet_sort_key.label(), + if app.wallet_sort_ascending { + '↑' + } else { + '↓' + } + ))) + .highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(">> "); + + f.render_stateful_widget(list, chunks[1], &mut app.wallet_list_state); +} + +fn render_block_details(f: &mut Frame, area: Rect, app: &mut App, idx: usize) { + let block = match app.blocks.get(idx) { + Some(b) => b, + None => { + let text = Paragraph::new("Block not found").block( + Block::default() + .borders(Borders::ALL) + .title("Block Details"), + ); + f.render_widget(text, area); + return; + } + }; + + let mut lines = vec![ + Line::from(vec![ + Span::styled("Height: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(block.height.to_string()), + ]), + Line::from(""), + Line::from(vec![Span::styled( + "Block ID: ", + Style::default().add_modifier(Modifier::BOLD), + )]), + Line::from(Span::styled( + hash_full_display(&block.block_id), + Style::default().fg(Color::Green), + )), + Line::from(""), + Line::from(vec![Span::styled( + "Parent ID: ", + Style::default().add_modifier(Modifier::BOLD), + )]), + Line::from(Span::styled( + hash_full_display(&block.parent), + Style::default().fg(Color::Yellow), + )), + Line::from(""), + Line::from(vec![ + Span::styled("Timestamp: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format_timestamp(block.timestamp)), + ]), + Line::from(""), + Line::from(vec![Span::styled( + format!("Transactions ({}): ", block.tx_ids.len()), + Style::default().add_modifier(Modifier::BOLD), + )]), + Line::from(""), + ]; + + if block.tx_ids.is_empty() { + lines.push(Line::from(Span::styled( + " (no transactions)", + Style::default().fg(Color::DarkGray), + ))); + let paragraph = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .title("Block Details (ESC to go back)"), + ) + .wrap(Wrap { trim: false }); + f.render_widget(paragraph, area); + return; + } + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(lines.len() as u16), Constraint::Min(5)]) + .split(area); + + let paragraph = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .title("Block Details (ESC to go back)"), + ) + .wrap(Wrap { trim: false }); + f.render_widget(paragraph, layout[0]); + + let tx_items: Vec = block + .tx_ids + .iter() + .enumerate() + .map(|(i, tx)| { + ListItem::new(Line::from(vec![ + Span::styled( + format!("[{:3}] ", i + 1), + Style::default().fg(Color::DarkGray), + ), + Span::styled(&tx.hash, Style::default().fg(Color::Cyan)), + ])) + }) + .collect(); + + let title = match app.block_focus { + BlockDetailsFocus::Transactions => "Transactions (Enter to inspect, c copies tx id)", + BlockDetailsFocus::Block => "Transactions (Tab to focus, Enter opens details)", + }; + + let list = List::new(tx_items) + .block(Block::default().borders(Borders::ALL).title(title)) + .highlight_style( + Style::default() + .bg(Color::DarkGray) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol(">> "); + f.render_stateful_widget(list, layout[1], &mut app.tx_list_state); +} + +fn render_transaction_details( + f: &mut Frame, + area: Rect, + app: &mut App, + block_idx: usize, + tx_idx: usize, +) { + let tx_state = match app.tx_detail.as_mut() { + Some(detail) => detail, + None => { + let paragraph = Paragraph::new("Transaction details not loaded") + .block(Block::default().borders(Borders::ALL).title("Transaction")); + f.render_widget(paragraph, area); + return; + } + }; + + let status_snapshot = tx_state.status.clone(); + + match status_snapshot { + TxDetailStatus::Confirmed(details) => { + let header_lines = build_tx_header_lines(tx_state, &details, block_idx, tx_idx); + let header_height = header_lines.len().saturating_add(2) as u16; + let header_constraint = + Constraint::Length(header_height.min(area.height.saturating_sub(6).max(6))); + + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([header_constraint, Constraint::Min(0)]) + .split(area); + + let header = Paragraph::new(header_lines) + .block( + Block::default() + .borders(Borders::ALL) + .title("Transaction Details"), + ) + .wrap(Wrap { trim: false }); + f.render_widget(header, layout[0]); + + let body = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(layout[1]); + + render_tx_inputs_section( + f, + body[0], + &details, + tx_state, + tx_state.pane_focus == TxDetailPane::Inputs, + ); + render_tx_outputs_section( + f, + body[1], + &details, + tx_state, + tx_state.pane_focus == TxDetailPane::Outputs, + ); + } + TxDetailStatus::Pending => render_tx_status_message( + f, + area, + tx_state, + block_idx, + tx_idx, + "Pending (not yet included in a block)", + Color::Yellow, + ), + TxDetailStatus::NotFound => render_tx_status_message( + f, + area, + tx_state, + block_idx, + tx_idx, + "Transaction not found on chain or in mempool", + Color::Red, + ), + TxDetailStatus::Error(err) => render_tx_status_message( + f, + area, + tx_state, + block_idx, + tx_idx, + &format!("Error: {}", err), + Color::Red, + ), + } +} + +fn build_tx_header_lines( + tx_state: &TxDetailState, + details: &RpcTransactionDetails, + block_idx: usize, + tx_idx: usize, +) -> Vec> { + let mut lines = Vec::new(); + lines.push(Line::from(vec![ + Span::styled( + "Transaction ID: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(tx_state.tx_id.clone()), + ])); + lines.push(Line::from(vec![ + Span::styled("Status: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled( + format!("Confirmed in block {}", details.height), + Style::default().fg(Color::Green), + ), + ])); + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "Block ID: ", + Style::default().add_modifier(Modifier::BOLD), + )])); + lines.push(Line::from(Span::styled( + hash_full_display(&details.block_id), + Style::default().fg(Color::Green), + ))); + lines.push(Line::from(vec![Span::styled( + "Parent: ", + Style::default().add_modifier(Modifier::BOLD), + )])); + lines.push(Line::from(Span::styled( + hash_full_display(&details.parent), + Style::default().fg(Color::Yellow), + ))); + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled("Height: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(details.height.to_string()), + Span::raw(" Timestamp: "), + Span::raw(format_timestamp(details.timestamp)), + ])); + lines.push(Line::from(vec![ + Span::styled("Version: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(details.version.to_string()), + Span::raw(" Size: "), + Span::raw(format!("{} bytes", format_number(details.size_bytes))), + ])); + lines.push(Line::from(vec![ + Span::styled("Totals: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format!( + "in {} | out {} | fee {}", + format_amount(details.total_input.as_ref()), + format_amount(details.total_output.as_ref()), + format_amount(details.fee.as_ref()) + )), + ])); + lines.push(Line::from("")); + lines.push(Line::from(vec![ + Span::styled( + "List position: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!("block {} • tx {}", block_idx, tx_idx)), + ])); + lines.push(Line::from(Span::styled( + "ESC: Back • TAB: Switch pane • c: Copy tx id", + Style::default().fg(Color::DarkGray), + ))); + lines +} + +fn render_tx_inputs_section( + f: &mut Frame, + area: Rect, + details: &RpcTransactionDetails, + tx_state: &mut TxDetailState, + active: bool, +) { + let lines = build_tx_input_lines(details); + let max_scroll = lines + .len() + .saturating_sub(area.height.saturating_sub(2) as usize); + let max_scroll_u16 = max_scroll.min(u16::MAX as usize) as u16; + tx_state.inputs_scroll = tx_state.inputs_scroll.min(max_scroll_u16); + + let mut block = Block::default().borders(Borders::ALL).title("Inputs"); + if active { + block = block.border_style(Style::default().fg(Color::Cyan)); + } + + let paragraph = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }) + .scroll((tx_state.inputs_scroll, 0)); + f.render_widget(paragraph, area); +} + +fn build_tx_input_lines(details: &RpcTransactionDetails) -> Vec> { + let mut lines = Vec::new(); + if details.inputs.is_empty() { + lines.push(Line::from("No inputs")); + } else { + for (idx, input) in details.inputs.iter().enumerate() { + lines.push(Line::from(vec![ + Span::styled( + format!("[{:02}] ", idx + 1), + Style::default().fg(Color::DarkGray), + ), + Span::styled( + format!("{} ", format_amount(input.amount.as_ref())), + Style::default().fg(Color::Yellow), + ), + Span::styled( + input.note_name_b58.clone(), + Style::default().fg(Color::Cyan), + ), + ])); + let mut source_desc = format!("source: {}", short_hash_str(&input.source_tx_id)); + if input.coinbase { + source_desc.push_str(" (coinbase)"); + } + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(source_desc, Style::default().fg(Color::DarkGray)), + ])); + lines.push(Line::from("")); + } + } + lines +} + +fn render_tx_outputs_section( + f: &mut Frame, + area: Rect, + details: &RpcTransactionDetails, + tx_state: &mut TxDetailState, + active: bool, +) { + let lines = build_tx_output_lines(details); + let max_scroll = lines + .len() + .saturating_sub(area.height.saturating_sub(2) as usize); + let max_scroll_u16 = max_scroll.min(u16::MAX as usize) as u16; + tx_state.outputs_scroll = tx_state.outputs_scroll.min(max_scroll_u16); + + let mut block = Block::default().borders(Borders::ALL).title("Outputs"); + if active { + block = block.border_style(Style::default().fg(Color::Cyan)); + } + + let paragraph = Paragraph::new(lines) + .block(block) + .wrap(Wrap { trim: false }) + .scroll((tx_state.outputs_scroll, 0)); + f.render_widget(paragraph, area); +} + +fn build_tx_output_lines(details: &RpcTransactionDetails) -> Vec> { + let mut lines = Vec::new(); + if details.outputs.is_empty() { + lines.push(Line::from("No outputs")); + } else { + for (idx, output) in details.outputs.iter().enumerate() { + lines.push(Line::from(vec![ + Span::styled( + format!("[{:02}] ", idx + 1), + Style::default().fg(Color::DarkGray), + ), + Span::styled( + format!("{} ", format_amount(output.amount.as_ref())), + Style::default().fg(Color::LightGreen), + ), + Span::styled( + output.note_name_b58.clone(), + Style::default().fg(Color::Cyan), + ), + ])); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("lock: {}", output.lock_summary.clone()), + Style::default().fg(Color::Magenta), + ), + ])); + lines.push(Line::from("")); + } + } + lines +} + +fn render_tx_status_message( + f: &mut Frame, + area: Rect, + tx_state: &TxDetailState, + block_idx: usize, + tx_idx: usize, + message: &str, + color: Color, +) { + let lines = vec![ + Line::from(vec![ + Span::styled( + "Transaction ID: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(tx_state.tx_id.clone()), + ]), + Line::from(""), + Line::from(vec![ + Span::styled("Status: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled(message, Style::default().fg(color)), + ]), + Line::from(""), + Line::from(vec![ + Span::styled( + "List position: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!("block {} • tx {}", block_idx, tx_idx)), + ]), + Line::from(""), + Line::from(Span::styled( + "ESC: Back to block • c: Copy tx id", + Style::default().fg(Color::DarkGray), + )), + ]; + + let paragraph = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .title("Transaction Details"), + ) + .wrap(Wrap { trim: false }); + + f.render_widget(paragraph, area); +} + +fn format_amount(amount: Option<&pb_common::Nicks>) -> String { + let value = amount.map(|n| n.value).unwrap_or(0); + let nock = value as f64 / NICKS_PER_NOCK as f64; + format!( + "{} nicks ({})", + format_number(value), + format_nock_value(nock) + ) +} + +fn format_wallet_nocks(value: u64) -> String { + let nock = value as f64 / NICKS_PER_NOCK as f64; + format!("{} nock", format_nock_value(nock)) +} + +fn format_nock_value(value: f64) -> String { + let mut s = format!("{:.6}", value); + while s.contains('.') && s.ends_with('0') { + s.pop(); + } + if s.ends_with('.') { + s.pop(); + } + s +} + +fn short_hash_str(text: &str) -> String { + if text.len() <= 16 { + text.to_string() + } else { + format!("{}...{}", &text[..8], &text[text.len() - 6..]) + } +} + +fn format_number(value: u64) -> String { + let s = value.to_string(); + let mut acc = String::with_capacity(s.len() + s.len() / 3); + let mut count = 0; + for ch in s.chars().rev() { + if count > 0 && count % 3 == 0 { + acc.push('_'); + } + acc.push(ch); + count += 1; + } + acc.chars().rev().collect() +} + +fn render_transaction_search(f: &mut Frame, area: Rect, app: &App) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Length(3), Constraint::Min(0)]) + .split(area); + + // Input box + let input = Paragraph::new(app.tx_search_input.as_str()) + .style(Style::default().fg(Color::Yellow)) + .block( + Block::default() + .borders(Borders::ALL) + .title("Transaction ID prefix (base58, Enter to search)"), + ); + f.render_widget(input, chunks[0]); + + // Results + let result_text = match &app.tx_search_result { + Some(TxSearchResult::Found(block_data)) => { + vec![ + Line::from(vec![ + Span::styled("Status: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled( + "CONFIRMED", + Style::default() + .fg(Color::Green) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(""), + Line::from(vec![ + Span::styled( + "Block Height: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(block_data.height.to_string()), + ]), + Line::from(""), + Line::from(vec![Span::styled( + "Block ID: ", + Style::default().add_modifier(Modifier::BOLD), + )]), + Line::from(Span::styled( + hash_full_display(&block_data.block_id), + Style::default().fg(Color::Green), + )), + Line::from(""), + Line::from(vec![Span::styled( + "Parent ID: ", + Style::default().add_modifier(Modifier::BOLD), + )]), + Line::from(Span::styled( + hash_full_display(&block_data.parent), + Style::default().fg(Color::Yellow), + )), + Line::from(""), + Line::from(vec![ + Span::styled("Timestamp: ", Style::default().add_modifier(Modifier::BOLD)), + Span::raw(format_timestamp(block_data.timestamp)), + ]), + ] + } + Some(TxSearchResult::Pending) => { + vec![ + Line::from(vec![ + Span::styled("Status: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled( + "PENDING", + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(""), + Line::from("Transaction exists in mempool but not yet in a block."), + ] + } + Some(TxSearchResult::NotFound) => { + vec![ + Line::from(vec![ + Span::styled("Status: ", Style::default().add_modifier(Modifier::BOLD)), + Span::styled( + "NOT FOUND", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + ]), + Line::from(""), + Line::from("Transaction does not exist in blockchain or mempool."), + ] + } + Some(TxSearchResult::Error(err)) => { + vec![ + Line::from(vec![Span::styled( + "Error: ", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + )]), + Line::from(""), + Line::from(Span::raw(err)), + ] + } + None => { + vec![Line::from(Span::styled( + "Enter a transaction ID prefix and press Enter (Ctrl+V or Shift+Insert pastes)", + Style::default().fg(Color::DarkGray), + ))] + } + }; + + let results = Paragraph::new(result_text) + .block(Block::default().borders(Borders::ALL).title("Results")) + .wrap(Wrap { trim: false }); + f.render_widget(results, chunks[1]); +} + +fn render_status_bar(f: &mut Frame, area: Rect, app: &App) { + let status_text = if let Some(err) = &app.error_message { + vec![Line::from(vec![ + Span::styled( + "Error: ", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + Span::styled(err, Style::default().fg(Color::Red)), + ])] + } else if !matches!(app.connection_status, ConnectionStatus::Connected) { + // Show connection error when not connected + let mut lines = vec![Line::from(vec![ + Span::styled( + "Disconnected: ", + Style::default().fg(Color::Red).add_modifier(Modifier::BOLD), + ), + Span::styled( + app.last_connection_error + .as_deref() + .unwrap_or("Unknown error"), + Style::default().fg(Color::Red), + ), + ])]; + + if let Some(last_success) = app.last_successful_connection { + lines.push(Line::from(vec![ + Span::raw("Last successful connection: "), + Span::styled( + format_duration(last_success.elapsed()), + Style::default().fg(Color::Yellow), + ), + Span::raw(" ago"), + ])); + } + + lines + } else if app.loading { + vec![Line::from(Span::styled( + "Loading...", + Style::default().fg(Color::Yellow), + ))] + } else if let Some(msg) = &app.status_message { + vec![Line::from(vec![ + Span::styled( + "Status: ", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::styled(msg, Style::default().fg(Color::Cyan)), + ])] + } else { + let age = app.last_refresh.elapsed().as_secs(); + let refresh_status = if app.auto_refresh_enabled { + if matches!(app.view, View::BlocksList) && app.viewing_tip() { + format!("Auto-refresh: ON (last: {}s ago)", age) + } else if matches!(app.view, View::BlocksList) { + format!( + "Auto-refresh: PAUSED (scroll to newest block to resume, last: {}s ago)", + age + ) + } else { + format!( + "Auto-refresh: PAUSED (viewing other tab, last: {}s ago)", + age + ) + } + } else { + format!("Auto-refresh: OFF (last: {}s ago)", age) + }; + let mut spans = vec![ + Span::styled("Ready", Style::default().fg(Color::Green)), + Span::raw(" | "), + Span::raw(refresh_status), + ]; + if app.has_more_pages { + spans.push(Span::raw(" | ")); + spans.push(Span::styled( + "More pages available", + Style::default().fg(Color::Cyan), + )); + } + if let Some(msg) = &app.wallet_index_message { + spans.push(Span::raw(" | ")); + spans.push(Span::styled( + format!("Wallets: {}", msg), + Style::default().fg(Color::Magenta), + )); + } + vec![Line::from(spans)] + }; + + let status = + Paragraph::new(status_text).block(Block::default().borders(Borders::ALL).title("Status")); + f.render_widget(status, area); +} + +fn format_duration(duration: Duration) -> String { + let secs = duration.as_secs(); + if secs < 60 { + format!("{}s", secs) + } else if secs < 3600 { + format!("{}m {}s", secs / 60, secs % 60) + } else { + format!("{}h {}m", secs / 3600, (secs % 3600) / 60) + } +} + +fn render_help(f: &mut Frame, area: Rect, app: &App) { + let help_text = match &app.view { + View::BlocksList => { + "↑: Up | ↓: Down (auto-loads older blocks) | PgUp/PgDn: Jump | Enter: View block | c: Copy block id | t: Search TX | r: Refresh | a: Auto-refresh | n: Next Page | s: Sync all pages | Tab/Shift+Tab: Switch tabs | ?: Help | q: Quit" + } + View::TransactionsList => { + "↑: Up | ↓: Down | PgUp/PgDn: Jump | Home/End: First/last | Enter: View tx | Esc: Back to blocks | Tab/Shift+Tab: Switch tabs | n/p: Next/Prev tx | s: Sync all pages | ?: Help | q: Quit" + } + View::WalletsList => { + "↑: Up | ↓: Down | PgUp/PgDn: Jump | Home/End: First/last | b/r/e/t: Sort balance/recv/sent/tx | o: Toggle order | Tab/Shift+Tab: Switch tabs | s: Sync all pages | ?: Help | q: Quit" + } + View::BlockDetails(_) => { + "ESC: Back | PgUp/PgDn: Prev/next block | Tab: Toggle tx focus | ↑/↓ (tx focus): Move selection | Enter: TX details | c: Copy tx id (tx focus) | n/p: Next/Prev tx | ?: Help | q: Quit" + } + View::TransactionDetails { .. } => "ESC: Back | Tab: Switch pane | ↑/↓/PgUp/PgDn/Home/End: Scroll pane | n/p: Next/Prev tx | c: Copy tx id | ?: Help | q: Quit", + View::TransactionSearch => { + "ESC: Back | Enter: Search (prefix ok) | Ctrl+V/Ctrl+Shift+V: Paste | Ctrl+C: Clear | ?: Help | q: Quit" + } + View::Help => "ESC/q/?: Close help", + }; + + let help = Paragraph::new(help_text).style(Style::default().fg(Color::DarkGray)); + f.render_widget(help, area); +} + +fn hash_display(hash: &Option) -> String { + hash.as_ref() + .map(|h| { + let full = hash_to_string(h); + if full.len() > 16 { + format!("{}...{}", &full[..8], &full[full.len() - 8..]) + } else { + full + } + }) + .unwrap_or_else(|| "(none)".to_string()) +} + +fn hash_full_display(hash: &Option) -> String { + hash.as_ref() + .map(hash_to_string) + .unwrap_or_else(|| "(none)".to_string()) +} + +fn hash_option_to_base58(hash: &Option) -> Option { + hash.as_ref() + .and_then(|h| proto_hash_to_tip5(h)) + .map(|h| h.to_base58()) +} + +fn proto_hash_to_tip5(hash: &pb_common::Hash) -> Option { + Some(Tip5Hash([ + Belt(hash.belt_1.as_ref()?.value), + Belt(hash.belt_2.as_ref()?.value), + Belt(hash.belt_3.as_ref()?.value), + Belt(hash.belt_4.as_ref()?.value), + Belt(hash.belt_5.as_ref()?.value), + ])) +} + +fn hash_to_string(hash: &nockapp_grpc_proto::pb::common::v1::Hash) -> String { + format!( + "{:016x}.{:016x}.{:016x}.{:016x}.{:016x}", + hash.belt_1.as_ref().map(|b| b.value).unwrap_or(0), + hash.belt_2.as_ref().map(|b| b.value).unwrap_or(0), + hash.belt_3.as_ref().map(|b| b.value).unwrap_or(0), + hash.belt_4.as_ref().map(|b| b.value).unwrap_or(0), + hash.belt_5.as_ref().map(|b| b.value).unwrap_or(0), + ) +} + +fn format_timestamp(raw_ts: u64) -> String { + // Blocks encode timestamps using `time-in-secs` on an Urbit `@da`, which means we need to + // subtract the Urbit base epoch (2^63 plus an offset) to get back to real Unix seconds. + const BASE_URBIT_EPOCH: u64 = 0x8000_000c_ce9e_0d80; + + let unix_secs = match raw_ts.checked_sub(BASE_URBIT_EPOCH) { + Some(secs) => secs as i64, + None => return format!("{} (before Urbit epoch base)", raw_ts), + }; + + match Utc.timestamp_opt(unix_secs, 0).single() { + Some(dt) => { + let age = Utc::now().signed_duration_since(dt); + format!( + "{} UTC ({})", + dt.format("%Y-%m-%d %H:%M:%S"), + format_relative_duration(age) + ) + } + None => format!("{} (invalid timestamp)", raw_ts), + } +} + +fn format_relative_duration(duration: ChronoDuration) -> String { + let secs = duration.num_seconds(); + if secs == 0 { + return "just now".to_string(); + } + + let suffix = if secs >= 0 { "ago" } else { "from now" }; + let mut remaining = secs.abs(); + + let mut parts = Vec::new(); + let days = remaining / 86_400; + if days > 0 { + parts.push(format!("{}d", days)); + remaining %= 86_400; + } + let hours = remaining / 3_600; + if hours > 0 && parts.len() < 2 { + parts.push(format!("{}h", hours)); + remaining %= 3_600; + } + let minutes = remaining / 60; + if minutes > 0 && parts.len() < 2 { + parts.push(format!("{}m", minutes)); + remaining %= 60; + } + if remaining > 0 && parts.len() < 2 { + parts.push(format!("{}s", remaining)); + } + + if parts.is_empty() { + format!("less than 1s {}", suffix) + } else { + format!("{} {}", parts.join(" "), suffix) + } +} + +struct TerminalGuard { + terminal: Terminal>, +} + +impl TerminalGuard { + fn new() -> Result { + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + terminal.clear()?; + Ok(Self { terminal }) + } + + fn terminal(&mut self) -> &mut Terminal> { + &mut self.terminal + } +} + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = execute!( + self.terminal.backend_mut(), + LeaveAlternateScreen, + DisableMouseCapture + ); + let _ = self.terminal.show_cursor(); + } +} + +#[tracing::instrument(name = "tui.run_app", skip(terminal, app))] +async fn run_app( + terminal: &mut Terminal>, + mut app: App, +) -> Result<()> { + let tick_rate = Duration::from_millis(250); + let auto_refresh_interval = Duration::from_secs(10); + let mut last_tick = Instant::now(); + + loop { + terminal.draw(|f| ui(f, &mut app))?; + app.poll_wallet_worker(); + + let timeout = tick_rate + .checked_sub(last_tick.elapsed()) + .unwrap_or_else(|| Duration::from_secs(0)); + + if crossterm::event::poll(timeout)? { + match event::read()? { + Event::Key(key) => { + app.clear_status_if_needed(); + app.note_user_action(); + match &app.view { + View::BlocksList => match key.code { + KeyCode::Char('q') => return Ok(()), + KeyCode::Down => { + app.move_selection_down(); + if app.should_auto_fetch_more() { + app.load_next_page().await?; + } + } + KeyCode::Up => { + app.move_selection_up(); + } + KeyCode::PageDown => { + app.page_down(); + if app.should_auto_fetch_more() { + app.load_next_page().await?; + } + } + KeyCode::PageUp => { + app.page_up(); + } + KeyCode::Home => { + app.select_first_block(); + } + KeyCode::End => { + app.select_last_block(); + } + KeyCode::Char('c') => { + app.copy_selected_block_id(); + } + KeyCode::Char('?') => app.open_help(), + KeyCode::Enter => { + if let Some(idx) = app.list_state.selected() { + app.set_view(View::BlockDetails(idx)); + app.block_focus = if app + .blocks + .get(idx) + .map(|b| b.tx_ids.is_empty()) + .unwrap_or(true) + { + BlockDetailsFocus::Block + } else { + BlockDetailsFocus::Transactions + }; + app.sync_tx_list_selection(); + } + } + KeyCode::Char('t') => { + app.set_view(View::TransactionSearch); + app.tx_search_input.clear(); + app.tx_search_result = None; + } + KeyCode::Char('r') => { + app.refresh().await?; + } + KeyCode::Char('a') => { + app.auto_refresh_enabled = !app.auto_refresh_enabled; + } + KeyCode::Char('n') => { + if app.has_more_pages { + app.load_next_page().await?; + } + } + KeyCode::Char('s') => { + app.sync_all_blocks().await?; + } + KeyCode::Tab => app.cycle_tabs(1), + KeyCode::BackTab => app.cycle_tabs(-1), + _ => {} + }, + View::TransactionsList => match key.code { + KeyCode::Char('q') => return Ok(()), + KeyCode::Esc => { + app.set_view(View::BlocksList); + } + KeyCode::Tab => app.cycle_tabs(1), + KeyCode::BackTab => app.cycle_tabs(-1), + KeyCode::Down => { + app.move_transaction_list_selection(1); + } + KeyCode::Up => { + app.move_transaction_list_selection(-1); + } + KeyCode::PageDown => { + app.page_transaction_list_selection(1); + } + KeyCode::PageUp => { + app.page_transaction_list_selection(-1); + } + KeyCode::Home => { + app.select_first_transaction(); + } + KeyCode::End => { + app.select_last_transaction(); + } + KeyCode::Enter => { + if let Some(idx) = app.tx_overview_state.selected() { + app.open_transaction_from_global_index(idx).await?; + } + } + KeyCode::Char('n') => { + app.navigate_transaction_delta(1).await?; + } + KeyCode::Char('p') => { + app.navigate_transaction_delta(-1).await?; + } + KeyCode::Char('s') => { + app.sync_all_blocks().await?; + } + KeyCode::Char('?') => app.open_help(), + _ => {} + }, + View::WalletsList => match key.code { + KeyCode::Char('q') => return Ok(()), + KeyCode::Esc => app.set_view(View::BlocksList), + KeyCode::Tab => app.cycle_tabs(1), + KeyCode::BackTab => app.cycle_tabs(-1), + KeyCode::Down => app.move_wallet_selection(1), + KeyCode::Up => app.move_wallet_selection(-1), + KeyCode::PageDown => app.page_wallet_selection(1), + KeyCode::PageUp => app.page_wallet_selection(-1), + KeyCode::Home => { + app.select_first_wallet(); + } + KeyCode::End => { + app.select_last_wallet(); + } + KeyCode::Char('b') => app.set_wallet_sort_key(WalletSortKey::Balance), + KeyCode::Char('r') => { + app.set_wallet_sort_key(WalletSortKey::TotalReceived) + } + KeyCode::Char('e') => app.set_wallet_sort_key(WalletSortKey::TotalSent), + KeyCode::Char('t') => app.set_wallet_sort_key(WalletSortKey::TxCount), + KeyCode::Char('o') => app.toggle_wallet_sort_order(), + KeyCode::Char('s') => { + app.sync_all_blocks().await?; + } + KeyCode::Char('?') => app.open_help(), + _ => {} + }, + View::BlockDetails(_) => match key.code { + KeyCode::Char('q') => return Ok(()), + KeyCode::Esc => { + app.set_view(View::BlocksList); + app.block_focus = BlockDetailsFocus::Block; + } + KeyCode::Tab => { + if matches!(app.block_focus, BlockDetailsFocus::Block) + && app.selected_tx_id().is_some() + { + app.block_focus = BlockDetailsFocus::Transactions; + } else { + app.block_focus = BlockDetailsFocus::Block; + } + } + KeyCode::Home => { + if matches!(app.block_focus, BlockDetailsFocus::Transactions) { + app.select_first_tx(); + } else if let Some(idx) = app.select_first_block() { + app.set_view(View::BlockDetails(idx)); + app.sync_tx_list_selection(); + } + } + KeyCode::End => { + if matches!(app.block_focus, BlockDetailsFocus::Transactions) { + app.select_last_tx(); + } else if let Some(idx) = app.select_last_block() { + app.set_view(View::BlockDetails(idx)); + app.sync_tx_list_selection(); + } + } + KeyCode::Char('n') => { + app.navigate_transaction_delta(1).await?; + } + KeyCode::Char('p') => { + app.navigate_transaction_delta(-1).await?; + } + KeyCode::Char('c') => { + if let Some(tx_id) = app.selected_tx_id() { + app.copy_tx_id(&tx_id); + } + } + KeyCode::Char('?') => app.open_help(), + KeyCode::Enter => { + if matches!(app.block_focus, BlockDetailsFocus::Transactions) { + if let (Some(block_idx), Some(tx_idx)) = + (app.list_state.selected(), app.selected_tx_index()) + { + app.open_transaction_detail(block_idx, tx_idx).await?; + } + } + } + KeyCode::Down => { + if app.block_focus == BlockDetailsFocus::Transactions { + app.move_tx_selection(1); + } else if let Some(idx) = app.move_selection_down() { + app.set_view(View::BlockDetails(idx)); + app.sync_tx_list_selection(); + if app.should_auto_fetch_more() { + app.load_next_page().await?; + } + } + } + KeyCode::Up => { + if app.block_focus == BlockDetailsFocus::Transactions { + app.move_tx_selection(-1); + } else if let Some(idx) = app.move_selection_up() { + app.set_view(View::BlockDetails(idx)); + app.sync_tx_list_selection(); + } + } + KeyCode::PageDown => { + if let Some(idx) = app.page_down() { + app.set_view(View::BlockDetails(idx)); + app.sync_tx_list_selection(); + if app.should_auto_fetch_more() { + app.load_next_page().await?; + } + } + } + KeyCode::PageUp => { + if let Some(idx) = app.page_up() { + app.set_view(View::BlockDetails(idx)); + app.sync_tx_list_selection(); + } + } + _ => {} + }, + View::TransactionDetails { block_idx, .. } => match key.code { + KeyCode::Char('q') => return Ok(()), + KeyCode::Esc => { + app.set_view(View::BlockDetails(*block_idx)); + app.block_focus = BlockDetailsFocus::Transactions; + app.sync_tx_list_selection(); + } + KeyCode::Char('c') => { + if let Some(detail) = app.tx_detail.clone() { + app.copy_tx_id(&detail.tx_id); + } + } + KeyCode::Char('?') => app.open_help(), + KeyCode::Tab => { + app.cycle_tx_detail_focus(); + } + KeyCode::Down => { + app.adjust_tx_pane_scroll(1); + } + KeyCode::Up => { + app.adjust_tx_pane_scroll(-1); + } + KeyCode::PageDown => { + app.page_tx_pane_scroll(1); + } + KeyCode::PageUp => { + app.page_tx_pane_scroll(-1); + } + KeyCode::Home => { + app.home_tx_pane(); + } + KeyCode::End => { + app.end_tx_pane(); + } + KeyCode::Char('n') => { + app.navigate_transaction_delta(1).await?; + } + KeyCode::Char('p') => { + app.navigate_transaction_delta(-1).await?; + } + _ => {} + }, + View::TransactionSearch => match key.code { + KeyCode::Char('q') => return Ok(()), + KeyCode::Esc => app.set_view(View::BlocksList), + KeyCode::Char('?') => app.open_help(), + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { + app.tx_search_input.clear(); + app.tx_search_result = None; + } + KeyCode::Char('v') if key.modifiers.contains(KeyModifiers::CONTROL) => { + if let Some(text) = app.read_clipboard_text() { + app.tx_search_input.push_str(&text); + } + } + KeyCode::Enter => { + if !app.tx_search_input.is_empty() { + let search_input = app.tx_search_input.clone(); + app.search_transaction(&search_input).await?; + } + } + KeyCode::Backspace => { + app.tx_search_input.pop(); + } + KeyCode::Char(c) => { + app.tx_search_input.push(c); + } + _ => {} + }, + View::Help => match key.code { + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') => { + app.close_help() + } + KeyCode::Down => app.scroll_help(1), + KeyCode::Up => app.scroll_help(-1), + KeyCode::PageDown => app.scroll_help(PAGE_JUMP as i32), + KeyCode::PageUp => app.scroll_help(-(PAGE_JUMP as i32)), + KeyCode::Home => app.reset_help_scroll(), + KeyCode::End => app.end_help_scroll(), + _ => {} + }, + } + } + Event::Paste(data) => { + app.clear_status_if_needed(); + app.note_user_action(); + if matches!(app.view, View::TransactionSearch) { + app.tx_search_input.push_str(&data); + } + } + _ => {} + } + } + + if last_tick.elapsed() >= tick_rate { + // Auto-reconnect if disconnected + if app.should_retry_connection() { + let _ = app.attempt_reconnect().await; // Don't fail on reconnect error + } + + // Auto-refresh blocks if connected + if app.should_auto_refresh_blocks(auto_refresh_interval) { + let _ = app.refresh().await; + } + last_tick = Instant::now(); + } + } +} + +fn init_tracing() -> Result<()> { + let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); + let fmt_layer = fmt::layer().with_target(false); + let registry = tracing_subscriber::registry() + .with(env_filter) + .with(fmt_layer); + let enable_tracy = env::var("TRACY_ENABLE").map(|v| v != "0").unwrap_or(false); + + if enable_tracy { + registry + .with(TracyLayer::default()) + .try_init() + .map_err(|e| anyhow!(e.to_string()))?; + info!("Tracing initialized with Tracy layer"); + } else { + registry.try_init().map_err(|e| anyhow!(e.to_string()))?; + } + Ok(()) +} + +#[tokio::main] +async fn main() -> Result<()> { + init_tracing()?; + // Parse CLI args + let args = Args::parse(); + + // Establish connection before touching the terminal so connection failures print normally. + let app = App::new(args.server, args.fail_fast).await?; + + // Setup terminal with drop guard so panics/errors restore the TTY. + let mut terminal = TerminalGuard::new()?; + + // Run app + run_app(terminal.terminal(), app).await +} + +#[tracing::instrument( + name = "tui.wallet_index_worker", + skip(command_rx, result_tx), + fields(server = %server_uri) +)] +async fn wallet_index_worker( + server_uri: String, + mut command_rx: UnboundedReceiver, + result_tx: UnboundedSender, +) { + let mut client: Option> = None; + while let Some(command) = command_rx.recv().await { + let WalletWorkerCommand::IndexTransactions { + tasks, + range_start, + range_end, + } = command; + if tasks.is_empty() { + continue; + } + + let mut completed = Vec::new(); + let mut delta_map: HashMap = HashMap::new(); + for task in tasks { + if client.is_none() { + match NockchainBlockServiceClient::connect(server_uri.clone()).await { + Ok(new_client) => { + client = Some(new_client); + let _ = result_tx.send(WalletWorkerResult::Status(format!( + "Wallet indexer connected to {}", + server_uri + ))); + } + Err(e) => { + let _ = result_tx.send(WalletWorkerResult::Error { + tx_ids: vec![task.tx_id], + message: format!("Wallet indexer connect error: {}", e), + }); + continue; + } + } + } + + let request = GetTransactionDetailsRequest { + tx_id: Some(Base58Hash { + hash: task.tx_id.clone(), + }), + }; + + let fetch_result = client + .as_mut() + .unwrap() + .get_transaction_details(Request::new(request)) + .await; + + match fetch_result { + Ok(response) => match response.into_inner().result { + Some(get_transaction_details_response::Result::Details(details)) => { + accumulate_wallet_delta(&mut delta_map, &details); + completed.push(task.tx_id); + } + Some(get_transaction_details_response::Result::Pending(_)) => { + let _ = result_tx.send(WalletWorkerResult::Error { + tx_ids: vec![task.tx_id], + message: "Transaction pending confirmation".into(), + }); + } + Some(get_transaction_details_response::Result::Error(err)) => { + let _ = result_tx.send(WalletWorkerResult::Error { + tx_ids: vec![task.tx_id], + message: err.message, + }); + } + None => { + let _ = result_tx.send(WalletWorkerResult::Error { + tx_ids: vec![task.tx_id], + message: "Empty response from block service".into(), + }); + } + }, + Err(e) => { + let _ = result_tx.send(WalletWorkerResult::Error { + tx_ids: vec![task.tx_id], + message: format!("Wallet indexer RPC error: {}", e), + }); + client = None; + } + } + } + + if !completed.is_empty() { + let deltas = delta_map + .into_iter() + .map(|(address, tally)| WalletDelta { + address, + received: tally.total_received, + sent: tally.total_sent, + tx_count: tally.tx_count, + }) + .collect(); + let _ = result_tx.send(WalletWorkerResult::ChunkComplete { + tx_ids: completed, + deltas, + range_start, + range_end, + }); + } + } +} + +fn accumulate_wallet_delta( + map: &mut HashMap, + details: &RpcTransactionDetails, +) { + let mut touched = HashSet::new(); + for input in &details.inputs { + if let Some(address) = normalize_wallet_label(&input.note_name_b58) { + let entry = map.entry(address.clone()).or_default(); + entry.total_sent = entry + .total_sent + .saturating_add(input.amount.as_ref().map(|n| n.value).unwrap_or(0)); + touched.insert(address); + } + } + for output in &details.outputs { + if let Some(address) = normalize_wallet_label(&output.note_name_b58) { + let entry = map.entry(address.clone()).or_default(); + entry.total_received = entry + .total_received + .saturating_add(output.amount.as_ref().map(|n| n.value).unwrap_or(0)); + touched.insert(address); + } + } + for address in touched { + if let Some(entry) = map.get_mut(&address) { + entry.tx_count += 1; + } + } +} + +fn normalize_wallet_label(label: &str) -> Option { + let trimmed = label.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +fn render_help_menu(f: &mut Frame, area: Rect, app: &mut App) { + let sections = vec![ + ( + "Connection Status", + vec![ + "● Connected Server is reachable", + "● Disconnected Lost connection (will auto-retry every 5s)", + "● Never Connected Still trying initial connection", + "● Reconnecting Attempting to reconnect now", + ], + ), + ( + "Blocks List", + vec![ + "↑ Move selection up", + "↓ Move selection down (auto-loads older blocks)", + "PgUp Jump up by 20 blocks", + "PgDn Jump down by 20 blocks (auto-loads older)", + "Enter View selected block", "c Copy selected block id", + "Tab/Shift+Tab Switch between tabs", "t Open transaction search", + "r Refresh newest page", "a Toggle auto-refresh", + "n Fetch next page now", "s Sync all pages", + "? Show this help", "q Quit", + ], + ), + ( + "Transactions List", + vec![ + "ESC Return to blocks", "Tab/Shift+Tab Switch tabs", + "↑/↓ Move selection", "PgUp/PgDn Jump by 20 transactions", + "Home/End First/last transaction", "Enter View transaction details", + "n / p Next/prev transaction detail", "s Sync all pages", + "? Show this help", + ], + ), + ( + "Wallets View", + vec![ + "↑/↓ Move selection", "PgUp/PgDn Jump by 20 wallets", + "Home/End Jump to first/last wallet", "b/r/e/t Sort balance/recv/sent/tx", + "o Toggle sort order", "Tab/Shift+Tab Switch tabs", + "s Sync all pages", "? Show this help", + ], + ), + ( + "Block Details", + vec![ + "ESC Back to list", "PgUp/PgDn Jump to prev/next block", + "Tab Focus/unfocus transaction list", "↑/↓ (tx) Move tx selection", + "Enter View selected transaction", "n / p Next/prev transaction", + "c Copy highlighted tx id (tx focus)", "? Show this help", + "q Quit", + ], + ), + ( + "Transaction Search", + vec![ + "ESC Back to list", "Enter Search for TX (prefix ok)", + "Ctrl+V/Ctrl+Shift+V Paste clipboard", "Ctrl+C Clear input", + "? Show this help", "q Quit", + ], + ), + ( + "Transaction Details", + vec![ + "ESC Back", "Tab Switch pane", "↑/↓/PgUp/PgDn Scroll inputs/outputs", + "Home/End Jump to start/end", "n / p Next/prev transaction", + "c Copy transaction id", "? Show this help", "q Quit", + ], + ), + ( + "Global", + vec![ + "n / p Next/prev transaction (details/list/block views)", + "Tab/Shift+Tab Switch top-level tabs", + ], + ), + ]; + + let mut lines: Vec = Vec::new(); + lines.push(Line::from(Span::styled( + "Keyboard Shortcuts", + Style::default().add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from("")); + + for (title, entries) in sections { + lines.push(Line::from(Span::styled( + title, + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))); + for entry in entries { + lines.push(Line::from(Span::raw(format!(" {}", entry)))); + } + lines.push(Line::from("")); + } + + lines.push(Line::from(Span::styled( + "Blocks you've already fetched stay cached locally; scrolling never re-downloads them.", + Style::default().fg(Color::DarkGray), + ))); + lines.push(Line::from(Span::styled( + "Press ESC, q, or ? to close this help.", + Style::default().fg(Color::DarkGray), + ))); + + let content_height = area.height.saturating_sub(2) as usize; + let max_scroll = lines + .len() + .saturating_sub(content_height) + .min(u16::MAX as usize) as u16; + if app.help_scroll > max_scroll { + app.help_scroll = max_scroll; + } + app.help_max_scroll = max_scroll; + let has_above = app.help_scroll > 0; + let has_below = app.help_scroll < max_scroll; + let mut title = String::from("Help"); + if has_above { + title.push('▲'); + } + if has_below { + title.push('▼'); + } + + let paragraph = Paragraph::new(lines) + .block( + Block::default() + .borders(Borders::ALL) + .title(title) + .title_alignment(Alignment::Center), + ) + .wrap(Wrap { trim: false }) + .scroll((app.help_scroll, 0)); + + f.render_widget(paragraph, area); +} diff --git a/crates/noun-serde-derive/Cargo.toml b/crates/noun-serde-derive/Cargo.toml index 30149d744..b556aeeca 100644 --- a/crates/noun-serde-derive/Cargo.toml +++ b/crates/noun-serde-derive/Cargo.toml @@ -7,6 +7,7 @@ edition = "2021" proc-macro = true [dependencies] +tracing.workspace = true nockvm = { workspace = true } proc-macro2 = { workspace = true } diff --git a/crates/noun-serde-derive/src/lib.rs b/crates/noun-serde-derive/src/lib.rs index c51909524..f4d1c0395 100644 --- a/crates/noun-serde-derive/src/lib.rs +++ b/crates/noun-serde-derive/src/lib.rs @@ -415,7 +415,8 @@ pub fn derive_noun_encode(input: TokenStream) -> TokenStream { #[proc_macro_derive(NounDecode, attributes(noun))] pub fn derive_noun_decode(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); - let name = input.ident; + let name = input.ident.clone(); + let name_str = name.to_string(); // Get enum-level tagged attribute let enum_tagged = parse_tagged_attr(&input.attrs); @@ -435,14 +436,23 @@ pub fn derive_noun_decode(input: TokenStream) -> TokenStream { if fields.named.is_empty() { quote! { + ::tracing::trace!(target: "noun_serde_decode", "Decoding {} (empty struct)", #name_str); Ok(Self {}) } } else if fields.named.len() == 1 { // Single field: decode directly from noun let field_name = &field_names[0]; + let field_name_str = field_name.to_string(); let field_type = &field_types[0]; quote! { - let #field_name = <#field_type as ::noun_serde::NounDecode>::from_noun(noun)?; + ::tracing::trace!(target: "noun_serde_decode", "Decoding {} (single field struct), is_atom={}, is_cell={}", #name_str, noun.is_atom(), noun.is_cell()); + ::tracing::trace!(target: "noun_serde_decode", " field={} type={}", #field_name_str, stringify!(#field_type)); + let #field_name = <#field_type as ::noun_serde::NounDecode>::from_noun(noun) + .map_err(|e| { + ::tracing::trace!(target: "noun_serde_decode", " FAILED decoding field {} in {}: {:?}", #field_name_str, #name_str, e); + e + })?; + ::tracing::trace!(target: "noun_serde_decode", " SUCCESS decoded field {} in {}", #field_name_str, #name_str); Ok(Self { #field_name }) } } else { @@ -491,14 +501,32 @@ pub fn derive_noun_decode(input: TokenStream) -> TokenStream { }; let axis = custom_axis.unwrap_or(default_axis); + let field_name_str = name.to_string(); quote! { - let #name = <#ty as ::noun_serde::NounDecode>::from_noun(&::nockvm::noun::Slots::slot(&cell, #axis)?)?; + ::tracing::trace!(target: "noun_serde_decode", " field={} type={} axis={}", #field_name_str, stringify!(#ty), #axis); + let field_noun = ::nockvm::noun::Slots::slot(&cell, #axis) + .map_err(|e| { + ::tracing::trace!(target: "noun_serde_decode", " FAILED to get slot {} for field {} in {}: {:?}", #axis, #field_name_str, #name_str, e); + ::noun_serde::NounDecodeError::ExpectedCell + })?; + ::tracing::trace!(target: "noun_serde_decode", " field={} is_atom={} is_cell={}", #field_name_str, field_noun.is_atom(), field_noun.is_cell()); + let #name = <#ty as ::noun_serde::NounDecode>::from_noun(&field_noun) + .map_err(|e| { + ::tracing::trace!(target: "noun_serde_decode", " FAILED decoding field {} in {}: {:?}", #field_name_str, #name_str, e); + e + })?; + ::tracing::trace!(target: "noun_serde_decode", " SUCCESS decoded field {} in {}", #field_name_str, #name_str); } }); quote! { - let cell = noun.as_cell().map_err(|_| ::noun_serde::NounDecodeError::ExpectedCell)?; + ::tracing::trace!(target: "noun_serde_decode", "Decoding {} (multi-field struct), is_atom={}, is_cell={}", #name_str, noun.is_atom(), noun.is_cell()); + let cell = noun.as_cell().map_err(|e| { + ::tracing::trace!(target: "noun_serde_decode", "FAILED {} expected cell but got atom", #name_str); + ::noun_serde::NounDecodeError::ExpectedCell + })?; #(#field_decoders)* + ::tracing::trace!(target: "noun_serde_decode", "SUCCESS decoded {}", #name_str); Ok(Self { #(#field_names),* }) @@ -510,7 +538,14 @@ pub fn derive_noun_decode(input: TokenStream) -> TokenStream { if field_count == 1 { let field_type = &fields.unnamed[0].ty; quote! { - let field_0 = <#field_type as ::noun_serde::NounDecode>::from_noun(noun)?; + ::tracing::trace!(target: "noun_serde_decode", "Decoding {} (single field tuple), is_atom={}, is_cell={}", #name_str, noun.is_atom(), noun.is_cell()); + ::tracing::trace!(target: "noun_serde_decode", " field=0 type={}", stringify!(#field_type)); + let field_0 = <#field_type as ::noun_serde::NounDecode>::from_noun(noun) + .map_err(|e| { + ::tracing::trace!(target: "noun_serde_decode", " FAILED decoding field 0 in {}: {:?}", #name_str, e); + e + })?; + ::tracing::trace!(target: "noun_serde_decode", "SUCCESS decoded {}", #name_str); Ok(Self(field_0)) } } else { @@ -566,26 +601,43 @@ pub fn derive_noun_decode(input: TokenStream) -> TokenStream { }; let axis = custom_axis.unwrap_or(default_axis); + let field_num_str = i.to_string(); quote! { + ::tracing::trace!(target: "noun_serde_decode", " field={} type={} axis={}", #field_num_str, stringify!(#field_type), #axis); let field_noun = ::nockvm::noun::Slots::slot(&cell, #axis) - .map_err(|_| ::noun_serde::NounDecodeError::FieldError(stringify!(#field_ident).to_string(), "Missing field".into()))?; + .map_err(|e| { + ::tracing::trace!(target: "noun_serde_decode", " FAILED to get slot {} for field {} in {}: {:?}", #axis, #field_num_str, #name_str, e); + ::noun_serde::NounDecodeError::FieldError(stringify!(#field_ident).to_string(), "Missing field".into()) + })?; + ::tracing::trace!(target: "noun_serde_decode", " field={} is_atom={} is_cell={}", #field_num_str, field_noun.is_atom(), field_noun.is_cell()); let #field_ident = <#field_type as ::noun_serde::NounDecode>::from_noun(&field_noun) - .map_err(|e| ::noun_serde::NounDecodeError::FieldError(stringify!(#field_ident).to_string(), e.to_string()))?; + .map_err(|e| { + ::tracing::trace!(target: "noun_serde_decode", " FAILED decoding field {} in {}: {:?}", #field_num_str, #name_str, e); + ::noun_serde::NounDecodeError::FieldError(stringify!(#field_ident).to_string(), e.to_string()) + })?; + ::tracing::trace!(target: "noun_serde_decode", " SUCCESS decoded field {} in {}", #field_num_str, #name_str); } }); let field_idents = (0..field_count).map(|i| format_ident!("field_{}", i)); quote! { - let cell = noun.as_cell().map_err(|_| ::noun_serde::NounDecodeError::ExpectedCell)?; + ::tracing::trace!(target: "noun_serde_decode", "Decoding {} (multi-field tuple), is_atom={}, is_cell={}", #name_str, noun.is_atom(), noun.is_cell()); + let cell = noun.as_cell().map_err(|e| { + ::tracing::trace!(target: "noun_serde_decode", "FAILED {} expected cell but got atom", #name_str); + ::noun_serde::NounDecodeError::ExpectedCell + })?; #(#field_decoders)* + ::tracing::trace!(target: "noun_serde_decode", "SUCCESS decoded {}", #name_str); Ok(Self(#(#field_idents),*)) } } } Fields::Unit => { quote! { + ::tracing::trace!(target: "noun_serde_decode", "Decoding {} (unit struct)", #name_str); + ::tracing::trace!(target: "noun_serde_decode", "SUCCESS decoded {}", #name_str); Ok(Self) } } @@ -628,9 +680,10 @@ pub fn derive_noun_decode(input: TokenStream) -> TokenStream { if is_tagged { // Tagged decoding: [%tag [[%field1 value1] [%field2 value2] ...]] + let variant_name_str = variant_name.to_string(); let field_decoders = field_names.iter().zip(field_types.iter()).enumerate() .map(|(i, (name, ty))| { - // print name and type decoding + let field_name_str = name.to_string(); // Get the corresponding field let field = fields.named.iter().find(|f| { f.ident.as_ref().unwrap().to_string() == name.to_string() @@ -640,7 +693,6 @@ pub fn derive_noun_decode(input: TokenStream) -> TokenStream { let custom_axis = parse_axis_attr(&field.attrs); // Calculate the axis for right-branching binary tree - // For field i, axis = 2 for i=0, axis = ((1*2+1)...*2+1)*2 for i>0 let default_axis = if i == 0 { 2 } else { @@ -653,18 +705,36 @@ pub fn derive_noun_decode(input: TokenStream) -> TokenStream { let axis = custom_axis.unwrap_or(default_axis); quote! { - let field_cell = ::nockvm::noun::Slots::slot(&data, #axis)?.as_cell()?; - let #name = <#ty as ::noun_serde::NounDecode>::from_noun(&field_cell.tail())?; + ::tracing::trace!(target: "noun_serde_decode", " variant={} field={} type={} axis={}", #variant_name_str, #field_name_str, stringify!(#ty), #axis); + let field_cell = ::nockvm::noun::Slots::slot(&data, #axis) + .map_err(|e| { + ::tracing::trace!(target: "noun_serde_decode", " FAILED to get slot {} for field {} in variant {}: {:?}", #axis, #field_name_str, #variant_name_str, e); + e + })? + .as_cell() + .map_err(|e| { + ::tracing::trace!(target: "noun_serde_decode", " FAILED field {} in variant {} expected cell: {:?}", #field_name_str, #variant_name_str, e); + e + })?; + let #name = <#ty as ::noun_serde::NounDecode>::from_noun(&field_cell.tail()) + .map_err(|e| { + ::tracing::trace!(target: "noun_serde_decode", " FAILED decoding field {} in variant {}: {:?}", #field_name_str, #variant_name_str, e); + e + })?; + ::tracing::trace!(target: "noun_serde_decode", " SUCCESS decoded field {} in variant {}", #field_name_str, #variant_name_str); } }); quote! { tag if tag == #tag => { + ::tracing::trace!(target: "noun_serde_decode", "Matched variant {} (tagged named fields)", #variant_name_str); if let Ok(cell) = noun.as_cell() { let data = cell.tail(); #(#field_decoders)* + ::tracing::trace!(target: "noun_serde_decode", "SUCCESS decoded variant {}", #variant_name_str); Ok(Self::#variant_name { #(#field_names),* }) } else { + ::tracing::trace!(target: "noun_serde_decode", "FAILED variant {} expected cell", #variant_name_str); Err(::noun_serde::NounDecodeError::ExpectedCell) } } @@ -742,27 +812,46 @@ pub fn derive_noun_decode(input: TokenStream) -> TokenStream { }).collect(); quote! { + ::tracing::trace!(target: "noun_serde_decode", "Decoding enum {}, is_atom={}, is_cell={}", #name_str, noun.is_atom(), noun.is_cell()); let tag = if let Ok(atom) = noun.as_atom() { let bytes = atom.as_ne_bytes(); - ::std::str::from_utf8(bytes) - .map_err(|_| ::noun_serde::NounDecodeError::InvalidTag)? + let tag_str = ::std::str::from_utf8(bytes) + .map_err(|e| { + ::tracing::trace!(target: "noun_serde_decode", "FAILED to decode tag for {} as UTF-8: {:?}", #name_str, e); + ::noun_serde::NounDecodeError::InvalidTag + })? .trim_end_matches('\0') - .to_string() + .to_string(); + ::tracing::trace!(target: "noun_serde_decode", "Decoded tag for {} (from atom): {:?}", #name_str, tag_str); + tag_str } else if let Ok(cell) = noun.as_cell() { let atom = cell.head().as_atom() - .map_err(|_| ::noun_serde::NounDecodeError::InvalidTag)?; + .map_err(|e| { + ::tracing::trace!(target: "noun_serde_decode", "FAILED to decode tag for {}, head is not atom", #name_str); + ::noun_serde::NounDecodeError::InvalidTag + })?; let bytes = atom.as_ne_bytes(); - ::std::str::from_utf8(bytes) - .map_err(|_| ::noun_serde::NounDecodeError::InvalidTag)? + let tag_str = ::std::str::from_utf8(bytes) + .map_err(|e| { + ::tracing::trace!(target: "noun_serde_decode", "FAILED to decode tag for {} as UTF-8: {:?}", #name_str, e); + ::noun_serde::NounDecodeError::InvalidTag + })? .trim_end_matches('\0') - .to_string() + .to_string(); + ::tracing::trace!(target: "noun_serde_decode", "Decoded tag for {} (from cell head): {:?}", #name_str, tag_str); + tag_str } else { + ::tracing::trace!(target: "noun_serde_decode", "FAILED to decode tag for {}, neither atom nor cell", #name_str); return Err(::noun_serde::NounDecodeError::InvalidEnumData); }; + ::tracing::trace!(target: "noun_serde_decode", "Matching enum {} with tag {:?}", #name_str, tag); match tag.as_str() { #(#cases,)* - _ => Err(::noun_serde::NounDecodeError::InvalidEnumVariant) + _ => { + ::tracing::trace!(target: "noun_serde_decode", "FAILED to match variant for {} with tag {:?}", #name_str, tag); + Err(::noun_serde::NounDecodeError::InvalidEnumVariant) + } } } } diff --git a/hoon/apps/dumbnet/inner.hoon b/hoon/apps/dumbnet/inner.hoon index 9145d4ebc..94ab206c2 100644 --- a/hoon/apps/dumbnet/inner.hoon +++ b/hoon/apps/dumbnet/inner.hoon @@ -365,6 +365,44 @@ %- some %- some [u.highest u.block-id] + :: + [%heaviest-chain-map ~] + ^- (unit (unit (z-map page-number:t block-id:t))) + ``heaviest-chain.d.k + :: + [%heaviest-chain-blocks-range start=@ end=@ ~] + ^- (unit (unit (list [page-number:t block-id:t page:t (z-map tx-id:t tx:t)]))) + =/ start-height ((soft page-number:t) start.pole) + =/ end-height ((soft page-number:t) end.pole) + ?~ start-height ~ + ?~ end-height ~ + :: ensure start <= end + ?: (gth u.start-height u.end-height) + ``~ + :: build list of blocks in range from heaviest chain + =/ result=(list [page-number:t block-id:t page:t (z-map tx-id:t tx:t)]) + =/ height u.start-height + |- ^- (list [page-number:t block-id:t page:t (z-map tx-id:t tx:t)]) + ?: (gth height u.end-height) + ~ + :: get block-id from heaviest chain + =/ block-id=(unit block-id:t) + (~(get z-by heaviest-chain.d.k) height) + ?~ block-id + $(height +(height)) + :: get block data + =/ local-block=(unit local-page:t) + (~(get z-by blocks.c.k) u.block-id) + ?~ local-block + $(height +(height)) + :: get transactions for this block + =/ block-txs=(unit (z-map tx-id:t tx:t)) + (~(get z-by txs.c.k) u.block-id) + =/ txs-map ?~(block-txs ~ u.block-txs) + :: add to result list + :- [height u.block-id (to-page:local-page:t u.local-block) txs-map] + $(height +(height)) + ``result :: [%desk-hash ~] ^- (unit (unit (unit @uvI))) From 50c9ed33893494fdc13557fa78a9aa3e4e8b0a1f Mon Sep 17 00:00:00 2001 From: tacryt-socryp <1377557+tacryt-socryp@users.noreply.github.com> Date: Thu, 20 Nov 2025 10:05:58 -0600 Subject: [PATCH 22/99] nockchain-wallet: add support for multisig creation --- Cargo.lock | 349 ++++++++- Cargo.toml | 1 + README.md | 1 + crates/nockapp/src/drivers/markdown.rs | 1 + crates/nockchain-wallet/Cargo.toml | 3 + crates/nockchain-wallet/README.md | 93 ++- crates/nockchain-wallet/src/command.rs | 101 ++- crates/nockchain-wallet/src/main.rs | 635 ++++++++-------- crates/nockchain-wallet/src/recipient.rs | 307 ++++++++ crates/nockchain/src/mining.rs | 2 +- crates/noun-serde-derive/src/lib.rs | 91 ++- .../tests/untagged_enum_regression.rs | 39 + hoon/apps/wallet/lib/tx-builder-v1.hoon | 233 ------ hoon/apps/wallet/lib/tx-builder.hoon | 466 ++++++++++++ hoon/apps/wallet/lib/types.hoon | 109 ++- hoon/apps/wallet/lib/utils.hoon | 393 ++++++++-- hoon/apps/wallet/wallet.hoon | 706 +++++++++++++++--- hoon/common/tx-engine-0.hoon | 13 + hoon/common/tx-engine-1.hoon | 49 +- hoon/common/tx-engine.hoon | 16 +- 20 files changed, 2801 insertions(+), 807 deletions(-) create mode 100644 crates/nockchain-wallet/src/recipient.rs create mode 100644 crates/noun-serde-derive/tests/untagged_enum_regression.rs delete mode 100644 hoon/apps/wallet/lib/tx-builder-v1.hoon create mode 100644 hoon/apps/wallet/lib/tx-builder.hoon diff --git a/Cargo.lock b/Cargo.lock index 0d454db58..117c58d65 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -140,6 +140,26 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image 0.25.8", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + [[package]] name = "arc-swap" version = "1.7.1" @@ -972,6 +992,15 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "cmac" version = "0.7.2" @@ -1542,6 +1571,16 @@ dependencies = [ "windows-sys 0.61.0", ] +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.9.4", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1671,6 +1710,15 @@ dependencies = [ "syn", ] +[[package]] +name = "env_filter" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +dependencies = [ + "log", +] + [[package]] name = "env_logger" version = "0.8.4" @@ -1681,6 +1729,18 @@ dependencies = [ "regex", ] +[[package]] +name = "env_logger" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "log", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1729,6 +1789,12 @@ dependencies = [ "windows-sys 0.61.0", ] +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + [[package]] name = "exr" version = "1.73.0" @@ -1750,6 +1816,26 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fax" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" +dependencies = [ + "fax_derive", +] + +[[package]] +name = "fax_derive" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "fdeflate" version = "0.3.7" @@ -2008,6 +2094,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc257fdb4038301ce4b9cd1b3b51704509692bb3ff716a410cbd07925d9dae55" +dependencies = [ + "rustix 1.1.2", + "windows-targets 0.52.6", +] + [[package]] name = "getrandom" version = "0.2.16" @@ -2674,9 +2770,9 @@ dependencies = [ "gif", "jpeg-decoder", "num-traits", - "png", + "png 0.17.16", "qoi", - "tiff", + "tiff 0.9.1", ] [[package]] @@ -2689,6 +2785,8 @@ dependencies = [ "byteorder-lite", "moxcms", "num-traits", + "png 0.18.0", + "tiff 0.10.3", ] [[package]] @@ -3790,6 +3888,7 @@ dependencies = [ "intmap", "nockvm", "nockvm_macros", + "noun-serde", "opentelemetry", "opentelemetry-otlp", "opentelemetry_sdk", @@ -3911,6 +4010,42 @@ dependencies = [ "zkvm-jetpack", ] +[[package]] +name = "nockchain-api" +version = "0.1.0" +dependencies = [ + "clap", + "kernels", + "nockapp", + "nockchain", + "nockvm", + "tikv-jemallocator", + "tokio", + "tracy-client", + "zkvm-jetpack", +] + +[[package]] +name = "nockchain-explorer-tui" +version = "0.1.0" +dependencies = [ + "anyhow", + "arboard", + "chrono", + "clap", + "crossterm 0.29.0", + "nockapp-grpc", + "nockapp-grpc-proto", + "nockchain-math", + "nockchain-types", + "ratatui 0.28.1", + "tokio", + "tonic 0.14.2", + "tracing", + "tracing-subscriber", + "tracing-tracy", +] + [[package]] name = "nockchain-libp2p-io" version = "0.1.0" @@ -3954,7 +4089,6 @@ dependencies = [ "either", "hex-literal", "ibig", - "nockapp", "nockvm", "nockvm_macros", "noun-serde", @@ -4024,10 +4158,13 @@ dependencies = [ "nockvm_macros", "noun-serde", "qrcode", - "ratatui", + "ratatui 0.29.0", "rustls", + "serde", + "serde_json", "tempfile", "termimad", + "test-log", "thiserror 2.0.16", "tokio", "tonic 0.14.2", @@ -4041,7 +4178,9 @@ name = "nockvm" version = "0.1.0" dependencies = [ "autotools", + "bincode", "bitvec", + "bytes", "cc", "criterion", "either", @@ -4057,6 +4196,7 @@ dependencies = [ "num-derive", "num-traits", "rand 0.9.2", + "serde", "signal-hook", "slotmap", "static_assertions", @@ -4125,8 +4265,8 @@ dependencies = [ name = "noun-serde" version = "0.1.0" dependencies = [ + "bytes", "ibig", - "nockapp", "nockvm", "noun-serde-derive", "thiserror 2.0.16", @@ -4143,6 +4283,7 @@ dependencies = [ "proc-macro2", "quote", "syn", + "tracing", ] [[package]] @@ -4227,6 +4368,79 @@ dependencies = [ "libc", ] +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.9.4", + "objc2", + "objc2-core-graphics", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.9.4", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.9.4", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.9.4", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.9.4", + "objc2", + "objc2-core-foundation", +] + [[package]] name = "object" version = "0.36.7" @@ -4566,6 +4780,19 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "png" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" +dependencies = [ + "bitflags 2.9.4", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + [[package]] name = "polling" version = "3.11.0" @@ -4833,6 +5060,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-protobuf" version = "0.8.1" @@ -4860,7 +5093,7 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "588f6378e4dd99458b60ec275b4477add41ce4fa9f64dcba6f15adccb19b50d6" dependencies = [ - "env_logger", + "env_logger 0.8.4", "log", "rand 0.8.5", ] @@ -5010,6 +5243,27 @@ dependencies = [ "getrandom 0.3.3", ] +[[package]] +name = "ratatui" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" +dependencies = [ + "bitflags 2.9.4", + "cassowary", + "compact_str", + "crossterm 0.28.1", + "instability", + "itertools 0.13.0", + "lru", + "paste", + "strum 0.26.3", + "strum_macros 0.26.4", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.1.14", +] + [[package]] name = "ratatui" version = "0.29.0" @@ -5040,6 +5294,20 @@ dependencies = [ "bitflags 2.9.4", ] +[[package]] +name = "raw-tx-checker" +version = "0.1.0" +dependencies = [ + "anyhow", + "clap", + "nockchain-math", + "nockchain-types", + "nockvm", + "noun-serde", + "tracing", + "zkvm-jetpack", +] + [[package]] name = "rayon" version = "1.11.0" @@ -6012,6 +6280,28 @@ dependencies = [ "unicode-width 0.1.14", ] +[[package]] +name = "test-log" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e33b98a582ea0be1168eba097538ee8dd4bbe0f2b01b22ac92ea30054e5be7b" +dependencies = [ + "env_logger 0.11.8", + "test-log-macros", + "tracing-subscriber", +] + +[[package]] +name = "test-log-macros" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451b374529930d7601b1eef8d32bc79ae870b6079b069401709c2a8bf9e75f36" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -6072,6 +6362,20 @@ dependencies = [ "weezl", ] +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half 2.6.0", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "tikv-jemalloc-sys" version = "0.6.0+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7" @@ -7559,6 +7863,23 @@ dependencies = [ "tap", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix 1.1.2", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "x25519-dalek" version = "2.0.1" @@ -7751,7 +8072,6 @@ dependencies = [ "either", "hex-literal", "ibig", - "nockapp", "nockchain-math", "nockvm", "nockvm_macros", @@ -7765,6 +8085,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + [[package]] name = "zune-inflate" version = "0.2.54" @@ -7773,3 +8099,12 @@ checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" dependencies = [ "simd-adler32", ] + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index 1554c1ea0..8de3d5887 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -114,6 +114,7 @@ tempfile = "3.3" termcolor = "1.4" termimad = "0.33.0" testcontainers = { git = "https://github.com/bitemyapp/testcontainers-rs.git", rev = "54851fd9faf9b9cded9d681b46f87c056880d870" } +test-log = "0.2.18" thiserror = "2.0.11" tikv-jemallocator = { version = "0.6" } tiny-keccak = { version = "2", features = ["keccak"] } diff --git a/README.md b/README.md index da85b1984..997b27fd7 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,7 @@ nockchain-wallet watch-address ``` The wallet normalizes the identifier so you can supply either a v1 payee hash or a schnorr pubkey. +Once added, watch-only addresses/first names are synced automatically alongside your signing keys, so their balances appear in all sync-heavy commands without additional flags. Use `.env_example` as a template and copy your pkh to the `.env` file. diff --git a/crates/nockapp/src/drivers/markdown.rs b/crates/nockapp/src/drivers/markdown.rs index 5b0aaeb1c..3818bd2eb 100644 --- a/crates/nockapp/src/drivers/markdown.rs +++ b/crates/nockapp/src/drivers/markdown.rs @@ -25,6 +25,7 @@ pub fn markdown() -> IODriverFn { error!("Failed to convert markdown text to string"); continue; }; + tracing::debug!("Markdown text: {}", text); println!("{}", skin.term_text(&text)); } diff --git a/crates/nockchain-wallet/Cargo.toml b/crates/nockchain-wallet/Cargo.toml index 4e6bdb94a..13927536d 100644 --- a/crates/nockchain-wallet/Cargo.toml +++ b/crates/nockchain-wallet/Cargo.toml @@ -12,6 +12,9 @@ nockchain-math = { workspace = true } nockvm = { workspace = true } nockvm_macros = { workspace = true } noun-serde = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +test-log = { workspace = true } bardecoder = { workspace = true } bs58 = { workspace = true } diff --git a/crates/nockchain-wallet/README.md b/crates/nockchain-wallet/README.md index 97297282e..c616c4b4c 100644 --- a/crates/nockchain-wallet/README.md +++ b/crates/nockchain-wallet/README.md @@ -30,14 +30,17 @@ nockchain-wallet import-keys --key "zprv..." # importing the seed phrase again with version 1. nockchain-wallet import-keys --seedphrase "your seed phrase here" --version -# Import a watch-only address or pubkey -nockchain-wallet watch-address +# Import watch-only identifiers +nockchain-wallet watch address +nockchain-wallet watch pubkey +nockchain-wallet watch multisig --threshold --participants ",,..." # Import a master public key from exported file nockchain-wallet import-master-pubkey keys.export ``` The exported keys file contains all wallet keys as a `jam` file that can be imported on another instance. +Once imported, watch-only identifiers are kept in sync automatically, so their balances show up in every sync-heavy command without extra flags. Can be used for: - Backing up your wallet @@ -63,7 +66,7 @@ nockchain-wallet \ - The wallet syncs its balance based on the pubkeys that are stored in it. Make sure your wallet is loaded with your keys before running sync-heavy commands such as `list-notes`, `list-notes-by-address`, `create-tx`, and `send-tx`. If you do not have pubkeys, import them with `import-keys` (see [Importing and Exporting Keys](#importing-and-exporting-keys)). - `--public-grpc-server-addr` accepts a bare `host:port` or a full URI (e.g. `http://host:port`). - If you omit the port, the wallet assumes **80** for `http://` and **443** for `https://` URLs. -- By default, we do not sync notes attached to watch-only pubkeys. Pair sync-heavy commands with `--include-watch-only` when you want watch-only pubkeys included in balance updates. +- Watch-only pubkeys and addresses are synced automatically alongside your signing keys, so watch-only balances appear in all sync-heavy commands without additional flags. #### Private API @@ -140,12 +143,15 @@ nockchain-wallet list-notes-by-address Shows only the notes associated with the specified public key. Useful for filtering wallet contents by address or for multisig scenarios. -### List Arbitrary Notes by Public Key (Watch-Only) +### Watch-Only Tracking -```bash -nockchain-wallet watch-address
-nockchain-wallet list-notes-by-address
--include-watch-only -``` +Use `nockchain-wallet watch ` to track external identifiers without importing private keys. + +- `watch address ` – accepts either a schnorr pubkey (v0) or a pay-to-pubkey-hash (v1) string +- `watch pubkey ` – shortcut when you know you’re tracking a raw schnorr key +- `watch multisig --threshold --participants ",,..."` – records multisig locks so their balances appear in sync results + +Once added, run `list-notes`, `list-notes-by-address`, or any other sync-heavy command to see the balances. Shows only the notes associated with the specified public key. Useful for filtering wallet contents by address or for multisig scenarios. @@ -182,18 +188,16 @@ Displays the aggregate wallet balance, including the total number of notes and t We support transactions with any amount of input notes going to any number of recipients. ```bash -# Send to a single recipient +# Send to a single P2PKH recipient nockchain-wallet create-tx \ --names "[first1 last1],[first2 last2]" \ - --recipient ":" \ + --recipient '{"kind":"p2pkh","address":"","amount":10000}' \ --fee 10 -# Send to multiple recipients +# Send to a multisig recipient nockchain-wallet create-tx \ --names "[first1 last1],[first2 last2]" \ - --recipient ":" \ - --recipient ":" \ - ... + --recipient '{"kind":"multisig","threshold":2,"addresses":["","",""],"amount":9000}' \ --fee 10 ``` @@ -203,6 +207,67 @@ Gifts and fees are denominated in nicks (65536 nicks = 1 nock). - The `names` argument is a list of `[first-name last-name]` pairs specifying funding notes - The `fee` argument is the transaction fee to pay (in nicks, 65536 nicks to 1 nock) +- Provide multiple `--recipient` flags to fan out to several outputs +- Each `--recipient` is either a JSON object (preferred) or a legacy `:` string +- `address`/`addresses` fields expect base58-encoded pay-to-pubkey-hash values +- Provide `--sign-key ` multiple times to explicitly choose signing keys. If omitted, the wallet uses the master key or the `--index/--hardened` pair. + +#### Recipient JSON Format + +`--recipient` accepts JSON objects in addition to the legacy `:` syntax (legacy supports simple 1-of-1 P2PKH locks only). Wrap JSON in single quotes (or escape the quotes) when invoking the CLI. The supported shapes are: + +```json +{"kind":"p2pkh","address":"","amount":10000} +{"kind":"multisig","threshold":2,"addresses":["","",""],"amount":9000} +``` + +- `kind` must be either `p2pkh` or `multisig` +- `amount` is specified in nicks +- Multisig objects also require a `threshold` (m) and at least one `addresses` entry + +Provide multiple `--recipient` flags to fan out to several recipients in one transaction. + +### Multisig Recipients + +Multisig outputs are expressed via the JSON form. Supply each output as: + +```json +{"kind":"multisig","threshold":,"addresses":["", ...],"amount":} +``` + +- `threshold` defines the `m` value (must be ≥1 and ≤ number of addresses) +- `addresses` is the list of base58 payee hashes that define the lock +- `amount` is denominated in nicks + +```bash +nockchain-wallet create-tx \ + --names "[first1 last1],[first2 last2]" \ + --recipient '{"kind":"multisig","threshold":2,"addresses":["","",""],"amount":750000000}' \ + --fee 60000000 +``` + +- `--sign-key` is optional and lets you pick which derived keys sign the bundle when the command runs. Each entry is `index:hardened` (for example, `5:true` signs with hardened child 5). If omitted, the active master key provides the initial signature. +- `--refund-pkh` is optional; when omitted, change returns to the default refund target for the spent notes. + +### Signing Multisig Transactions + +Every multisig bundle is validated and saved to `./txs/.tx`. Share this file with the remaining signers. Additional signatures are appended with: + +```bash +nockchain-wallet sign-multisig-tx ./txs/.tx --sign-keys "1:false" +``` + +`sign-multisig-tx` accepts the same `index:hardened` pairs (or defaults to the active master key). Use `show-tx` to inspect the current signature set before broadcasting: + +```bash +nockchain-wallet show-tx ./txs/.tx +``` + +Once enough signatures are collected, broadcast the transaction with: + +```bash +nockchain-wallet send-tx ./txs/.tx +``` ### Make Transaction from Transaction File diff --git a/crates/nockchain-wallet/src/command.rs b/crates/nockchain-wallet/src/command.rs index 00989c060..99ce9e9fb 100644 --- a/crates/nockchain-wallet/src/command.rs +++ b/crates/nockchain-wallet/src/command.rs @@ -10,6 +10,7 @@ use nockchain_math::belt::Belt; use nockchain_types::tx_engine::v0; use crate::connection::ConnectionCli; +use crate::recipient::{parse_recipient_arg, RecipientSpecToken}; /// CLI helper that captures optional lower and upper bounds for timelocks. #[allow(dead_code)] @@ -179,14 +180,41 @@ pub struct WalletCli { #[command(flatten)] pub connection: ConnectionCli, - /// Include watch-only pubkeys when synchronizing wallet balance - #[arg(long, global = true, default_value_t = false)] - pub include_watch_only: bool, - #[command(subcommand)] pub command: Commands, } +#[derive(Subcommand, Debug, Clone)] +pub enum WatchSubcommand { + /// Add a watch-only address (base58 pkh or schnorr pubkey) + Address { + /// Base58-encoded address or schnorr pubkey + #[arg(value_name = "address")] + address: String, + }, + /// Add a watch-only schnorr pubkey + Pubkey { + /// Base58-encoded schnorr pubkey + #[arg(value_name = "pubkey")] + pubkey: String, + }, + /// Add a watch-only first name (base58 hash) + //FirstName { + // /// Base58-encoded first name hash + // #[arg(value_name = "first-name")] + // first_name: String, + //}, + /// Import a multisig lock for watch-only tracking + Multisig { + /// Threshold (m) value for the m-of-n multisig + #[arg(short = 't', long = "threshold")] + threshold: u64, + /// Comma-separated list of base58 pubkey hashes for the multisig + #[arg(long)] + participants: String, + }, +} + #[derive(clap::ValueEnum, Debug, Clone, PartialEq, Eq)] pub enum ClientType { Public, @@ -276,11 +304,10 @@ pub enum Commands { version: Option, }, - /// Add a watch-only address or pubkey to the wallet - WatchAddress { - /// Public key hash (v1 address) or schnorr pubkey (v0 address). base58 encoded. - #[arg(value_name = "address")] - address: String, + /// Watch addresses, pubkeys, multisigs, or first-names + Watch { + #[command(subcommand)] + subcommand: WatchSubcommand, }, /// Export keys to a file @@ -323,31 +350,23 @@ pub enum Commands { tx_id: String, }, - /// Signs a transaction (for multisigs only) - SignTx { - /// Path to input bundle file - transaction: String, - - /// Optional key index to use for signing [0, 2^31) - #[arg(short, long, value_parser = clap::value_parser!(u64).range(0..2 << 31))] - index: Option, - /// Hardened or unhardened child key - #[arg(long, default_value = "false")] - hardened: bool, - }, - /// Create a transaction (use --refund-pkh when spending legacy v0 notes) #[command( name = "create-tx", - override_usage = "nockchain-wallet create-tx --names --recipient --fee [--refund-pkh ] [--include-data ] [--save-raw-tx] \n\n# NOTE: --refund-pkh is required when spending from v0 notes. For v1 notes, the refund defaults to the note owner. --include-data defaults to true (pass 'false' to exclude note data). \n\nExamples:\n # Send to a single recipient\n nockchain-wallet create-tx \\\n --names \"[first1 last1],[first2 last2]\" \\\n --recipient \":\" \\\n --fee 10 \\\n --refund-pkh " + override_usage = "nockchain-wallet create-tx --names --recipient ... --fee [--refund-pkh ] [--include-data ]\n\n# NOTE: --refund-pkh is required when spending from v0 notes. For v1 notes, the refund defaults to the note owner. --include-data defaults to true (pass 'false' to exclude note data).\n# RECIPIENT accepts either legacy ':' strings or JSON objects like '{\"kind\":\"multisig\",\"threshold\":2,\"addresses\":[\"pkh-a\",\"pkh-b\"],\"amount\":9000}'.\n\nExamples:\n # Pay a simple recipient\n nockchain-wallet create-tx \\\n --names \"[first1 last1],[first2 last2]\" \\\n --recipient '{\"kind\":\"p2pkh\",\"address\":\"\",\"amount\":10000}' \\\n --fee 10 \\\n --refund-pkh \n\n # Create a multisig recipient\n nockchain-wallet create-tx \\\n --names \"[first1 last1],[first2 last2]\" \\\n --recipient '{\"kind\":\"multisig\",\"threshold\":2,\"addresses\":[\"\",\"\",\"\"],\"amount\":9000}' \\\n --fee 10" )] CreateTx { /// Names of notes to spend (comma-separated) #[arg(long)] names: String, - /// Transaction output, formatted as ":" - #[arg(long = "recipient")] - recipients: Vec, + /// Recipient specifications (repeat --recipient for each output) + #[arg( + long = "recipient", + value_name = "RECIPIENT", + value_parser = parse_recipient_arg, + action = ArgAction::Append + )] + recipients: Vec, /// Transaction fee #[arg(long)] fee: u64, @@ -368,12 +387,24 @@ pub enum Commands { default_value_t = true )] include_data: bool, + /// Additional signing keys. Accepts `index` or `index:hardened`. + #[arg(long = "sign-key", value_name = "INDEX[:HARDENED]", action = ArgAction::Append)] + sign_keys: Vec, /// For debugging purposes. If true, the raw-tx jam will be saved in the /// txs-debug folder in the current working directory. #[arg(long, default_value = "false")] save_raw_tx: bool, }, + /// Sign a multisig transaction + SignMultisigTx { + /// Path to transaction file + transaction: String, + /// Comma-separated list of key indices to sign with (format: index:hardened). If not provided, uses master key. + #[arg(long)] + sign_keys: Option, + }, + /// Export a master public key ExportMasterPubkey, @@ -407,6 +438,14 @@ pub enum Commands { #[command(name = "show-master-zprv")] ShowMasterZPrv, + /// Show the key tree structure + #[command(name = "show-key-tree")] + ShowKeyTree { + /// Include values at each path + #[arg(long)] + include_values: bool, + }, + /// Fetch confirmation depth for a transaction ID // Confirmations { // /// Base58-encoded transaction ID @@ -511,12 +550,12 @@ impl Commands { Commands::DeriveChild { .. } => "derive-child", Commands::ImportKeys { .. } => "import-keys", Commands::ExportKeys => "export-keys", - Commands::SignTx { .. } => "sign-tx", Commands::ListNotes => "list-notes", Commands::ListNotesByAddress { .. } => "list-notes-by-address", Commands::ListNotesByAddressCsv { .. } => "list-notes-by-address-csv", Commands::SetActiveMasterAddress { .. } => "set-active-master-address", Commands::CreateTx { .. } => "create-tx", + Commands::SignMultisigTx { .. } => "sign-multisig-tx", Commands::SendTx { .. } => "send-tx", Commands::ShowTx { .. } => "show-tx", Commands::ShowBalance => "show", @@ -527,12 +566,18 @@ impl Commands { Commands::ShowSeedphrase => "show-seed-phrase", Commands::ShowMasterZPub => "show-master-zpub", Commands::ShowMasterZPrv => "show-master-zprv", + Commands::ShowKeyTree { .. } => "show-key-tree", Commands::SignMessage { .. } => "sign-message", Commands::VerifyMessage { .. } => "verify-message", Commands::SignHash { .. } => "sign-hash", Commands::VerifyHash { .. } => "verify-hash", Commands::TxAccepted { .. } => "tx-accepted", - Commands::WatchAddress { .. } => "watch-address", + Commands::Watch { subcommand } => match subcommand { + WatchSubcommand::Address { .. } => "watch-address", + WatchSubcommand::Pubkey { .. } => "watch-address", + //WatchSubcommand::FirstName { .. } => "watch-first-name", + WatchSubcommand::Multisig { .. } => "watch-address-multisig", + }, } } } diff --git a/crates/nockchain-wallet/src/main.rs b/crates/nockchain-wallet/src/main.rs index e9abf9630..712c0a7fd 100644 --- a/crates/nockchain-wallet/src/main.rs +++ b/crates/nockchain-wallet/src/main.rs @@ -3,6 +3,7 @@ mod command; mod connection; mod error; +mod recipient; use std::fs; use std::io::{self, Write}; @@ -13,7 +14,7 @@ use clap::Parser; use command::TimelockRangeCli; #[cfg(test)] use command::WalletWire; -use command::{ClientType, CommandNoun, Commands, WalletCli}; +use command::{ClientType, CommandNoun, Commands, WalletCli, WatchSubcommand}; use kernels::wallet::KERNEL; use nockapp::driver::*; use nockapp::kernel::boot::{self, NockStackSize}; @@ -34,6 +35,7 @@ use nockvm::jets::cold::Nounable; use nockvm::noun::{Atom, Cell, IndirectAtom, Noun, D, NO, SIG, T, YES}; use noun_serde::prelude::*; use noun_serde::NounDecodeError; +use recipient::{recipient_tokens_to_specs, RecipientSpec}; use termimad::MadSkin; use tokio::fs as tokio_fs; use tracing::{error, info, warn}; @@ -79,7 +81,6 @@ async fn main() -> Result<(), NockAppError> { Commands::Keygen | Commands::DeriveChild { .. } | Commands::ImportKeys { .. } - | Commands::WatchAddress { .. } | Commands::ExportKeys | Commands::SignMessage { .. } | Commands::VerifyMessage { .. } @@ -93,7 +94,10 @@ async fn main() -> Result<(), NockAppError> { | Commands::ShowSeedphrase | Commands::ShowMasterZPub | Commands::ShowMasterZPrv + | Commands::ShowKeyTree { .. } | Commands::ShowTx { .. } + | Commands::SignMultisigTx { .. } + | Commands::Watch { .. } | Commands::TxAccepted { .. } => false, // All other commands DO need sync @@ -113,11 +117,6 @@ async fn main() -> Result<(), NockAppError> { hardened, label, } => Wallet::derive_child(*index, *hardened, label), - Commands::SignTx { - transaction, - index, - hardened, - } => Wallet::sign_tx(transaction, *index, *hardened), Commands::SignMessage { message, message_file, @@ -233,11 +232,36 @@ async fn main() -> Result<(), NockAppError> { .into()); } } - Commands::WatchAddress { address } => match normalize_watch_address(address.clone())? { - Some(normalized) => Wallet::watch_address(&normalized), - None => { - return Err(CrownError::Unknown("Invalid watch identifier provided".into()).into()); - } + Commands::Watch { subcommand } => match subcommand { + WatchSubcommand::Address { address } => match normalize_watch_address(address.clone())? + { + Some(normalized) => Wallet::watch_address(&normalized), + None => { + return Err( + CrownError::Unknown("Invalid watch identifier provided".into()).into(), + ); + } + }, + WatchSubcommand::Pubkey { pubkey } => match normalize_watch_address(pubkey.clone())? { + Some(normalized) => Wallet::watch_address(&normalized), + None => { + return Err(CrownError::Unknown("Invalid pubkey provided".into()).into()); + } + }, + //WatchSubcommand::FirstName { first_name } => { + // match normalize_first_name(first_name.clone())? { + // Some(name) => Wallet::watch_first_name(&name), + // None => { + // return Err( + // CrownError::Unknown("Invalid first name provided".into()).into() + // ); + // } + // } + //} + WatchSubcommand::Multisig { + threshold, + participants, + } => Wallet::watch_multisig(*threshold, participants), }, Commands::ExportKeys => Wallet::export_keys(), Commands::ListNotes => Wallet::list_notes(), @@ -257,17 +281,25 @@ async fn main() -> Result<(), NockAppError> { index, hardened, include_data, + sign_keys, save_raw_tx, - } => Wallet::create_tx( - names.clone(), - recipients.clone(), - *fee, - refund_pkh.clone(), - *index, - *hardened, - *include_data, - *save_raw_tx, - ), + } => { + let recipient_specs = recipient_tokens_to_specs(recipients.clone())?; + let signing_keys = Wallet::collect_signing_keys(*index, *hardened, sign_keys)?; + Wallet::create_tx( + names.clone(), + recipient_specs, + *fee, + refund_pkh.clone(), + signing_keys, + *include_data, + *save_raw_tx, + ) + } + Commands::SignMultisigTx { + transaction, + sign_keys, + } => Wallet::sign_multisig_tx(transaction, sign_keys.as_deref()), Commands::SendTx { transaction } => Wallet::send_tx(transaction), Commands::ShowTx { transaction } => Wallet::show_tx(transaction), Commands::ShowBalance => Wallet::show_balance(), @@ -281,6 +313,7 @@ async fn main() -> Result<(), NockAppError> { Commands::ShowSeedphrase => Wallet::show_seed_phrase(), Commands::ShowMasterZPub => Wallet::show_master_pubkey(), Commands::ShowMasterZPrv => Wallet::show_master_privkey(), + Commands::ShowKeyTree { include_values } => Wallet::show_key_tree(*include_values), Commands::TxAccepted { .. } => { unreachable!("transaction-accepted handled earlier") } @@ -293,15 +326,13 @@ async fn main() -> Result<(), NockAppError> { ); let mut pubkey_peek_slab = NounSlab::new(); let tracked_tag = make_tas(&mut pubkey_peek_slab, "tracked-pubkeys").as_noun(); - let watch_only = cli.include_watch_only.to_noun(&mut pubkey_peek_slab); - let path = T(&mut pubkey_peek_slab, &[tracked_tag, watch_only, SIG]); + let path = T(&mut pubkey_peek_slab, &[tracked_tag, SIG]); pubkey_peek_slab.set_root(path); let pubkey_slab = wallet.app.peek_handle(pubkey_peek_slab).await?; let mut first_name_peek_slab = NounSlab::new(); let tracked_tag = make_tas(&mut first_name_peek_slab, "tracked-names").as_noun(); - let watch_only = cli.include_watch_only.to_noun(&mut first_name_peek_slab); - let path = T(&mut first_name_peek_slab, &[tracked_tag, watch_only, SIG]); + let path = T(&mut first_name_peek_slab, &[tracked_tag, SIG]); first_name_peek_slab.set_root(path); let first_name_slab = wallet.app.peek_handle(first_name_peek_slab).await?; @@ -521,7 +552,7 @@ impl Wallet { let hardened_noun = if hardened { YES } else { NO }; T(&mut slab, &[D(0), inner, hardened_noun]) } - None => D(0), + None => SIG, }; // Generate random entropy @@ -560,7 +591,7 @@ impl Wallet { let hardened_noun = if hardened { YES } else { NO }; T(&mut slab, &[D(0), inner, hardened_noun]) } - None => D(0), + None => SIG, }; Self::wallet( @@ -607,7 +638,7 @@ impl Wallet { let hardened_noun = if hardened { YES } else { NO }; T(&mut slab, &[D(0), inner, hardened_noun]) } - None => D(0), + None => SIG, }; Self::wallet( @@ -694,6 +725,63 @@ impl Wallet { Self::wallet("watch-address", &[address_noun], Operation::Poke, &mut slab) } + /// Imports a watch-only first name. + /// + /// # Arguments + /// + /// * `first_name` - Base58-encoded first name hash. + fn watch_first_name(first_name: &str) -> CommandNoun { + let mut slab = NounSlab::new(); + let first_name_noun = make_tas(&mut slab, first_name).as_noun(); + let lock_noun = SIG; // unit: no known lock provided + Self::wallet( + "watch-first-name", + &[first_name_noun, lock_noun], + Operation::Poke, + &mut slab, + ) + } + + /// Imports a watch-only multisig lock by its parameters. + /// + /// # Arguments + /// + /// * `m` - The M value of the multisig. + /// * `pubkeys_str` - Comma-separated list of base58 pubkey hashes. + fn watch_multisig(m: u64, pubkeys_str: &str) -> CommandNoun { + if m == 0 { + return Err( + CrownError::Unknown("m must be greater than 0 for multisig watch".into()).into(), + ); + } + + let pubkey_hashes = Self::parse_pubkey_hashes(pubkeys_str)?; + + if m as usize > pubkey_hashes.len() { + return Err(CrownError::Unknown(format!( + "m ({}) cannot exceed number of pubkeys ({})", + m, + pubkey_hashes.len() + )) + .into()); + } + + let mut slab = NounSlab::new(); + let m_noun = D(m); + let pubkeys_noun = pubkey_hashes.into_iter().rev().fold(D(0), |acc, hash| { + let hash_b58 = hash.to_base58(); + let hash_noun = make_tas(&mut slab, &hash_b58).as_noun(); + Cell::new(&mut slab, hash_noun, acc).as_noun() + }); + + Self::wallet( + "watch-address-multisig", + &[m_noun, pubkeys_noun], + Operation::Poke, + &mut slab, + ) + } + /// Exports keys to a file. fn export_keys() -> CommandNoun { let mut slab = NounSlab::new(); @@ -756,68 +844,34 @@ impl Wallet { Ok(names) } - fn parse_multiple_recipients( - recipients: &Vec, - ) -> Result, NockAppError> { - recipients - .iter() - .map(|v| &**v) - .map(Self::parse_single_output) - .collect::, _>>() - } - - fn parse_single_output(raw: &str) -> Result<(String, u64), NockAppError> { - let specs: Vec<&str> = raw - .split(',') - .map(str::trim) - .filter(|spec| !spec.is_empty()) - .collect(); - - if specs.is_empty() { - return Err( - CrownError::Unknown("At least one output must be provided".to_string()).into(), - ); - } - - if specs.len() > 1 { - return Err(CrownError::Unknown( - "Multiple outputs are not supported yet. Provide a single : pair." - .to_string(), - ) - .into()); - } - - let spec = specs[0]; - let (pkh, amount_str) = spec.split_once(':').ok_or_else(|| { - CrownError::Unknown(format!( - "Invalid output spec '{}', expected :", - spec - )) - })?; - - let pkh_trimmed = pkh.trim(); - if pkh_trimmed.is_empty() { - return Err( - CrownError::Unknown("Output pubkey hash cannot be empty".to_string()).into(), - ); + fn collect_signing_keys( + index: Option, + hardened: bool, + sign_keys: &[String], + ) -> Result, NockAppError> { + if !sign_keys.is_empty() { + sign_keys + .iter() + .map(|entry| Self::parse_sign_key_entry(entry)) + .collect() + } else if let Some(idx) = index { + Ok(vec![(idx, hardened)]) + } else { + Ok(Vec::new()) } + } - let amount = amount_str.trim().parse::().map_err(|err| { - CrownError::Unknown(format!( - "Invalid amount '{}' in output spec '{}': {}", - amount_str.trim(), - spec, - err - )) - })?; - - if amount == 0 { - return Err( - CrownError::Unknown("Gift amount to recipient cannot be 0".to_string()).into(), - ); + fn parse_sign_key_entry(entry: &str) -> Result<(u64, bool), NockAppError> { + let trimmed = entry.trim(); + if trimmed.is_empty() { + return Err(CrownError::Unknown("Sign key entries cannot be empty".to_string()).into()); } - Ok((pkh_trimmed.to_string(), amount)) + let (index_part, hardened_part) = trimmed + .split_once(':') + .map(|(index, hardened)| (index, Some(hardened))) + .unwrap_or((trimmed, None)); + Self::parse_sign_key_components(index_part, hardened_part) } /// Creates a transaction. Use `--refund-pkh` when spending legacy v0 notes so the kernel @@ -825,25 +879,20 @@ impl Wallet { /// defaults back to the note owner, so `--refund-pkh` can be omitted. fn create_tx( names: String, - recipients: Vec, + recipients: Vec, fee: u64, refund_pkh: Option, - index: Option, - hardened: bool, + sign_keys: Vec<(u64, bool)>, include_data: bool, save_raw_tx: bool, ) -> CommandNoun { let mut slab = NounSlab::new(); let names_vec = Self::parse_note_names(&names)?; - let recipients = Self::parse_multiple_recipients(&recipients)?; - - // Convert names to list of pairs let names_noun = names_vec .into_iter() .rev() .fold(D(0), |acc, (first, last)| { - // Create a tuple [first_name last_name] for each name pair let first_noun = make_tas(&mut slab, &first).as_noun(); let last_noun = make_tas(&mut slab, &last).as_noun(); let name_pair = T(&mut slab, &[first_noun, last_noun]); @@ -851,32 +900,8 @@ impl Wallet { }); let fee_noun = D(fee); - - // Format information about signing key - let sign_key_noun = match index { - Some(i) => { - let inner = D(i); - let hardened_noun = if hardened { YES } else { NO }; - T(&mut slab, &[D(0), inner, hardened_noun]) - } - None => D(0), - }; - - let mut order_nouns = vec![]; - for (pkh, amount) in recipients { - let recipient_pkh = Hash::from_base58(&pkh) - .map_err(|err| { - NockAppError::from(CrownError::Unknown(format!( - "Invalid output pubkey hash '{}': {}", - pkh, err - ))) - })? - .to_noun(&mut slab); - let order_noun = T(&mut slab, &[recipient_pkh, D(amount)]); - order_nouns.push(order_noun); - } - order_nouns.push(D(0)); - let order_noun = T(&mut slab, &order_nouns); + let order_noun = recipients.to_noun(&mut slab); + let sign_key_noun = Wallet::encode_sign_keys(&mut slab, sign_keys); let refund_noun = if let Some(refund) = refund_pkh { let refund_hash = Hash::from_base58(&refund).map_err(|err| { @@ -904,6 +929,14 @@ impl Wallet { ) } + fn encode_sign_keys(slab: &mut NounSlab, keys: Vec<(u64, bool)>) -> Noun { + if keys.is_empty() { + SIG + } else { + Some(keys).to_noun(slab) + } + } + async fn update_balance_grpc_public( client: &mut public_nockchain::PublicNockchainGrpcClient, pubkeys: Vec, @@ -1164,6 +1197,155 @@ impl Wallet { let mut slab = NounSlab::new(); Self::wallet("show-master-zprv", &[], Operation::Poke, &mut slab) } + + /// Shows the key tree structure. + fn show_key_tree(include_values: bool) -> CommandNoun { + let mut slab = NounSlab::new(); + let include_values_noun = if include_values { YES } else { NO }; + Self::wallet( + "show-key-tree", + &[include_values_noun], + Operation::Poke, + &mut slab, + ) + } + + fn parse_sign_key_components( + index_str: &str, + hardened_str: Option<&str>, + ) -> Result<(u64, bool), NockAppError> { + let index = index_str.trim().parse::().map_err(|err| { + CrownError::Unknown(format!("Invalid key index '{}': {}", index_str.trim(), err)) + })?; + if index >= 2 << 31 { + return Err(CrownError::Unknown("Key index must not exceed 2^31 - 1".into()).into()); + } + let hardened = if let Some(flag) = hardened_str { + Self::parse_boolish(flag)? + } else { + false + }; + Ok((index, hardened)) + } + + fn parse_boolish(flag: &str) -> Result { + match flag { + "true" | "t" | "1" | "yes" | "y" => Ok(true), + "false" | "f" | "0" | "no" | "n" => Ok(false), + _ => Err(CrownError::Unknown(format!( + "Invalid hardened value '{}', expected true/false", + flag + )) + .into()), + } + } + + fn parse_sign_keys(sign_keys_str: &str) -> Result, NockAppError> { + let mut sign_keys = Vec::new(); + for piece in sign_keys_str.split(',') { + let trimmed = piece.trim(); + if trimmed.is_empty() { + continue; + } + let parts: Vec<&str> = trimmed.split(':').collect(); + if parts.len() != 2 { + return Err(CrownError::Unknown(format!( + "Invalid sign key '{}', expected index:hardened", + trimmed + )) + .into()); + } + sign_keys.push(Self::parse_sign_key_components(parts[0], Some(parts[1]))?); + } + if sign_keys.is_empty() { + return Err( + CrownError::Unknown("At least one sign key must be provided".to_string()).into(), + ); + } + Ok(sign_keys) + } + + fn parse_pubkey_hashes(pubkeys_str: &str) -> Result, NockAppError> { + let pubkeys: Vec = pubkeys_str + .split(',') + .map(|s| { + let trimmed = s.trim(); + if trimmed.is_empty() { + return Err(NockAppError::from(CrownError::Unknown( + "Empty pubkey hash provided in list".into(), + ))); + } + Hash::from_base58(trimmed).map_err(|err| { + NockAppError::from(CrownError::Unknown(format!( + "Invalid pubkey hash '{}': {}", + trimmed, err + ))) + }) + }) + .collect::, NockAppError>>()?; + + if pubkeys.is_empty() { + return Err( + CrownError::Unknown("At least one pubkey hash must be provided".into()).into(), + ); + } + + Ok(pubkeys) + } + + fn sign_multisig_tx( + transaction_path: &str, + sign_keys_str: Option<&str>, + ) -> CommandNoun { + let mut slab = NounSlab::new(); + + let transaction_data = fs::read(transaction_path) + .map_err(|e| CrownError::Unknown(format!("Failed to read transaction file: {}", e)))?; + + let transaction_noun = slab.cue_into(transaction_data.as_bytes()?).map_err(|e| { + CrownError::Unknown(format!("Failed to decode transaction data: {}", e)) + })?; + + let sign_keys_noun = if let Some(sign_keys_str) = sign_keys_str { + let sign_keys = Self::parse_sign_keys(sign_keys_str)?; + sign_keys + .into_iter() + .rev() + .fold(D(0), |acc, (index, hardened)| { + let index_noun = D(index); + let hardened_noun = if hardened { YES } else { NO }; + let pair = T(&mut slab, &[index_noun, hardened_noun]); + Cell::new(&mut slab, pair, acc).as_noun() + }) + } else { + SIG + }; + + Self::wallet( + "sign-multisig-tx", + &[transaction_noun, sign_keys_noun], + Operation::Poke, + &mut slab, + ) + } + + fn show_multisig_tx(transaction_path: &str) -> CommandNoun { + let mut slab = NounSlab::new(); + + let transaction_data = fs::read(transaction_path) + .map_err(|e| CrownError::Unknown(format!("Failed to read transaction file: {}", e)))?; + + let transaction_noun = slab.cue_into(transaction_data.as_bytes()?).map_err(|e| { + CrownError::Unknown(format!("Failed to decode transaction data: {}", e)) + })?; + + Self::wallet( + "show-multisig-tx", + &[transaction_noun], + Operation::Poke, + &mut slab, + ) + } } pub async fn wallet_data_dir() -> Result { @@ -1228,6 +1410,16 @@ fn normalize_watch_address(value: String) -> Result, NockAppError } } +fn normalize_first_name(value: String) -> Result, NockAppError> { + match Hash::from_base58(&value) { + Ok(hash) => Ok(Some(hash.to_base58())), + Err(err) => { + warn!("Skipping invalid first name '{}': {}", value, err); + Ok(None) + } + } +} + async fn run_transaction_accepted( connection: &connection::ConnectionCli, tx_id: &str, @@ -1422,58 +1614,23 @@ mod tests { } #[test] - fn parse_single_output_accepts_valid_spec() { - let (pkh, amount) = Wallet::parse_single_output( - "9phXGACnW4238oqgvn2gpwaUjG3RAqcxq2Ash2vaKp8KjzSd3MQ56Jt:65536", - ) - .expect("valid"); - assert_eq!( - pkh, - "9phXGACnW4238oqgvn2gpwaUjG3RAqcxq2Ash2vaKp8KjzSd3MQ56Jt" - ); - assert_eq!(amount, 65_536); + fn collect_signing_keys_prefers_explicit_entries() { + let entries = vec!["0:true".to_string(), "1:false".to_string()]; + let keys = + Wallet::collect_signing_keys(Some(5), false, &entries).expect("valid explicit keys"); + assert_eq!(keys, vec![(0, true), (1, false)]); } #[test] - fn parse_multiple_output_accepts_valid_spec() -> Result<(), Box> { - let recipients = vec![ - "9phXGACnW4238oqgvn2gpwaUjG3RAqcxq2Ash2vaKp8KjzSd3MQ56Jt:65536".to_string(), - "9yPePjfWAdUnzaQKyxcRXKRa5PpUzKKEwtpECBZsUYt9Jd7egSDEWoV:99999".to_string(), - ]; - let allocations = Wallet::parse_multiple_recipients(&recipients)?; - assert_eq!( - allocations[0], - ( - "9phXGACnW4238oqgvn2gpwaUjG3RAqcxq2Ash2vaKp8KjzSd3MQ56Jt".to_string(), - 65536 - ) - ); - assert_eq!( - allocations[1], - ( - "9yPePjfWAdUnzaQKyxcRXKRa5PpUzKKEwtpECBZsUYt9Jd7egSDEWoV".to_string(), - 99999 - ) - ); - Ok(()) + fn collect_signing_keys_falls_back_to_index() { + let keys = Wallet::collect_signing_keys(Some(3), true, &[]).expect("valid"); + assert_eq!(keys, vec![(3, true)]); } #[test] - fn parse_single_output_rejects_multiple_outputs() { - let err = Wallet::parse_single_output("a:1,b:2").expect_err("expected failure"); - assert!( - err.to_string().contains("Multiple outputs"), - "unexpected error message: {err}" - ); - } - - #[test] - fn parse_single_output_rejects_bad_amount() { - let err = Wallet::parse_single_output("pkh:not-a-number").expect_err("expected failure"); - assert!( - err.to_string().contains("Invalid amount"), - "unexpected error message: {err}" - ); + fn collect_signing_keys_defaults_to_master() { + let keys = Wallet::collect_signing_keys(None, false, &[]).expect("valid"); + assert!(keys.is_empty()); } #[tokio::test] @@ -1567,64 +1724,6 @@ mod tests { Ok(()) } - // TODO make this a real test by creating and signing a real draft - #[tokio::test] - #[ignore] - async fn test_sign_tx() -> Result<(), NockAppError> { - init_tracing(); - let cli = BootCli::parse_from(&[""]); - let nockapp = boot::setup(KERNEL, cli.clone(), &[], "wallet", None) - .await - .map_err(|e| CrownError::Unknown(e.to_string()))?; - let mut wallet = Wallet::new(nockapp); - - // Create a temporary input bundle file - let bundle_path = "test_bundle.jam"; - let test_data = vec![0u8; 32]; // TODO make this a real input bundle - fs::write(bundle_path, &test_data).map_err(|e| NockAppError::IoError(e))?; - - let wire = WalletWire::Command(Commands::SignTx { - transaction: bundle_path.to_string(), - index: None, - hardened: false, - }) - .to_wire(); - - // Test signing with valid indices - let (noun, op) = Wallet::sign_tx(bundle_path, None, false)?; - let sign_result = wallet.app.poke(wire, noun.clone()).await?; - - println!("sign_result: {:?}", sign_result); - - let wire = WalletWire::Command(Commands::SignTx { - transaction: bundle_path.to_string(), - index: Some(1), - hardened: false, - }) - .to_wire(); - - let (noun, op) = Wallet::sign_tx(bundle_path, Some(1), false)?; - let sign_result = wallet.app.poke(wire, noun.clone()).await?; - - println!("sign_result: {:?}", sign_result); - - let wire = WalletWire::Command(Commands::SignTx { - transaction: bundle_path.to_string(), - index: Some(255), - hardened: false, - }) - .to_wire(); - - let (noun, op) = Wallet::sign_tx(bundle_path, Some(255), false)?; - let sign_result = wallet.app.poke(wire, noun.clone()).await?; - - println!("sign_result: {:?}", sign_result); - - // Cleanup - fs::remove_file(bundle_path).map_err(|e| NockAppError::IoError(e))?; - Ok(()) - } - // Tests for Cold Side Commands #[tokio::test] #[cfg_attr(miri, ignore)] @@ -1703,98 +1802,14 @@ mod tests { #[tokio::test] #[ignore] async fn test_spend_multisig_format() -> Result<(), NockAppError> { - init_tracing(); - let cli = BootCli::parse_from(&[""]); - let nockapp = boot::setup(KERNEL, cli.clone(), &[], "wallet", None) - .await - .map_err(|e| CrownError::Unknown(e.to_string()))?; - let mut wallet = Wallet::new(nockapp); - - let names = "[first1 last1],[first2 last2]".to_string(); - let recipients = vec!["pk1:1".to_string()]; - let fee = 1; - - let (noun, op) = Wallet::create_tx( - names.clone(), - recipients.clone(), - fee, - None::, - None, - false, - true, - false, - )?; - let wire = WalletWire::Command(Commands::CreateTx { - names: names.clone(), - recipients: recipients.clone(), - fee: fee.clone(), - refund_pkh: None, - index: None, - hardened: false, - include_data: true, - save_raw_tx: false, - }) - .to_wire(); - let spend_result = wallet.app.poke(wire, noun.clone()).await?; - println!("spend_result: {:?}", spend_result); - + // TODO: replace with an end-to-end test that exercises multisig recipient specs. Ok(()) } #[tokio::test] #[ignore] async fn test_spend_single_sig_format() -> Result<(), NockAppError> { - let cli = BootCli::parse_from(&[""]); - let nockapp = boot::setup(KERNEL, cli.clone(), &[], "wallet", None) - .await - .map_err(|e| CrownError::Unknown(e.to_string()))?; - init_tracing(); - let mut wallet = Wallet::new(nockapp); - - // these should be valid names of notes in the wallet balance - let names = "[Amt4GcpYievY4PXHfffiWriJ1sYfTXFkyQsGzbzwMVzewECWDV3Ad8Q BJnaDB3koU7ruYVdWCQqkFYQ9e3GXhFsDYjJ1vSmKFdxzf6Y87DzP4n]".to_string(); - let recipients = vec!["3HKKp7xZgCw1mhzk4iw735S2ZTavCLHc8YDGRP6G9sSTrRGsaPBu1AqJ8cBDiw2LwhRFnQG7S3N9N9okc28uBda6oSAUCBfMSg5uC9cefhrFrvXVGomoGcRvcFZTWuJzm3ch:100".to_string()]; - let fee = 0; - - // generate keys - let version = 1; - let (genkey_noun, genkey_op) = - Wallet::import_seed_phrase("correct horse battery staple", version)?; - let (spend_noun, spend_op) = Wallet::create_tx( - names.clone(), - recipients.clone(), - fee, - None::, - None, - false, - true, - false, - )?; - - let wire1 = WalletWire::Command(Commands::ImportKeys { - file: None, - key: None, - seedphrase: Some("correct horse battery staple".to_string()), - version: Some(version), - }) - .to_wire(); - let genkey_result = wallet.app.poke(wire1, genkey_noun.clone()).await?; - println!("genkey_result: {:?}", genkey_result); - - let wire2 = WalletWire::Command(Commands::CreateTx { - names: names.clone(), - recipients: recipients.clone(), - fee: fee.clone(), - refund_pkh: None, - index: None, - hardened: false, - include_data: true, - save_raw_tx: false, - }) - .to_wire(); - let spend_result = wallet.app.poke(wire2, spend_noun.clone()).await?; - println!("spend_result: {:?}", spend_result); - + // TODO: replace with an end-to-end test for PKH recipients once fixtures exist. Ok(()) } diff --git a/crates/nockchain-wallet/src/recipient.rs b/crates/nockchain-wallet/src/recipient.rs new file mode 100644 index 000000000..bc8b9ba48 --- /dev/null +++ b/crates/nockchain-wallet/src/recipient.rs @@ -0,0 +1,307 @@ +use std::collections::BTreeSet; + +use nockchain_types::common::Hash; +use noun_serde::{NounDecode, NounEncode}; +use serde::Deserialize; + +use crate::{CrownError, NockAppError}; + +#[derive(Debug, Clone, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum RecipientSpecToken { + P2pkh { + address: String, + amount: u64, + }, + Multisig { + threshold: u64, + addresses: Vec, + amount: u64, + }, +} + +#[derive(Debug, Clone, NounEncode, NounDecode, PartialEq)] +pub enum RecipientSpec { + #[noun(tag = "pkh")] + P2pkh { address: Hash, amount: u64 }, + #[noun(tag = "multisig")] + Multisig { + threshold: u64, + addresses: Vec, + amount: u64, + }, +} + +impl RecipientSpecToken { + pub fn from_cli_arg(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(CrownError::Unknown( + "Recipient specification cannot be empty".into(), + )); + } + if trimmed.starts_with('{') { + return Self::from_json(trimmed); + } + Self::from_legacy(trimmed) + } + + fn from_json(raw: &str) -> Result { + serde_json::from_str(raw).map_err(|err| { + CrownError::Unknown(format!("Failed to parse recipient JSON '{raw}': {err}")) + }) + } + + fn from_legacy(raw: &str) -> Result { + let (address, amount_str) = raw.split_once(':').ok_or_else(|| { + CrownError::Unknown("Legacy recipient must be formatted as :".into()) + })?; + let p2pkh = address.trim(); + if p2pkh.is_empty() { + return Err(CrownError::Unknown( + "Legacy recipient p2pkh cannot be empty".into(), + )); + } + let amount_raw = amount_str.trim(); + let amount = amount_raw.parse::().map_err(|err| { + CrownError::Unknown(format!( + "Invalid amount '{}' in legacy recipient: {err}", + amount_raw + )) + })?; + if amount == 0 { + return Err(CrownError::Unknown( + "Legacy recipient amount must be greater than zero".into(), + )); + } + Ok(RecipientSpecToken::P2pkh { + address: p2pkh.to_string(), + amount, + }) + } + + pub fn into_recipient_spec(self) -> Result { + match self { + RecipientSpecToken::P2pkh { address, amount } => { + if amount == 0 { + return Err(CrownError::Unknown( + "Recipient amount must be greater than zero".into(), + ) + .into()); + } + let recipient = Hash::from_base58(&address).map_err(|err| { + NockAppError::from(CrownError::Unknown(format!( + "Invalid recipient address '{address}': {err}" + ))) + })?; + Ok(RecipientSpec::P2pkh { + address: recipient, + amount, + }) + } + RecipientSpecToken::Multisig { + threshold, + addresses, + amount, + } => { + if amount == 0 { + return Err(CrownError::Unknown( + "Recipient amount must be greater than zero".into(), + ) + .into()); + } + if threshold == 0 { + return Err(CrownError::Unknown( + "Multisig threshold must be greater than zero".into(), + ) + .into()); + } + if addresses.is_empty() { + return Err(CrownError::Unknown( + "Multisig recipient must include at least one address".into(), + ) + .into()); + } + let mut unique = BTreeSet::new(); + let parsed = addresses + .into_iter() + .map(|pkh| { + if !unique.insert(pkh.clone()) { + return Err(NockAppError::from(CrownError::Unknown( + "Multisig recipients cannot include duplicate addresses".into(), + ))); + } + Hash::from_base58(&pkh).map_err(|err| { + NockAppError::from(CrownError::Unknown(format!( + "Invalid multisig address '{pkh}': {err}" + ))) + }) + }) + .collect::, _>>()?; + if threshold as usize > parsed.len() { + return Err( + CrownError::Unknown(format!( + "Multisig threshold ({threshold}) cannot exceed the number of addresses ({})", + parsed.len() + )) + .into(), + ); + } + Ok(RecipientSpec::Multisig { + threshold, + addresses: parsed, + amount, + }) + } + } + } +} + +pub fn parse_recipient_arg(raw: &str) -> Result { + RecipientSpecToken::from_cli_arg(raw).map_err(|err| err.to_string()) +} + +pub fn recipient_tokens_to_specs( + tokens: Vec, +) -> Result, NockAppError> { + if tokens.is_empty() { + return Err(CrownError::Unknown("At least one --recipient must be provided".into()).into()); + } + tokens + .into_iter() + .map(|token| token.into_recipient_spec()) + .collect() +} + +#[cfg(test)] +mod tests { + use nockapp::noun::slab::{NockJammer, NounSlab}; + use nockvm::noun::FullDebugCell; + use noun_serde::NounDecode; + + use super::*; + + const SAMPLE_P2PKH: &str = "9yPePjfWAdUnzaQKyxcRXKRa5PpUzKKEwtpECBZsUYt9Jd7egSDEWoV"; + const SAMPLE_P2PKH_ALT: &str = "9phXGACnW4238oqgvn2gpwaUjG3RAqcxq2Ash2vaKp8KjzSd3MQ56Jt"; + + #[test] + fn parse_recipient_arg_accepts_json_p2pkh() { + let raw = format!( + "{{\"kind\":\"p2pkh\",\"address\":\"{}\",\"amount\":42}}", + SAMPLE_P2PKH + ); + let token = RecipientSpecToken::from_cli_arg(&raw).expect("json p2pkh parses"); + assert!(matches!(token, RecipientSpecToken::P2pkh { amount, .. } if amount == 42)); + } + + #[test] + fn parse_recipient_arg_accepts_json_multisig() { + let raw = format!( + "{{\"kind\":\"multisig\",\"threshold\":2,\"addresses\":[\"{}\",\"{}\"],\"amount\":9000}}", + SAMPLE_P2PKH, SAMPLE_P2PKH_ALT + ); + let token = RecipientSpecToken::from_cli_arg(&raw).expect("json multisig parses"); + assert!(matches!( + token, + RecipientSpecToken::Multisig { + threshold, amount, .. + } if threshold == 2 && amount == 9000 + )); + } + + #[test] + fn parse_recipient_arg_accepts_legacy() { + let token = RecipientSpecToken::from_cli_arg(&format!("{SAMPLE_P2PKH}:7")) + .expect("legacy recipient parses"); + assert!(matches!( + token, + RecipientSpecToken::P2pkh { amount, .. } if amount == 7 + )); + } + + #[test] + fn parse_recipient_arg_rejects_empty() { + let err = RecipientSpecToken::from_cli_arg(" ").expect_err("empty spec should fail"); + assert!(format!("{err}").contains("cannot be empty")); + } + + #[test] + fn recipient_tokens_to_specs_builds_structs() { + let tokens = vec![ + RecipientSpecToken::P2pkh { + address: SAMPLE_P2PKH.to_string(), + amount: 1000, + }, + RecipientSpecToken::Multisig { + threshold: 1, + addresses: vec![SAMPLE_P2PKH_ALT.to_string(), SAMPLE_P2PKH.to_string()], + amount: 5, + }, + ]; + let specs = recipient_tokens_to_specs(tokens).expect("tokens -> specs"); + assert_eq!(specs.len(), 2); + match &specs[0] { + RecipientSpec::P2pkh { address, amount } => { + assert_eq!(*amount, 1000); + assert_eq!( + address, + &Hash::from_base58(SAMPLE_P2PKH).expect("sample p2pkh hash") + ); + } + _ => panic!("first spec should be p2pkh"), + } + match &specs[1] { + RecipientSpec::Multisig { + threshold, + addresses, + amount, + } => { + assert_eq!(*threshold, 1); + assert_eq!(*amount, 5); + assert_eq!(addresses.len(), 2); + assert_eq!( + addresses[0], + Hash::from_base58(SAMPLE_P2PKH_ALT).expect("sample alt hash") + ); + assert_eq!( + addresses[1], + Hash::from_base58(SAMPLE_P2PKH).expect("sample alt hash") + ); + } + _ => panic!("second spec should be multisig"), + } + } + + #[test] + fn recipient_tokens_to_specs_rejects_empty() { + let err = recipient_tokens_to_specs(vec![]).expect_err("missing recipients"); + assert!(format!("{err}").contains("At least one --recipient")); + } + + #[test] + fn recipient_spec_roundtrips_via_noun() { + let specs = vec![ + RecipientSpec::P2pkh { + address: Hash::from_base58(SAMPLE_P2PKH).expect("p2pkh hash"), + amount: 10, + }, + RecipientSpec::Multisig { + threshold: 1, + addresses: vec![ + Hash::from_base58(SAMPLE_P2PKH_ALT).expect("alt hash"), + Hash::from_base58(SAMPLE_P2PKH).expect("p2pkh hash"), + ], + amount: 20, + }, + ]; + + let mut slab = NounSlab::::new(); + for spec in specs { + let noun = spec.to_noun(&mut slab); + eprintln!("spec noun: {:?}", FullDebugCell(&noun.as_cell().unwrap())); + let decoded = + RecipientSpec::from_noun(&noun).expect("recipient spec should decode from noun"); + assert_eq!(decoded, spec); + } + } +} diff --git a/crates/nockchain/src/mining.rs b/crates/nockchain/src/mining.rs index 8397e650d..886e02bca 100644 --- a/crates/nockchain/src/mining.rs +++ b/crates/nockchain/src/mining.rs @@ -125,7 +125,7 @@ pub fn create_mining_driver( share: 1, m: 1, // hardcoded key to satisfy pass-through for v0 pubkey mining infra - keys: vec!["2qwq9dQRZfpFx8BDicghpMRnYGKZsZGxxhh9m362pzpM9aeo276pR1yHZPS41y3CW3vPKxeYM8p8fzZS8GXmDGzmNNCnVNekjrSYogqfEFMqwhHh5iCjaKPaDTwhupWqiXj6".to_string()], + keys: vec!["2cPnE4Z9RevhTv9is9Hmc1amFubEFbUxzCV2Fxb9GxevJstV5VG92oYt6Sai3d3NjLFcsuVXSLx9hikMbD1agv9M267TVw3hV9MCpMfEnGo5LYtjJ7jPyHg8SERPjJRCWTgZ".to_string()], }); let Some(pkh_configs) = mining_pkh_config else { diff --git a/crates/noun-serde-derive/src/lib.rs b/crates/noun-serde-derive/src/lib.rs index f4d1c0395..a62cd7b05 100644 --- a/crates/noun-serde-derive/src/lib.rs +++ b/crates/noun-serde-derive/src/lib.rs @@ -678,6 +678,8 @@ pub fn derive_noun_decode(input: TokenStream) -> TokenStream { .map(|f| &f.ty) .collect(); + let variant_name_str = variant_name.to_string(); + if is_tagged { // Tagged decoding: [%tag [[%field1 value1] [%field2 value2] ...]] let variant_name_str = variant_name.to_string(); @@ -740,23 +742,86 @@ pub fn derive_noun_decode(input: TokenStream) -> TokenStream { } } } else { - // Untagged decoding: [%tag value1 value2 ...] + let num_fields = field_names.len(); + let field_decoders = field_names.iter().zip(field_types.iter()).enumerate() + .map(|(i, (name, ty))| { + let field = fields.named.iter().find(|f| { + f.ident.as_ref().unwrap().to_string() == name.to_string() + }).unwrap(); + + let custom_axis = parse_axis_attr(&field.attrs); + + let default_axis = if i == 0 { + 2 // first field at axis 2 + } else if i == num_fields - 1 { + let mut axis = 2; + for _ in 1..i { + axis = 2 * axis + 2; + } + axis + 1 + } else { + let mut axis = 2; + for _ in 1..=i { + axis = 2 * axis + 2; + } + axis + }; + + let axis = custom_axis.unwrap_or(default_axis); + let field_name_str = name.to_string(); + quote! { + ::tracing::trace!(target: "noun_serde_decode", " variant={} field={} type={} axis={}", #variant_name_str, #field_name_str, stringify!(#ty), #axis); + let field_noun = ::nockvm::noun::Slots::slot(&data_cell, #axis) + .map_err(|e| { + ::tracing::trace!(target: "noun_serde_decode", " FAILED to get slot {} for field {} in variant {}: {:?}", #axis, #field_name_str, #variant_name_str, e); + ::noun_serde::NounDecodeError::ExpectedCell + })?; + ::tracing::trace!(target: "noun_serde_decode", " variant={} field={} is_atom={} is_cell={}", #variant_name_str, #field_name_str, field_noun.is_atom(), field_noun.is_cell()); + let #name = <#ty as ::noun_serde::NounDecode>::from_noun(&field_noun) + .map_err(|e| { + ::tracing::trace!(target: "noun_serde_decode", " FAILED decoding field {} in variant {}: {:?}", #field_name_str, #variant_name_str, e); + e + })?; + ::tracing::trace!(target: "noun_serde_decode", " SUCCESS decoded field {} in variant {}", #field_name_str, #variant_name_str); + } + }); + + let payload_atom_handler = if num_fields == 1 { + let field_name = field_names[0]; + let field_type = field_types[0]; + let field_name_str = field_name.to_string(); + quote! { + ::tracing::trace!(target: "noun_serde_decode", "Matched variant {} (untagged named fields, payload atom)", #variant_name_str); + let #field_name = <#field_type as ::noun_serde::NounDecode>::from_noun(&payload) + .map_err(|e| { + ::tracing::trace!(target: "noun_serde_decode", " FAILED decoding field {} in variant {}: {:?}", #field_name_str, #variant_name_str, e); + e + })?; + ::tracing::trace!(target: "noun_serde_decode", "SUCCESS decoded variant {}", #variant_name_str); + Ok(Self::#variant_name { #field_name }) + } + } else { + quote! { + ::tracing::trace!(target: "noun_serde_decode", "FAILED variant {} payload atom but multiple fields expected", #variant_name_str); + Err(::noun_serde::NounDecodeError::ExpectedCell) + } + }; + quote! { tag if tag == #tag => { + ::tracing::trace!(target: "noun_serde_decode", "Matched variant {} (untagged named fields)", #variant_name_str); if let Ok(cell) = noun.as_cell() { - let tail = cell.tail(); - let tail_cell = tail.as_cell()?; - #( - let #field_names = <#field_types as ::noun_serde::NounDecode>::from_noun( - &if stringify!(#field_names) == stringify!(x) { - tail_cell.head() - } else { - tail_cell.tail() - } - )?; - )* - Ok(Self::#variant_name { #(#field_names),* }) + let payload = cell.tail(); + if let Ok(payload_cell) = payload.as_cell() { + let data_cell = payload_cell; + #(#field_decoders)* + ::tracing::trace!(target: "noun_serde_decode", "SUCCESS decoded variant {}", #variant_name_str); + Ok(Self::#variant_name { #(#field_names),* }) + } else { + #payload_atom_handler + } } else { + ::tracing::trace!(target: "noun_serde_decode", "FAILED variant {} expected cell", #variant_name_str); Err(::noun_serde::NounDecodeError::ExpectedCell) } } diff --git a/crates/noun-serde-derive/tests/untagged_enum_regression.rs b/crates/noun-serde-derive/tests/untagged_enum_regression.rs new file mode 100644 index 000000000..92ac917e1 --- /dev/null +++ b/crates/noun-serde-derive/tests/untagged_enum_regression.rs @@ -0,0 +1,39 @@ +use nockvm::mem::NockStack; +use noun_serde::{NounDecode, NounEncode}; + +#[derive(Debug, Clone, PartialEq, Eq, NounEncode, NounDecode)] +struct Hash(pub [u64; 2]); + +#[derive(Debug, Clone, PartialEq, Eq, NounEncode, NounDecode)] +enum RecipientSpec { + #[noun(tag = "pkh")] + Pkh { hash: Hash, amount: u64 }, + #[noun(tag = "multi")] + Multi { first: u64, second: u64 }, +} + +#[test] +fn untagged_named_variant_decodes_all_fields() { + let mut stack = NockStack::new(8 << 10 << 10, 0); + let expected = RecipientSpec::Pkh { + hash: Hash([0x1234, 0x5678]), + amount: 42, + }; + let noun = expected.to_noun(&mut stack); + + let decoded = RecipientSpec::from_noun(&noun).expect("recipient spec decodes"); + assert_eq!(decoded, expected); +} + +#[test] +fn untagged_named_variant_with_multiple_fields_decodes_all_fields() { + let mut stack = NockStack::new(8 << 10 << 10, 0); + let expected = RecipientSpec::Multi { + first: 0xaaaa, + second: 0xbbbb, + }; + let noun = expected.to_noun(&mut stack); + + let decoded = RecipientSpec::from_noun(&noun).expect("multi recipient spec decodes"); + assert_eq!(decoded, expected); +} diff --git a/hoon/apps/wallet/lib/tx-builder-v1.hoon b/hoon/apps/wallet/lib/tx-builder-v1.hoon deleted file mode 100644 index 06448c05f..000000000 --- a/hoon/apps/wallet/lib/tx-builder-v1.hoon +++ /dev/null @@ -1,233 +0,0 @@ -/= transact /common/tx-engine -/= utils /apps/wallet/lib/utils -/= wt /apps/wallet/lib/types -/= zo /common/zoon -:: -:: Builds a simple fan-in transaction -|= $: names=(list nname:transact) - orders=(list order:wt) - fee=coins:transact - sign-key=schnorr-seckey:transact - pubkey=schnorr-pubkey:transact - refund-pkh=(unit hash:transact) - get-note=$-(nname:transact nnote:transact) - include-data=? - == -|^ -^- [spends:v1:transact hash:transact] -?: (lien orders |=(ord=order:wt =(0 gift.ord))) - ~|('Cannot create a transaction with zero gift!' !!) -?: =(orders ~) - ~|("Cannot create a transaction with empty order" !!) -=/ sender-pkh=hash:transact (hash:schnorr-pubkey:transact pubkey) -=/ notes=(list nnote:transact) (turn names get-note) -:: TODO: unify functions across versions. There's too much repetition -=/ [=spends:v1:transact =hash:transact] - :: If all notes are v0 - ?: (levy notes |=(=nnote:transact ?=(^ -.nnote))) - ?~ refund-pkh - ~|('Need to specify a refund address if spending from v0 notes. Use the `--refund-pkh` flag in the create-tx command' !!) - =/ notes=(list nnote:v0:transact) - %+ turn notes - |= =nnote:transact - ?> ?=(^ -.nnote) - nnote - =. notes - %+ sort notes - |= [a=nnote:v0:transact b=nnote:v0:transact] - (gth assets.a assets.b) - [(create-spends-0 notes) u.refund-pkh] - :: If all notes are v1 - ?: (levy notes |=(=nnote:transact ?=(@ -.nnote))) - =/ notes=(list nnote-1:v1:transact) - %+ turn notes - |= =nnote:transact - ?> ?=(@ -.nnote) - nnote - =. notes - %+ sort notes - |= [a=nnote-1:v1:transact b=nnote-1:v1:transact] - (gth assets.a assets.b) - :: If a refund-pkh is passed in, use that. Otherwise, default to pkh - =/ refund-pkh=hash:transact (fall refund-pkh sender-pkh) - [(create-spends-1 notes) refund-pkh] - :: - :: I don't want to do this, but the fact that we're constrained to a single master seckey - :: means no mixing versions in single spends. - :: - ~> %slog.[0 'Notes must all be the same version!!!'] !! -=+ min-fee=(calculate-min-fee:spends:transact spends) -?: (lth fee min-fee) - ~|("Min fee not met. This transaction requires at least: {(trip (format-ui:common:display:utils min-fee))} nicks" !!) -[spends hash] -:: -++ create-spends-0 - |= notes=(list nnote:v0:transact) - =; [=spends:v1:transact remaining=[fee=@ orders=(list order:wt)]] - ?. ?& =(~ orders.remaining) - =(0 fee.remaining) - == - ~|('Insufficient funds to pay fee and gift' !!) - spends - %+ roll notes - |= $: note=nnote:v0:transact - =spends:v1:transact - remaining=_[fee=fee orders=orders] - == - ?. ?& =(1 m.sig.note) - (~(has z-in:zo pubkeys.sig.note) pubkey) - == - ~> %slog.[0 'Note not spendable by signing key'] !! - =/ res (allocate-from-note orders.remaining note assets.note fee.remaining) - =/ [new-orders=(list order:wt) specs=(list order:wt) new-fee=@] res - :: skip if no seeds would be created (protocol requires >=1 seed) - :: do not update fees or orders - ?: =(~ specs) - [spends remaining] - =/ fee-portion=@ (sub fee.remaining new-fee) - :: turn specs (recipient,gift) into v1 seeds - =/ =seeds:v1:transact (seeds-from-specs specs note fee-portion) - ?~ seeds - ~|('No seeds were provided' !!) - =/ spend=spend-0:v1:transact - %* . *spend-0:v1:transact - seeds seeds - fee fee-portion - == - :_ [fee=new-fee orders=new-orders] - %- ~(put z-by:zo spends) - [name.note (sign:spend-v1:transact [%0 spend] sign-key)] -:: -++ create-spends-1 - |= notes=(list nnote-1:v1:transact) - =; [=spends:v1:transact remaining=[fee=@ orders=(list order:wt)]] - ?. ?& =(~ orders.remaining) - =(0 fee.remaining) - == - ~|('Insufficient funds to pay fee and gift' !!) - spends - =/ pkh=hash:transact (hash:schnorr-pubkey:transact pubkey) - %+ roll notes - |= $: note=nnote-1:v1:transact - =spends:v1:transact - remaining=_[fee=fee orders=orders] - == - =/ nd=(unit note-data:v1:transact) ((soft note-data:v1:transact) note-data.note) - ?~ nd - ~> %slog.[0 'error: note-data malformed in note!'] !! - =/ coinbase-lock=spend-condition:transact (coinbase-pkh-sc:v1:first-name:transact pkh) - =/ input-lock=(reason:transact lock:transact) - :: if there is no lock noun, default to coinbase lock - ?~ parent-lock=(pull-lock:locks:utils [u.nd name.note (some pkh)]) - [%.n 'the first name of the note did not correspond to a simple-pkh or coinbase'] - [%.y u.parent-lock] - ?: ?=(%.n -.input-lock) - =+ name-cord=(name:v1:display:utils name.note) - ~& "Error processing note {}. Reason: {(trip p.input-lock)}." !! - :: fan-out gifts + fee for this v1 note (reuse shared gates) - =/ res (allocate-from-note orders.remaining note assets.note fee.remaining) - =/ [new-orders=(list order:wt) specs=(list order:wt) new-fee=@] res - :: skip if no seeds would be created (protocol requires >=1 seed) - ?: =(~ specs) - [spends remaining] - =/ fee-portion=@ (sub fee.remaining new-fee) - :: build v1 seeds from specs (recipient,gift) - =/ =seeds:v1:transact (seeds-from-specs specs note fee-portion) - ?~ seeds - ~|('No seeds were provided' !!) - :: prove input lock and emit spend-1 with fee-portion - =/ lmp=lock-merkle-proof:transact - (build-lock-merkle-proof:lock:transact p.input-lock 1) - =/ spend=spend-1:v1:transact - %* . *spend-1:v1:transact - seeds seeds - fee fee-portion - == - =. witness.spend - %* . *witness:transact - lmp lmp - == - :_ [fee=new-fee orders=new-orders] - %- ~(put z-by:zo spends) - [name.note (sign:spend-v1:transact [%1 spend] sign-key)] -:: -++ create-refund - |= [note=nnote:transact refund=@] - ^- seed:v1:transact - =/ refund-lp=lock-primitive:transact - ?^ refund-pkh - [%pkh [m=1 (z-silt:zo ~[u.refund-pkh])]] - =/ sender-pkh=hash:transact (hash:schnorr-pubkey:transact pubkey) - [%pkh [m=1 (z-silt:zo ~[sender-pkh])]] - =/ lok=lock:transact ~[refund-lp] - =/ =note-data:v1:transact - ?. include-data - ~ - %- ~(put z-by:zo *note-data:v1:transact) - [%lock ^-(lock-data:wt [%0 lok])] - :* output-source=~ - lock-root=(hash:lock:transact lok) - note-data - gift=refund - parent-hash=(hash:nnote:transact note) - == -++ allocate-from-note - |= [orders=(list order:wt) note=nnote:transact assets=@ fee=@] - ^- [orders=(list order:wt) seeds=(list order:wt) fee=@] - :: fill gifts greedily left-to-right - =/ [remaining-orders=(list order:wt) out=(list order:wt) rem=@] - %+ roll orders - |= $: ord=order:wt - acc=_[remaining-orders=`(list order:wt)`~ out=`(list order:wt)`~ rem=`@`assets] - == - ?: =(0 rem.acc) - [remaining-orders=[ord remaining-orders.acc] out=out.acc rem=rem.acc] - =/ take=@ (min gift.ord rem.acc) - =. rem.acc (sub rem.acc take) - =. out.acc [[recipient=recipient.ord gift=take] out.acc] - =? remaining-orders.acc (lth take gift.ord) - [[recipient=recipient.ord gift=(sub gift.ord take)] remaining-orders.acc] - acc - :: pay fee from remainder (post-gift), then refund any tail - =/ fee-portion=@ (min fee rem) - =/ refund=@ (sub rem fee-portion) - =? out (gth refund 0) - =/ refund-recipient=hash:transact - ?^ refund-pkh - u.refund-pkh - (hash:schnorr-pubkey:transact pubkey) - [[recipient=refund-recipient gift=refund] out] - :: emit remaining orders, outputs which will translate into seeds, and remaining fee - [remaining-orders out (sub fee fee-portion)] -:: Build v1 seeds from (recipient,gift) specs for a given input note (any version). -++ seeds-from-specs - |= $: specs=(list order:wt) - note=nnote:transact - fee-portion=@ - == - ^- seeds:v1:transact - =/ [seeds=(list seed:v1:transact) gifts=@] - %+ roll specs - |= $: spec=order:wt - _acc=[seeds=`(list seed:v1:transact)`~ gifts=0] - == - =/ output-lock=lock:transact - [%pkh [m=1 (z-silt:zo ~[recipient.spec])]]~ - =/ nd=note-data:v1:transact - ?. include-data - ~ - %- ~(put z-by:zo *note-data:v1:transact) - [%lock ^-(lock-data:wt [%0 output-lock])] - :_ (add gifts.acc gift.spec) - :_ seeds.acc - :* output-source=~ - lock-root=(hash:lock:transact output-lock) - note-data=nd - gift=gift.spec - parent-hash=(hash:nnote:transact note) - == - ~| "assets in must equal gift + fee + refund" - ?> =(assets.note (add gifts fee-portion)) - %- z-silt:zo - seeds --- diff --git a/hoon/apps/wallet/lib/tx-builder.hoon b/hoon/apps/wallet/lib/tx-builder.hoon new file mode 100644 index 000000000..19d0aebb1 --- /dev/null +++ b/hoon/apps/wallet/lib/tx-builder.hoon @@ -0,0 +1,466 @@ +/= transact /common/tx-engine +/= utils /apps/wallet/lib/utils +/= wt /apps/wallet/lib/types +/= zo /common/zoon +:: +:: Builds a fan-in transaction that can emit both simple PKH and multisig locks. +|= $: names=(list nname:transact) + orders=(list order:wt) + fee=coins:transact + sign-keys=(list schnorr-seckey:transact) + refund-pkh=(unit hash:transact) + get-note=$-(nname:transact nnote:transact) + include-data=? + == +|^ +^- $: spends:v1:transact + witness-data:wt + display=transaction-display:wt + == +=+ orders-valid=(orders-valid orders) +?: ?=(%.n -.orders-valid) + ~|("One or more orders are invalid. Reason: {}" !!) +=/ signer-pubkeys=(list schnorr-pubkey:transact) + %+ turn sign-keys + |= sk=schnorr-seckey:transact + %- from-sk:schnorr-pubkey:transact + (to-atom:schnorr-seckey:transact sk) +?~ signer-pubkeys + ~|("At least one signing key is required" !!) +=/ sender-pubkey=schnorr-pubkey:transact i.signer-pubkeys +=/ sender-pkh=hash:transact (hash:schnorr-pubkey:transact sender-pubkey) +=/ notes=(list nnote:transact) (turn names get-note) +:: If all notes are v0 +=/ [raw-spends=spends:v1:transact =witness-data:wt display=transaction-display:wt] + ?: (levy notes |=(=nnote:transact ?=(^ -.nnote))) + ?~ refund-pkh + ~|('Need to specify a refund address if spending from v0 notes. Use the `--refund-pkh` flag in the create-tx command' !!) + =/ notes-v0=(list nnote:v0:transact) + %+ turn notes + |= =nnote:transact + ?> ?=(^ -.nnote) + nnote + =. notes-v0 + %+ sort notes-v0 + |= [a=nnote:v0:transact b=nnote:v0:transact] + (gth assets.a assets.b) + =/ refund-lock=lock:transact [%pkh [m=1 (z-silt:zo ~[u.refund-pkh])]]~ + (create-spends-0 notes-v0 orders fee sender-pubkey refund-lock) + :: If all notes are v1 + ?: (levy notes |=(=nnote:transact ?=(@ -.nnote))) + =/ notes-v1=(list nnote-1:v1:transact) + %+ turn notes + |= =nnote:transact + ?> ?=(@ -.nnote) + nnote + =. notes-v1 + %+ sort notes-v1 + |= [a=nnote-1:v1:transact b=nnote-1:v1:transact] + (gth assets.a assets.b) + =/ multisig-lock=(unit lock:transact) + :: + :: ensure that all multisig locks are the same in the input notes + |- + ?~ notes-v1 ~ + ?^ lok=(multisig-lock i.notes-v1) + =+ ref-fn=(first:nname:v1:transact (hash:lock:transact u.lok)) + ?: %+ levy `(list nnote-1:v1:transact)`notes-v1 + |= note=nnote-1:v1:transact + =(ref-fn ~(first-name get:nnote:transact note)) + lok + ~|('Multisig detected in input. When a multisig is present, all inputs must share the same lock.' !!) + $(notes-v1 t.notes-v1) + =/ refund-lock=lock:transact + ?^ refund-pkh + [%pkh [m=1 (z-silt:zo ~[u.refund-pkh])]]~ + %+ fall multisig-lock + [%pkh [m=1 (z-silt:zo ~[sender-pkh])]]~ + (create-spends-1 notes-v1 orders fee sender-pkh refund-lock) +:: +~> %slog.[0 'Notes must all be the same version!!!'] !! +:: +=+ min-fee=(spends:estimate-fee:utils raw-spends inputs.display) +:: uncomment to debug out of band fee estimation +:: =+ min-fee-ref=(calculate-min-fee:spends:transact (apply:witness-data:wt witness-data raw-spends)) +:: ~& min-fee-est+min-fee +:: ~& min-fee-ref+min-fee-ref +?: (lth fee min-fee) + ~|("Min fee not met. This transaction requires at least: {(trip (format-ui:common:display:utils min-fee))} nicks" !!) + [raw-spends witness-data display] +:: +:: helpers for building display metadata +:: +++ update-display-0 + |= $: note=nnote:v0:transact + display=transaction-display:wt + addition=output-lock-map:wt + == + ^- transaction-display:wt + ?> ?=(%0 -.inputs.display) + %= display + outputs + (~(uni z-by:zo outputs.display) addition) + :: + inputs + :- %0 + %- ~(put z-by:zo p.inputs.display) + [name.note sig.note] + == +:: +++ update-display-1 + |= $: name=nname:transact + display=transaction-display:wt + addition=output-lock-map:wt + =lock:transact + == + ^- transaction-display:wt + ?> ?=(%1 -.inputs.display) + %= display + outputs + (~(uni z-by:zo outputs.display) addition) + :: + inputs + :- %1 + %- ~(put z-by:zo p.inputs.display) + :: assert that the lock is a spend-condition + ?> ?=(^ -.lock) + [name lock] + == +:: +++ create-spends-0 + |= $: notes=(list nnote:v0:transact) + orders=(list order:wt) + fee=@ + pubkey=schnorr-pubkey:transact + refund-lock=lock:transact + == + ^- [=spends:v1:transact witness-data:wt transaction-display:wt] + =/ initial-state=spend-build-state:wt + %* . *spend-build-state:wt + fee fee + orders orders + wd [%0 ~] + display [[%0 ~] ~] + == + =/ final-state + (process-spends-0 notes initial-state pubkey refund-lock) + =+ remaining-orders=orders.final-state + =+ remaining-fee=fee.final-state + ?. ?& =(~ remaining-orders) + =(0 remaining-fee) + == + ~|('Insufficient funds to pay fee and gift' !!) + [spends.final-state wd.final-state display.final-state] +:: +++ process-spends-0 + |= $: notes=(list nnote:v0:transact) + state=spend-build-state:wt + pubkey=schnorr-pubkey:transact + refund-lock=lock:transact + == + ^- spend-build-state:wt + ?~ notes + state + =/ note i.notes + ?. ?& =(1 m.sig.note) + (~(has z-in:zo pubkeys.sig.note) pubkey) + == + ~> %slog.[0 'Note not spendable by signing key'] !! + =/ [pending-orders=(list order:wt) specs=(list order:wt) remainder=@] + (allocate-orders orders.state assets.note) + =/ fee-portion=@ (min fee.state remainder) + =/ new-fee=@ (sub fee.state fee-portion) + =/ refund=@ (sub remainder fee-portion) + =? specs !=(refund 0) + [(build-refund-order refund refund-lock) specs] + ?: =(~ specs) + %= $ + notes t.notes + == + =/ [=seeds:v1:transact output-map=output-lock-map:wt] + (seeds-from-specs specs note fee-portion) + ?~ seeds + ~|('No seeds were provided' !!) + =/ spend=spend-0:v1:transact + %* . *spend-0:v1:transact + seeds seeds + fee fee-portion + == + %= $ + notes t.notes + spends.state (~(put z-by:zo spends.state) [name.note [%0 spend]]) + fee.state new-fee + orders.state pending-orders + display.state (update-display-0 note display.state output-map) + wd.state (sign-spend name.note [%0 spend] wd.state) + == +:: +++ create-spends-1 + |= $: notes=(list nnote-1:v1:transact) + orders=(list order:wt) + fee=@ + sender-pkh=hash:transact + refund-lock=lock:transact + == + ^- [=spends:v1:transact witness-data:wt transaction-display:wt] + =/ initial-state=spend-build-state:wt + %* . *spend-build-state:wt + fee fee + orders orders + wd [%1 ~] + display [[%1 ~] ~] + == + =/ final-state + (process-spends-1 notes initial-state sender-pkh refund-lock) + =+ remaining-orders=orders.final-state + =+ remaining-fee=fee.final-state + ?. ?& =(~ remaining-orders) + =(0 remaining-fee) + == + ~|('Insufficient funds to pay fee and gift' !!) + [spends.final-state wd.final-state display.final-state] +:: +++ process-spends-1 + |= $: notes=(list nnote-1:v1:transact) + state=spend-build-state:wt + sender-pkh=hash:transact + refund-lock=lock:transact + == + ^- spend-build-state:wt + ?~ notes + state + =/ note i.notes + =/ nd=(unit note-data:v1:transact) + ((soft note-data:v1:transact) note-data.note) + ?~ nd + ~> %slog.[0 'error: note-data malformed in note!'] !! + =+ pulled=(pull-lock:locks:utils [u.nd name.note (some sender-pkh)]) + ?~ pulled + =+ name-cord=(name:v1:display:utils name.note) + ~| "Error processing note {}. Reason: first-name did not correspond to a supported lock." !! + =/ input-lock=lock:transact u.pulled + =/ allocation (allocate-orders orders.state assets.note) + =/ [pending-orders=(list order:wt) specs=(list order:wt) remainder=@] + allocation + =/ fee-portion=@ (min fee.state remainder) + =/ new-fee=@ (sub fee.state fee-portion) + =/ refund=@ (sub remainder fee-portion) + =/ specs-with-refund=(list order:wt) + ?: =(refund 0) + specs + [(build-refund-order refund refund-lock) specs] + ?: =(~ specs-with-refund) + $(notes t.notes) + =/ [=seeds:v1:transact output-map=output-lock-map:wt] + (seeds-from-specs specs-with-refund note fee-portion) + ?~ seeds + ~|('No seeds were provided' !!) + =/ lmp=lock-merkle-proof:transact + (build-lock-merkle-proof:lock:transact input-lock 1) + =/ spend=spend-1:v1:transact + %* . *spend-1:v1:transact + seeds seeds + fee fee-portion + == + =. witness.spend + %* . *witness:transact + lmp lmp + == + %= $ + notes t.notes + spends.state (~(put z-by:zo spends.state) [name.note [%1 spend]]) + fee.state new-fee + orders.state pending-orders + display.state (update-display-1 name.note display.state output-map input-lock) + wd.state (sign-spend name.note [%1 spend] wd.state) + == +++ sign-spend + |= [name=nname:transact =spend:v1:transact wd=witness-data:wt] + ^- witness-data:wt + ?- -.spend + %0 + ?> ?=(%0 -.wd) + :- %0 + %+ ~(put z-by:zo p.wd) + name + =+ sig-hash=(sig-hash:spend:v1:transact spend) + %+ roll sign-keys + |= $: sk=schnorr-seckey:transact + acc=_signature.spend + == + (sign:signature:transact acc sk sig-hash) + :: + %1 + ?> ?=(%1 -.wd) + :- %1 + %+ ~(put z-by:zo p.wd) + name + =+ sig-hash=(sig-hash:spend:v1:transact spend) + %+ roll sign-keys + |= $: sk=schnorr-seckey:transact + acc=_witness.spend + == + (sign:witness:transact acc sk sig-hash) + == +:: +++ allocate-orders + |= [orders=(list order:wt) assets=@] + ^- [orders=(list order:wt) specs=(list order:wt) remainder=@] + %+ roll orders + |= $: ord=order:wt + next-orders=(list order:wt) + out-orders=(list order:wt) + rem=_assets + == + ?: =(0 rem) + [[ord next-orders] out-orders rem] + =/ gift-out (order-gift ord) + =/ take=@ (min gift-out rem) + =. rem (sub rem take) + =. out-orders [(with-gift ord take) out-orders] + =? next-orders (lth take gift-out) + [(with-gift ord (sub gift-out take)) next-orders] + [next-orders out-orders rem] +:: +++ seeds-from-specs + |= $: specs=(list order:wt) + note=nnote:transact + fee-portion=@ + == + ^- [seeds:v1:transact output-lock-map:wt] + =; [seeds=(list seed:v1:transact) total-gifts=@ =output-lock-map:wt] + ~| "assets in must equal gift + fee + refund" + ?> =(assets.note (add total-gifts fee-portion)) + [(z-silt:zo seeds) output-lock-map] + %+ roll specs + |= $: spec=order:wt + seeds=(list seed:v1:transact) + gifts=@ + =output-lock-map:wt + == + =/ output-lock=lock:transact (order-lock spec) + =? include-data ?=(%multisig -.spec) + %.y + =/ nd=note-data:v1:transact + ?. include-data + ~ + %- ~(put z-by:zo *note-data:v1:transact) + [%lock ^-(lock-data:wt [%0 output-lock])] + =/ seed=seed:v1:transact + :* output-source=~ + lock-root=(hash:lock:transact output-lock) + note-data=nd + gift=(order-gift spec) + parent-hash=(hash:nnote:transact note) + == + =/ metadata=lock-metadata:wt + [output-lock include-data] + :* [seed seeds] + (add gifts (order-gift spec)) + %- ~(put z-by:zo output-lock-map) + [(first:nname:transact lock-root.seed) metadata] + == +:: +++ orders-valid + |= orders=(list order:wt) + ^- (reason:transact ~) + ?: =(0 (lent orders)) + [%.n 'cannot create transaction with no orders'] + |- + ?~ orders + [%.y ~] + =/ ord=order:wt i.orders + ?- -.ord + %pkh + ?: =(0 gift.ord) + [%.n %gift-cannot-be-zero] + $(orders t.orders) + :: + %multisig + =/ participants=(list hash:transact) participants.ord + =/ unique=@ud + ~(wyt z-in:zo (z-silt:zo participants)) + ?: =(participants ~) + [%.n 'Multisig order must include at least one participant'] + ?: (lte threshold.ord 0) + [%.n 'Multisig threshold must be greater than zero'] + ?: (gth threshold.ord (lent participants)) + [%.n 'Multisig threshold cannot exceed number of participants'] + ?: (lth unique (lent participants)) + [%.n 'Multisig participants must be unique'] + ?: =(0 gift.ord) + [%.n 'order must include a gift greater than 0'] + $(orders t.orders) + == +:: +++ order-gift + |= ord=order:wt + ^- coins:transact + ?- -.ord + %pkh gift.ord + %multisig gift.ord + == +:: +++ with-gift + |= [ord=order:wt gift=coins:transact] + ^- order:wt + ?- -.ord + %pkh [%pkh recipient=recipient.ord gift=gift] + %multisig [%multisig threshold=threshold.ord participants=participants.ord gift=gift] + == +:: +++ order-lock + |= ord=order:wt + ^- lock:transact + ?- -.ord + %pkh + [%pkh [m=1 (z-silt:zo ~[recipient.ord])]]~ + %multisig + =/ participants=(list hash:transact) participants.ord + =/ allowed=(z-set:zo hash:transact) (z-silt:zo participants) + [%pkh [m=threshold.ord allowed]]~ + == +:: +++ order-from-lock + |= [lok=lock:transact gift=@] + ^- (unit order:wt) + ?@ -.lok ~ + =/ primitive=lock-primitive:transact i.lok + ?. ?=(%pkh -.primitive) + ~ + =/ threshold=@ m.primitive + =/ allowed=(z-set:zo hash:transact) h.primitive + =/ participants=(list hash:transact) ~(tap z-in:zo allowed) + ?~ participants + ~|('Invalid lock, no participants specified.' !!) + ?: &(=(threshold 1) =(1 (lent participants))) + (some [%pkh recipient=i.participants gift=gift]) + (some [%multisig threshold=threshold participants=participants gift=gift]) +:: +++ build-refund-order + |= [refund=@ refund-lock=lock:transact] + ^- order:wt + ?~ parsed=(order-from-lock [refund-lock refund]) + ~|('Unsupported owner lock for refund; please specify --refund-pkh' !!) + u.parsed +:: +++ multisig-lock + |= note=nnote-1:v1:transact + ^- (unit lock:transact) + ?~ lock-noun=(~(get z-by:zo note-data.note) %lock) + ~ + ?~ soft-lock=((soft lock-data:wt) u.lock-noun) + ~> %slog.[0 'lock data in note is malformed'] ~ + =+ pulled=lock.u.soft-lock + ?@ -.pulled + ~ + ?: !=(1 (lent pulled)) + ~ + =/ lp=lock-primitive:transact -.pulled + ?. ?=(%pkh -.lp) + ~ + ?: =(1 ~(wyt z-in:zo h.lp)) + ~ + `pulled +:: +-- diff --git a/hoon/apps/wallet/lib/types.hoon b/hoon/apps/wallet/lib/types.hoon index 389118dd9..406b0c04c 100644 --- a/hoon/apps/wallet/lib/types.hoon +++ b/hoon/apps/wallet/lib/types.hoon @@ -140,7 +140,13 @@ [%seed p=@t] [%watch-key p=@t] == -+$ meta meta-v3 +::$ meta-v4 extends meta-v3 with support for watch-only first-name locks ++$ meta-v4 + $+ meta-v4 + $% meta-v3 + [%first-name name=hash:transact lock=(unit lock:transact)] + == ++$ meta meta-v4 :: :: $keys: path indexed map for keys :: @@ -176,7 +182,7 @@ +$ keys-v1 $+(keys-axal keys-v0) +$ keys-v2 $+(keys-axal keys-v1) +$ keys-v3 $+(keys-axal (axal meta-v3)) -+$ keys-v4 $+(keys-axal keys-v3) ++$ keys-v4 $+(keys-axal (axal meta-v4)) +$ keys keys-v4 :: :: $transaction-tree: structured tree of transaction, input, and seed data @@ -351,20 +357,36 @@ :: +$ input-name $~('default-input' @t) :: - +$ order [recipient=hash:transact gift=coins:transact] - :: - :: +++ order + =< form + |% + +$ form + $% [%pkh recipient=hash:transact gift=coins:transact] + [%multisig threshold=@ participants=(list hash:transact) gift=coins:transact] + == + ++ gift + |= =form + ^- coins:transact + ?- -.form + %pkh gift.form + %multisig gift.form + == + -- +:: +$ grpc-bind-cause $% [%grpc-bind result=*] == :: ++ key-version ?(%0 %1) + :: +$ cause $% [%keygen entropy=byts salt=byts] [%derive-child i=@ hardened=? label=(unit @tas)] [%import-keys keys=(list (pair trek *))] [%import-extended extended-key=@t] :: extended key string [%watch-address address=@t] :: imports base58-encoded pubkey + [%watch-address-multisig m=@ participants=(list @t)] + ::[%watch-first-name first-name=@t lock=(unit lock:transact)] [%export-keys ~] [%export-master-pubkey ~] [%import-master-pubkey coil=*] :: base58-encoded pubkey + chain code @@ -381,7 +403,7 @@ names=(list [first=@t last=@t]) :: base58-encoded name hashes orders=(list order) fee=coins:transact :: fee - sign-key=(unit [child-index=@ud hardened=?]) :: child key information to sign from + sign-keys=(unit (list [child-index=@ud hardened=?])) :: child key information to sign from refund-pkh=(unit hash:transact) :: refund pkh for spends over v0 notes include-data=? :: whether or not we should include note-data. defaults :: to yes in cli. not including note-data is a power-user option because @@ -393,6 +415,7 @@ == [%list-active-addresses ~] [%list-notes ~] + [%show-key-tree include-values=?] [%show-seed-phrase ~] [%show-master-zpub ~] [%show-master-zprv ~] @@ -402,10 +425,15 @@ [%set-active-master-address address-b58=@t] [%list-master-addresses ~] [%file file-cause] + $: %sign-multisig-tx + dat=transaction + sign-keys=(unit (list [child-index=@ud hardened=?])) + == == +$ file-cause $% [%write path=@t contents=@t success=?] [%batch-write result=(list [path=@t contents=@t success=?])] + [%read contents=(unit @t)] == :: :: $seed-mask: tracks which fields of a $seed:transact have been set @@ -439,7 +467,74 @@ :: +$ preinput [name=@t (pair input:transact input-mask)] :: - +$ transaction [name=@t p=spends:transact] + +$ input-display + $% [%0 p=(z-map:zo nname:transact =sig:v0:transact)] + [%1 p=(z-map:zo nname:transact sc=spend-condition:transact)] + == + :: + +$ lock-metadata + $: lock=lock:transact + include-data=? + == + :: + +$ output-lock-map (z-map:zo hash:transact lock-metadata) + :: + +$ transaction-display + $: inputs=input-display + outputs=output-lock-map + == + :: + +$ spend-build-state + $: =spends:v1:transact + fee=@ + orders=(list order) + display=transaction-display + wd=witness-data + == + :: + +$ transaction-0 [name=@t p=spends:transact] + :: + ++ witness-data + =< form + |% + +$ form + $+ witness-data + $% [%0 p=(z-map:zo nname:transact signature:transact)] + [%1 p=(z-map:zo nname:transact witness:transact)] + == + ++ apply + |= [=form =spends:v1:transact] + ^- spends:v1:transact + ?- -.form + %0 + %- ~(gas z-by:zo *spends:v1:transact) + %+ turn ~(tap z-by:zo spends) + |= [name=nname:transact =spend:v1:transact] + ?> ?=(%0 -.spend) + [name spend(signature (~(got z-by:zo p.form) name))] + :: + %1 + %- ~(gas z-by:zo *spends:v1:transact) + %+ turn ~(tap z-by:zo spends) + |= [name=nname:transact =spend:v1:transact] + ?> ?=(%1 -.spend) + [name spend(witness (~(got z-by:zo p.form) name))] + == + -- + :: + +$ transaction-1 + $: %1 + name=@t + =spends:transact + display=transaction-display + =witness-data + == + :: + +$ versioned-transaction + $+ versioned-transaction + $^(transaction-0 transaction-1) + :: + +$ transaction transaction-1 :: :: +$ nockchain-grpc-effect diff --git a/hoon/apps/wallet/lib/utils.hoon b/hoon/apps/wallet/lib/utils.hoon index f37e78126..982128467 100644 --- a/hoon/apps/wallet/lib/utils.hoon +++ b/hoon/apps/wallet/lib/utils.hoon @@ -4,6 +4,7 @@ /= transact /common/tx-engine /= zo /common/zoon /= * /common/zose +/= * /common/zeke /= wt /apps/wallet/lib/types |_ bug=? :: @@ -31,6 +32,82 @@ ++ make-markdown-effect |= nodes=markdown:m [%markdown (crip (en:md nodes))] +:: +++ estimate-fee + |% + ++ spends + |= [raw-spends=spends:v1:transact =input-display:wt] + =+ bc=*blockchain-constants:transact + =/ word-count=@ + %- ~(rep z-by:zo raw-spends) + |= [[nam=nname:transact sp=spend:v1:transact] acc=@] + %+ add acc + %+ add + (witness-words sp nam input-display) + (count-seed-words:spend-v1:transact sp) + =/ word-fee=@ (mul word-count base-fee.bc) + (max word-fee min-fee.data.bc) + :: + :: +witness-words: estimate the number of words in a witness + ++ witness-words + |= [=spend:v1:transact nam=nname:transact =input-display:wt] + ?- -.spend + %0 + ?> ?=(%0 -.input-display) + =/ signature-leaves=@ + :: - 13 leaves for the key which is a schnorr-pubkey: + :: - 6 for x, 6 for y, 1 for inf flag + :: - 16 leaves for the signature + =/ num-sigs-required=@ + =/ =sig:transact (~(got z-by:zo p.input-display) nam) + m.sig + (map-words num-sigs-required 13 16) + signature-leaves + :: + %1 + ?> ?=(%1 -.input-display) + =/ =witness:transact witness.+.spend + ?> ?& !=(*lock-merkle-proof:v1:transact lmp.witness) + =(~ tim.witness) + =(~ hax.witness) + == + =/ lmp-count=@ (num-of-leaves:shape lmp.witness) + =/ tim-count=@ (num-of-leaves:shape tim.witness) + =/ hax-count=@ (num-of-leaves:shape hax.witness) + =/ pkh-count=@ + :: 5 leaves for the key which is a hash + :: 13 leaves for the schnorr-pubkey: 6 for x, 6 for y, 1 for inf flag + :: 16 leaves for the signature + =/ num-sigs-required=@ + =/ sc=spend-condition:transact (~(got z-by:zo p.input-display) nam) + %+ roll sc + |= [lp=lock-primitive:transact acc=@] + :: TODO handle hax lock primitives size contribution. for now we will just do pkhs + ?. ?=(%pkh -.lp) + acc + (add acc m.lp) + (map-words num-sigs-required 5 (add 13 16)) + :(add lmp-count pkh-count tim-count hax-count) + == + :: + :: +map-words: estimate number of leaves in a map given node size and number of entries + :: A map (binary tree) with n entries has n + 1 null branches, independent of shape/balance. + :: To calculate the size of a map, we take the number of leaves contributed by each node by + :: multiplying the per-node-leaf count by the number of entries. We leaf contribution of the + :: nodes and add them to the number of null branches, each of which contributes 1 to the size. + :: + :: If either key-leaves or val-leaves are variable size, their maximum theoretical size should + :: be provided. + ++ map-words + |= $: entries=@ + key-leaves=@ + val-leaves=@ + == + ^- @ + =+ per-node-count=(add key-leaves val-leaves) + %+ add (mul entries per-node-count) + (add entries 1) + -- ++ locks |% ++ pull-lock-inner @@ -186,6 +263,7 @@ =/ absolute-index=@ ?.(hardened child-index (add child-index (bex 31))) (by-index absolute-index) + =/ keyc=keyc:s10 ~(keyc get:coil:wt coil) (from-atom:schnorr-seckey:transact p:~(key get:coil:wt coil)) :: ++ by-index @@ -202,12 +280,49 @@ :: ++ watch-addrs ^- (list @t) - =/ subtree (~(kids of keys.state) watch-path) - %+ turn + =+ subtree=(~(kids of keys.state) watch-path) + %+ murn ~(tap by kid.subtree) |= [=trek =meta:wt] - ?> ?=(%watch-key -.meta) - p.meta + ?: ?=(%watch-key -.meta) + `p.meta + ~ + :: + ++ watch-locks + ^- (list [name=@t lock=@t]) + =+ subtree=(~(kids of keys.state) watch-path) + %+ murn + ~(tap by kid.subtree) + |= [=trek =meta:wt] + ?. ?=(%first-name -.meta) + ~ + %- some + :- (to-b58:hash:transact name.meta) + ?~ lock.meta + 'N/A' + (lock:v1:display u.lock.meta) + :: + ++ watch-first-names + ^- (list @t) + =+ subtree=(~(kids of keys.state) watch-path) + %+ roll + ~(tap by kid.subtree) + |= [[=trek =meta:wt] acc=(list @t)] + ?+ -.meta acc + %watch-key + =+ addr=p.meta + ?: (gte (met 3 addr) 132) + acc + =+ pubkey-hash=(from-b58:hash:transact addr) + =+ simple-name=(simple:v1:first-name:transact pubkey-hash) + =+ coinbase-name=(coinbase:v1:first-name:transact pubkey-hash) + :+ (to-b58:hash:transact simple-name) + (to-b58:hash:transact coinbase-name) + acc + :: + %first-name + [(to-b58:hash:transact name.meta) acc] + == :: ++ keys ^- (list [trek coil:wt]) @@ -254,11 +369,18 @@ (welp key-path /label) label/u.label :: - ++ watch-addrs + ++ watch-addr |= b58-addr=@t %+ ~(put of keys.state) (welp watch-path ~[t/b58-addr]) [%watch-key b58-addr] + :: + ++ watch-first-name + |= [name=hash:transact lock=(unit lock:transact)] + =/ name-b58=@t (to-b58:hash:transact name) + %+ ~(put of keys.state) + (welp watch-path ~[t/name-b58]) + [%first-name name lock] -- :: ++ get-note @@ -266,7 +388,9 @@ ^- nnote:transact ?: (~(has z-by:zo notes.balance.state) name) (~(got z-by:zo notes.balance.state) name) - ~|("note not found: {}" !!) + ~| "note not found: ". + "{(trip (name:v1:display name))}" + !! :: ++ get-note-v0 |= name=nname:transact @@ -276,7 +400,9 @@ :: v0 note ?> ?=(^ -.note) note - ~|("note not found: {}" !!) + ~| "note not found: ". + "{(trip (name:v1:display name))}" + !! :: :: TODO: way too slow, need a better way to do this or :: remove entirely in favor of requiring note names in @@ -381,7 +507,7 @@ {} """ (make-markdown-effect nodes) - -- ::+common + -- :: +common ++ v0 |% :: @@ -440,7 +566,7 @@ ^- @t ?> ?=(^ -.note) ^- cord - %^ cat 3 + ;: (cury cat 3) ;: (cury cat 3) ''' @@ -461,21 +587,29 @@ '\0a- Source: ' (to-b58:hash:transact p.source.note) '\0a## Lock' - '\0a- Required Signatures: ' + '\0a - Required Signatures: ' (format-ui:common m.sig.note) - '\0a- Signers: ' + '\0a - Signers: ' == - %- crip - %+ join ' ' - (serialize-lock sig.note) + :: + %+ roll (serialize-lock sig.note) + |= [lock=@t acc=@t] + ;: (cury cat 3) + acc + '\0a - ' + lock + == + :: + '\0a---' + == :: ++ serialize-lock - |= =sig:transact - ^- (list @t) - ~+ - pks:(to-b58:sig:transact sig) + |= =sig:transact + ^- (list @t) + ~+ + pks:(to-b58:sig:transact sig) :: - -- ::+v0 + -- :: +v0 ++ v1 |% ++ name @@ -484,6 +618,84 @@ =+ (to-b58:nname:transact name) :((cury cat 3) '[' first ' ' last ']') :: + ++ lock + |= lk=lock:transact + ^- @t + =/ cond=(unit spend-condition:transact) + ((soft spend-condition:transact) lk) + ?~ cond + 'Lock data not displayable' + (spend-condition u.cond) + :: + ++ lock-primitive + |= prim=lock-primitive:transact + ^- cord + =; txt=@t + (cat 3 txt '\0a---') + ?- -.prim + %pkh + =/ participants=(list hash:transact) ~(tap z-in:zo h.prim) + %^ cat 3 + '\0a - PKH Lock (m-of-n)' + (render-lock-signers m.prim participants) + :: + %hax + =/ hashes=(list hash:transact) ~(tap z-in:zo +.prim) + ;: (cury cat 3) + '\0a - Hash Lock' + '\0a - Preimage Hashes:' + %+ roll hashes + |= [h=hash:transact hash-lines=@t] + ;: (cury cat 3) + hash-lines + '\0a - ' + (to-b58:hash:transact h) + == + == + :: + %tim + =/ rel-min=@t + %^ cat 3 + '\0a - Min Relative Height: ' + ?~ min.rel.prim 'N/A' + (format-ui:common u.min.rel.prim) + =/ rel-max=@t + %^ cat 3 + '\0a - Max Relative Height: ' + ?~ max.rel.prim 'N/A' + (format-ui:common u.max.rel.prim) + =/ abs-min=@t + %^ cat 3 + '\0a - Min Absolute Height: ' + ?~ min.abs.prim 'N/A' + (format-ui:common u.min.abs.prim) + =/ abs-max=@t + ?~ max.abs.prim 'N/A' + %^ cat 3 + '\0a - Max Absolute Height: ' + (format-ui:common u.max.abs.prim) + ;: (cury cat 3) + '\0a - Time Lock' + rel-min + rel-max + abs-min + abs-max + == + :: + %brn + '\0a - Unspendable (burn) condition' + == + :: + ++ spend-condition + |= cond=spend-condition:transact + ^- @t + %+ roll cond + |= [lp=lock-primitive:transact lines=@t] + ;: (cury cat 3) + lines + (lock-primitive lp) + == + :: ++ lock-data |= data=note-data:v1:transact ^- @t @@ -491,31 +703,54 @@ ~> %slog.[2 'lock data in note is missing'] 'N/A' ?~ soft-lock=((soft lock-data:wt) u.lock-data) ~> %slog.[2 'lock data in note is malformed'] 'N/A' - ?: ?=(@ -.lock.u.soft-lock) - ~> %slog.[2 'expected m-of-n pkh lock'] 'N/A' - =+ lp=`lock-primitive:transact`(head lock.u.soft-lock) - ?. ?=(%pkh -.lp) - ~> %slog.[2 'expected m-of-n pkh lock'] 'N/A' - =/ signers=tape - %- zing - %+ turn ~(tap z-in:zo h.lp) - |= =hash:transact - """ - - {(trip (to-b58:hash:transact hash))} - """ - %- crip - """ - - - Required Signatures: {(trip (format-ui:common m.lp))} - - Signers: - {signers} - - """ + (lock lock.u.soft-lock) + :: + ++ bool-text + |= flag=? + ^- @t + ?: flag 'yes' 'no' + :: + ++ render-lock-signers + |= [required=@ participants=(list hash:transact)] + ^- @t + =/ signer-text=@t + %+ roll participants + |= [hash=hash:transact acc=@t] + ;: (cury cat 3) + acc + '\0a - ' + (to-b58:hash:transact hash) + == + ;: (cury cat 3) + '\0a - Required Signatures: ' + (format-ui:common required) + '\0a - Signers:' + signer-text + == + :: + ++ lock-metadata + |= data=lock-metadata:wt + ^- @t + =/ cond=(unit spend-condition:transact) + ((soft spend-condition:transact) lock.data) + ?~ cond + '\0a - Lock data not displayable' + ;: (cury cat 3) + '\0a - Lock data included in note: ' + (bool-text include-data.data) + (spend-condition u.cond) + == :: ++ note - |= [note=nnote:transact output=?] + |= [note=nnote:transact output=? output-lock-map=output-lock-map:wt] ^- @t ?> ?=(@ -.note) + =/ metadata=(unit lock-metadata:wt) + (~(get z-by:zo output-lock-map) -.name.note) + =/ output-lock-info=@t + ?~ metadata + (lock-data note-data.note) + (lock-metadata u.metadata) ;: (cury cat 3) ''' @@ -533,11 +768,69 @@ 'N/A (output note has not been submitted yet)' (format-ui:common origin-page.note) '\0a- Lock Information: ' - (lock-data note-data.note) - + output-lock-info == + :: + ++ witness-data + |= wd=witness-data:wt + ^- @t + =; signers=(set @t) + =/ signers-text=@t + %+ roll ~(tap in signers) + |= [signer=@t text=@t] + ;: (cury cat 3) + text + '\0a - ' + signer + == + ;: (cury cat 3) + '\0a - Number of Unique Signers So Far: ' + (format-ui:common ~(wyt in signers)) + '\0a - Signers So Far: ' + signers-text + == + ?- -.wd + %0 + %- ~(rep z-by:zo p.wd) + |= $: [=nname:transact =signature:transact] + signers=(set @t) + == + %- ~(gas in signers) + %+ turn ~(tap z-in:zo ~(key z-by:zo signature)) + to-b58:schnorr-pubkey:transact + :: + %1 + %- ~(rep z-by:zo p.wd) + |= $: [=nname:transact =witness:transact] + signers=(set @t) + == + %- ~(gas in signers) + %+ turn ~(tap z-in:zo ~(key z-by:zo pkh.witness)) + to-b58:hash:transact + == + :: + ++ timelock-range + |= [label=@t range=timelock-range:transact] + ^- @t + =/ min-text=@t + ?~ min.range 'N/A' + (format-ui:common u.min.range) + =/ max-text=@t + ?~ max.range 'N/A' + (format-ui:common u.max.range) + ;: (cury cat 3) + label + ' min: ' + min-text ', max: ' max-text + == + :: ++ transaction - |= [name=@t outs=outputs:v1:transact fees=@] + |= $: name=@t + outs=outputs:v1:transact + fees=@ + display=transaction-display:wt + wd=(unit witness-data:wt) + == ^- @t =/ output-notes=tape %- zing @@ -545,20 +838,24 @@ ~(tap z-in:zo outs) |= out=output:v1:transact =/ out-note=nnote:v1:transact note.out - "\0a{(trip (note out-note %.y))}" + "\0a{(trip (note out-note %.y outputs.display))}" + :: Input note display is not working because they are not accumulate in builder %- crip """ ## Transaction Information - Name: {(trip name)} - - Fee: {(trip (format-ui:common:display fees))} + - Fee: {(trip (format-ui:common fees))} ### Output Notes {output-notes} + + ### Witness Data + {(trip ?~(wd 'N/A' (witness-data u.wd)))} --- """ - -- ::+v1 - -- ::+display + -- :: +v1 + -- :: +display :: ++ show |= [=state:wt =path] @@ -590,7 +887,7 @@ %+ roll :: all notes owned by keys in wallet, excluding watch-only pubkeys %+ skim notes - |= [note=nnote:transact] + |= note=nnote:transact %- ~(has in owned-names) ~(first-name get:nnote:transact note) |= [note=nnote:transact [len=@ acc=coins:transact]] diff --git a/hoon/apps/wallet/wallet.hoon b/hoon/apps/wallet/wallet.hoon index 6684b177c..c13a9d0b8 100644 --- a/hoon/apps/wallet/wallet.hoon +++ b/hoon/apps/wallet/wallet.hoon @@ -10,7 +10,7 @@ /= * /common/wrapper /= wt /apps/wallet/lib/types /= wutils /apps/wallet/lib/utils -/= tx-builder /apps/wallet/lib/tx-builder-v1 +/= tx-builder /apps/wallet/lib/tx-builder /= s10 /apps/wallet/lib/s10 => =| bug=_& @@ -83,16 +83,16 @@ ^- state-3:wt ?> ?=(%2 -.old) ~> %slog.[0 'upgrade version 2 to 3'] - =/ new-keys=keys:wt + =/ new-keys=keys-v3:wt %+ roll ~(tap of keys.old) - |= [[=trek m=meta-v2:wt] new=keys:wt] + |= [[=trek m=meta-v2:wt] new=keys-v3:wt] %- ~(put of new) :- trek - ^- meta:wt + ^- meta-v3:wt ?. ?=(%coil -.m) m [%coil [%0 +.m]] - =/ new-master=active:wt + =/ new-master=active-v3:wt ?~ active-master.old ~ `[%0 +.u.active-master.old] :* %3 @@ -134,7 +134,7 @@ ``state :: :: returns a list of tracked first names - [%tracked-names include-watch-only=? ~] + [%tracked-names ~] :+ ~ ~ =/ signing-names=(list @t) @@ -147,27 +147,15 @@ :+ (to-b58:hash:transact (simple-first-name:coil:wt coil)) (to-b58:hash:transact (coinbase-first-name:coil:wt coil)) names - ?. include-watch-only.pole - signing-names - %+ weld signing-names - %+ roll watch-addrs:get:v - |= [addr=@t first-names=(list @t)] - :: v0 keys have at least 132 bytes - ?: (gte (met 3 addr) 132) - :: exclude names for v0 keys because those are handled through tracked pubkeys - first-names - =+ pubkey-hash=(from-b58:hash:transact addr) - :+ (to-b58:hash:transact (simple:v1:first-name:transact pubkey-hash)) - (to-b58:hash:transact (coinbase:v1:first-name:transact pubkey-hash)) - first-names + %~ tap in + %- silt + (weld signing-names watch-first-names:get:v) :: :: returns a list of pubkeys - [%tracked-pubkeys include-watch-only=? ~] + [%tracked-pubkeys ~] :+ ~ ~ =; signing-keys=(list @t) - ?. include-watch-only.pole - signing-keys %+ weld signing-keys %+ murn watch-addrs:get:v |= addr=@t @@ -214,6 +202,7 @@ %list-notes-by-address (do-list-notes-by-address cause) %list-notes-by-address-csv (do-list-notes-by-address-csv cause) %create-tx (do-create-tx cause) + %sign-multisig-tx (do-sign-multisig-tx cause) %update-balance-grpc (do-update-balance-grpc cause) %sign-message (do-sign-message cause) %verify-message (do-verify-message cause) @@ -221,14 +210,17 @@ %verify-hash (do-verify-hash cause) %import-keys (do-import-keys cause) %import-extended (do-import-extended cause) - %watch-address (do-watch-address cause) + %watch-address (do-watch-address cause) + ::%watch-first-name (do-watch-first-name cause) + %watch-address-multisig (do-watch-address-multisig cause) %export-keys (do-export-keys cause) %export-master-pubkey (do-export-master-pubkey cause) %import-master-pubkey (do-import-master-pubkey cause) %import-seed-phrase (do-import-seed-phrase cause) %send-tx (do-send-tx cause) - %show-tx (do-show-tx cause) + %show-tx (do-show-tx cause) %list-active-addresses (do-list-active-addresses cause) + %show-key-tree (do-show-key-tree cause) %show-seed-phrase (do-show-seed-phrase cause) %show-master-zpub (do-show-master-zpub cause) %show-master-zprv (do-show-master-zprv cause) @@ -236,11 +228,20 @@ %set-active-master-address (do-set-active-master-address cause) :: %file - ::?> ?=(%write +<.cause) - ::[[%exit 0]~ state] - ?- +<.cause - %write [[%exit 0]~ state] - %batch-write [[%exit 0]~ state] + ?- +<.cause + %write + [[%exit 0]~ state] + :: + %batch-write + [[%exit 0]~ state] + :: + %read + ?^ contents.cause + ~& "success" + :: file read response with contents + [[%exit 0]~ state] + :: file read error + [[%exit 1]~ state] == == == @@ -381,17 +382,95 @@ ++ do-watch-address |= =cause:wt ?> ?=(%watch-address -.cause) - :_ state(keys (watch-addrs:put:v address.cause)) + :_ state(keys (watch-addr:put:v address.cause)) :~ :- %markdown %- crip """ - ## Imported watch-only pubkey + ## Imported watch-only address - - Imported key: {} + - Imported address: {} """ [%exit 0] == :: + ::++ do-watch-first-name + :: |= =cause:wt + :: ?> ?=(%watch-first-name -.cause) + :: =/ first-name=hash:transact (from-b58:hash:transact first-name.cause) + :: =/ maybe-lock=(unit lock:transact) lock.cause + :: =/ first-name-b58=@t (to-b58:hash:transact first-name) + :: :_ state(keys (watch-first-name:put:v first-name maybe-lock)) + :: :~ :- %markdown + :: %- crip + :: """ + :: ## Imported watch-only first name + + :: - First name: {(trip first-name-b58)} + :: - Lock info: {?~(maybe-lock "N/A" (trip (lock:v1:display:utils u.maybe-lock)))} + + :: """ + :: [%exit 0] + :: == + :: + ++ do-watch-address-multisig + |= =cause:wt + ?> ?=(%watch-address-multisig -.cause) + =/ participant-count=@ud (lent participants.cause) + ?: =(0 participant-count) + :_ state + :~ :- %markdown + %- crip + """ + No pubkeys were provided for the multisig watch request. + """ + [%exit 0] + == + ?: ?| (lte m.cause 0) + (gth m.cause participant-count) + == + :_ state + :~ :- %markdown + %- crip + """ + Invalid m value: {}. Must be > 0 and <= number of participant addresses ({}). + """ + [%exit 0] + == + =/ address-hash-set=(z-set:zo hash:transact) + %+ roll participants.cause + |= [b58=@t acc=(z-set:zo hash:transact)] + (~(put z-in:zo acc) (from-b58:hash:transact b58)) + =/ multisig=lock:transact + [%pkh m=m.cause address-hash-set]~ + =/ first-name=hash:transact + (first:nname:transact (hash:lock:transact multisig)) + =/ first-name-b58=@t (to-b58:hash:transact first-name) + =. keys.state (watch-first-name:put:v first-name `multisig) + =/ keys=tape + %- zing + %+ join "\0a " + %+ turn + participants.cause + |= k=@t + """ + - {(trip k)} + """ + =/ summary=@t + %- crip + """ + ## Imported multisig watch + + - First name: {(trip first-name-b58)} + - Required Signatures: {} + - Signers: + {keys} + + """ + :_ state + :~ [%markdown summary] + [%exit 0] + == + :: ++ do-import-extended |= =cause:wt ?> ?=(%import-extended -.cause) @@ -625,27 +704,63 @@ |= =cause:wt ?> ?=(%send-tx -.cause) %- (debug "send-tx: creating raw-tx") - :: - =/ raw=raw-tx:v1:transact (new:raw-tx:v1:transact p.dat.cause) - ?. (validate:raw-tx:v1:transact raw) + =/ =transaction:wt dat.cause + =/ transaction-name=@t name.transaction + =/ =spends:transact spends.transaction + =/ display=transaction-display:wt display.transaction + =/ =witness-data:wt witness-data.transaction + =/ signed-spends=spends:v1:transact + (apply:witness-data:wt witness-data spends) + =/ raw=raw-tx:v1:transact (new:raw-tx:v1:transact signed-spends) + =/ =tx:v1:transact (new:tx:v1:transact raw height.balance.state) + =/ fees=@ (roll-fees:spends:v1:transact signed-spends) + =/ tx-display=@t + (transaction:v1:display:utils transaction-name outputs.tx fees display.transaction `witness-data) + =+ data=data:*blockchain-constants:transact + =/ valid=(reason:dumb ~) + %- validate-with-context:spends:transact + [notes.balance.state signed-spends height.balance.state max-size.data] + ?- -.valid + %.y + =/ nock-cause=$>(%fact cause:dumb) + [%fact %0 %heard-tx raw] + %- (debug "send-tx: made raw-tx, sending poke request over grpc") + :: we currently do not need to assign pids. shim is here in case + =/ pid *@ + =/ msg=@t + %- crip + """ + ## Sent Tx + - Validation for TX {(trip (to-b58:hash:transact id.raw))} passed. TX has been submitted to node. + --- + + """ :_ state - :~ :- %markdown + :~ [%markdown msg] + [%grpc %poke pid nock-cause] + [%nockchain-grpc %send-tx raw] + [%exit 0] + == + :: + %.n + =/ msg=@t %- crip """ - Cannot send transaction: invalid raw-tx + # TX Validation Failed + + Failed to validate the correctness of transaction {(trip transaction-name)}. + Reason: {(trip p.valid)} + + {(trip tx-display)} + --- + """ - [%exit 1] + %- (debug "{(trip msg)}") + :_ state + :~ + [%markdown msg] + [%exit 1] == - =/ nock-cause=$>(%fact cause:dumb) - [%fact %0 %heard-tx raw] - %- (debug "send-tx: made raw-tx, sending poke request over grpc") - :: we currently do not need to assign pids. shim is here in case - =/ pid *@ - :_ state - :~ - [%grpc %poke pid nock-cause] - [%nockchain-grpc %send-tx raw] - [%exit 0] == :: ++ do-show-tx @@ -654,12 +769,20 @@ %- (debug "show-tx: displaying transaction") =/ =transaction:wt dat.cause =/ transaction-name=@t name.transaction - =/ =spends:transact p.transaction + =/ =spends:transact spends.transaction + =/ display=transaction-display:wt display.transaction =/ fees=@ (roll-fees:spends:v1:transact spends) =/ =raw-tx:v1:transact (new:raw-tx:v1:transact spends) =/ =tx:v1:transact (new:tx:v1:transact raw-tx height.balance.state) + ~& display+display.transaction =/ markdown-text=@t - (transaction:v1:display:utils transaction-name outputs.tx fees) + %: transaction:v1:display:utils + transaction-name + outputs.tx + fees + display.transaction + `witness-data.transaction + == :_ state :~ [%markdown markdown-text] @@ -687,6 +810,15 @@ - {} --- + """ + =/ base58-watch-locks=(list tape) + %+ turn watch-locks:get:v + |= [name-b58=@t lock-b58=@t] + """ + - First Name: {(trip name-b58)} + {(trip lock-b58)} + --- + """ :_ state :~ :- %markdown @@ -696,13 +828,177 @@ {?~(base58-sign-keys "No pubkeys found" (zing base58-sign-keys))} - ## Addresses -- Watch only + ## Addresses -- Watch Only {?~(base58-watch-addrs "No pubkeys found" (zing base58-watch-addrs))} + + ## Lock First Names -- Watch Only + + {?~(base58-watch-locks "No watch only locks found" (zing base58-watch-locks))} + """ [%exit 0] == :: + ++ do-show-key-tree + |= =cause:wt + ?> ?=(%show-key-tree -.cause) + |^ + =/ include-values=? include-values.cause + =/ entries=(list [trek meta:wt]) ~(tap of keys.state) + =/ root=trek (pave /keys) + =/ init=[seen=(set trek) ordered=(list trek)] + [(~(put in *(set trek)) root) ~[root]] + =/ final=[seen=(set trek) ordered=(list trek)] + %+ roll entries + |= [[path=trek meta=meta:wt] acc=[seen=(set trek) ordered=(list trek)]] + ^- [seen=(set trek) ordered=(list trek)] + (add-prefixes (prefixes path) acc) + =/ paths=(list trek) + (flop ordered.final) + =/ lines=(list tape) + %+ turn paths + |= path=trek + (render-line path include-values) + =/ tree-text=tape + %- zing + %+ turn lines + |= line=tape + (weld line "\0a") + :_ state + :~ + :- %markdown + %- crip + """ + ## Key Tree + + ``` + {tree-text} + ``` + + """ + [%exit 0] + == + :: + ++ prefixes + |= path=trek + ^- (list trek) + =| acc=(list trek) + =| cur=trek + |- ^- (list trek) + ?~ path + (flop acc) + =. cur (snoc cur i.path) + =. acc [cur acc] + $(path t.path) + :: + ++ add-prefixes + |= [paths=(list trek) acc=[seen=(set trek) ordered=(list trek)]] + ^- [seen=(set trek) ordered=(list trek)] + =/ seen=(set trek) seen.acc + =/ ordered=(list trek) ordered.acc + |- + ?~ paths + [seen ordered] + =/ path=trek i.paths + ?: (~(has in seen) path) + $(paths t.paths) + =. seen (~(put in seen) path) + =. ordered [path ordered] + $(paths t.paths) + :: + ++ render-line + |= [path=trek include-values=?] + ^- tape + =/ len=@ud (lent path) + =/ depth=@ud ?:(=(0 len) 0 (dec len)) + =/ indent=tape (indent depth) + =/ path-text=tape (spud (pout path)) + =/ base=tape (weld indent path-text) + =/ value=(unit meta:wt) (~(get of keys.state) path) + ?~ value + base + ?: include-values + (weld base (weld "\0a {indent} -> " (summarize-meta u.value indent))) + base + :: + ++ indent + |= depth=@ud + ^- tape + =| i=@ud + =| acc=tape + |- ^- tape + ?: =(i depth) + acc + $(i +(i), acc (weld acc " ")) + :: + ++ summarize-meta + |= [=meta:wt indent=tape] + ^- tape + ?- -.meta + %coil (summarize-coil p.meta) + %label "label {}" + %seed "seed {}" + %watch-key "watch-key {}" + %first-name (summarize-first-name indent +.meta) + == + :: + ++ summarize-first-name + |= [indent=tape first-name=hash:transact lock=(unit lock:transact)] + ^- tape + """ + first-name {(trip (to-b58:hash:transact first-name))} + """ + :: + ++ summarize-coil + |= =coil:wt + ^- tape + =/ version-text=tape (scow %ud -.coil) + =/ key-type=@tas + =< - + ~(key get:coil:wt coil) + =/ type-text=tape (scow %tas key-type) + "coil {version-text} {type-text}" + :: + ++ pout + |= pit=pith + ^- path + %+ turn pit + |= iot=iota + ^- @ta + ?- iot + @tas iot + [%ub @] (scot %ub +.iot) + [%uc @] (scot %uc +.iot) + [%ud @] (scot %ud +.iot) + [%ui @] (scot %ui +.iot) + [%ux @] (scot %ux +.iot) + [%uv @] (scot %uv +.iot) + [%uw @] (scot %uw +.iot) + [%sb @] (scot %sb +.iot) + [%sc @] (scot %sc +.iot) + [%sd @] (scot %sd +.iot) + [%si @] (scot %si +.iot) + [%sx @] (scot %sx +.iot) + [%sv @] (scot %sv +.iot) + [%sw @] (scot %sw +.iot) + [%da @] (scot %da +.iot) + [%dr @] (scot %dr +.iot) + [%f ?] (scot %f +.iot) + [%n ~] (scot %n ~) + [%if @] (scot %if +.iot) + [%is @] (scot %is +.iot) + [%t @] (scot %tas +.iot) + [%ta @] (scot %tas +.iot) + [%p @] (scot %p +.iot) + [%q @] (scot %q +.iot) + [%rs @] (scot %rs +.iot) + [%rd @] (scot %rd +.iot) + [%rh @] (scot %rh +.iot) + [%rq @] (scot %rq +.iot) + == + -- + :: ++ do-show-seed-phrase |= =cause:wt ?> ?=(%show-seed-phrase -.cause) @@ -803,6 +1099,8 @@ %+ welp """ ## Wallet Notes + - Height: {(trip (format-ui:common:display:utils height.balance.state))} + - Block id: {(trip (to-b58:hash:transact block-id.balance.state))} """ =- ?: =("" -) "No notes found" - @@ -811,7 +1109,7 @@ |= note=nnote:transact ?^ -.note (trip (note:v0:display:utils note)) - (trip (note:v1:display:utils note %.n)) + (trip (note:v1:display:utils note %.n ~)) :: [%exit 0] == @@ -855,6 +1153,8 @@ %+ welp """ ## Wallet Notes for Address {(trip address.cause)} + - Height: {(trip (format-ui:common:display:utils height.balance.state))} + - Block id: {(trip (to-b58:hash:transact block-id.balance.state))} """ =- ?: =("" -) "No notes found" - @@ -864,7 +1164,7 @@ %- trip ?^ -.nnote (note:v0:display:utils nnote) - (note:v1:display:utils nnote output=%.n) + (note:v1:display:utils nnote output=%.n ~) :: [%exit 0] == @@ -945,6 +1245,7 @@ |^ %- (debug "create-tx: {}") =/ names=(list nname:transact) (parse-names names.cause) + =/ orders=(list order:wt) orders.cause ?~ active-master.state :_ state :~ :- %markdown @@ -954,13 +1255,40 @@ """ [%exit 0] == - =/ sign-key (sign-key:get:v sign-key.cause) - =/ pubkey=schnorr-pubkey:transact - %- from-sk:schnorr-pubkey:transact - (to-atom:schnorr-seckey:transact sign-key) - =/ [=spends:transact primary-pkh=hash:transact] - (tx-builder names orders.cause fee.cause sign-key pubkey refund-pkh.cause get-note:v include-data.cause) - (save-transaction spends primary-pkh) + =/ sign-keys=(list schnorr-seckey:transact) + ?~ sign-keys.cause + ~[(sign-key:get:v ~)] + %+ turn u.sign-keys.cause + |= key-info=[child-index=@ud hardened=?] + (sign-key:get:v [~ key-info]) + =/ [=spends:v1:transact =witness-data:wt display=transaction-display:wt] + %: tx-builder + names + orders + fee.cause + sign-keys + refund-pkh.cause + get-note:v + include-data.cause + == + =/ multisig-recv-locks=(z-set:zo lock:transact) + (gather-multisig-locks orders) + =/ transaction-name=@t + %- to-b58:hash:transact + id:(new:raw-tx:v1:transact spends) + =/ =transaction:wt + %* . *transaction:wt + name transaction-name + spends spends + display display + witness-data witness-data + == + =/ res=effects=(list effect:wt) + (save-transaction transaction) + ?: ?=(~ multisig-recv-locks) + [effects.res state] + :- effects.res + state(keys (watch-multisig-locks multisig-recv-locks)) :: ++ parse-names |= raw-names=(list [first=@t last=@t]) @@ -970,71 +1298,69 @@ (from-b58:nname:transact [first last]) :: ++ save-transaction - |= [=spends:transact primary-pkh=hash:transact] - ^- [(list effect:wt) state:wt] - ~& "Validating transaction before saving" + |= tx-ser=transaction:wt + ^- (list effect:wt) :: we fallback to the hash of the spends as the transaction name - :: if the tx is invalid. this is just for display - :: in the error message, as an invalid tx is not saved. - =/ transaction-name=@t - %- to-b58:hash:transact - id:(new:raw-tx:v1:transact spends) - :: TODO: modulate blockchain constants from wallet with poke - =+ data=data:*blockchain-constants:transact - =/ valid=(reason:dumb ~) - %- validate-with-context:spends:transact - [notes.balance.state spends height.balance.state max-size.data] - =/ =raw-tx:v1:transact (new:raw-tx:v1:transact spends) + :: when generating filenames to ensure uniqueness. + =/ =raw-tx:v1:transact (new:raw-tx:v1:transact spends.tx-ser) =/ =tx:v1:transact (new:tx:v1:transact raw-tx height.balance.state) - =/ fees=@ (roll-fees:spends:v1:transact spends) - =/ markdown-text=@t (transaction:v1:display:utils transaction-name outputs.tx fees) - ?- -.valid - %.y - :: jam inputs and save as transaction - =/ =transaction:wt [transaction-name spends] - =/ transaction-jam (jam transaction) - =/ tx-path=@t + =/ =witness-data:wt witness-data.tx-ser + =/ fees=@ (roll-fees:spends:v1:transact spends.tx-ser) + =/ markdown-text=@t + (transaction:v1:display:utils name.tx-ser outputs.tx fees display.tx-ser `witness-data) + :: jam inputs and save as transaction + =/ transaction-jam (jam tx-ser) + =/ tx-path=@t + (crip "./txs/{(trip name.tx-ser)}.tx") + %- (debug "saving transaction to {}") + =/ write-effect=effect:wt + ?. save-raw-tx.cause + [%file %write tx-path transaction-jam] + =/ hashable-path=@t %- crip - "./txs/{(trip name.transaction)}.tx" - %- (debug "saving transaction to {}") - =/ write-effect=effect:wt - ?. save-raw-tx.cause - [%file %write tx-path transaction-jam] - =/ hashable-path=@t - %- crip - "./txs-debug/{(trip name.transaction)}-hashable.jam" - =/ raw-tx-path=@t - %- crip - "./txs-debug/{(trip name.transaction)}.jam" - :* %file - %batch-write - :~ [hashable-path (jam [leaf+%1 (hashable:spends:transact spends)])] - [tx-path transaction-jam] - [raw-tx-path (jam raw-tx)] - == + "./txs-debug/{(trip name.tx-ser)}-hashable.jam" + =/ raw-tx-path=@t + %- crip + "./txs-debug/{(trip name.tx-ser)}.jam" + :* %file + %batch-write + :~ [hashable-path (jam [leaf+%1 (hashable:spends:transact spends.tx-ser)])] + [tx-path transaction-jam] + [raw-tx-path (jam raw-tx)] + == + == + =. markdown-text + ;: (cury cat 3) + '\0a## Create Tx' + '\0a - Saved transaction to ' + tx-path + '\0a ' + markdown-text == - :_ state - ~[write-effect [%markdown markdown-text]] + ~[write-effect [%markdown markdown-text]] + :: + ++ gather-multisig-locks + |= orders=(list order:wt) + ^- (z-set:zo lock:transact) + %- z-silt:zo + %+ murn orders + |= ord=order:wt + ?- -.ord + %pkh ~ :: - %.n - =/ msg=@t - %- crip - """ - # TX Validation Failed - - Failed to validate the correctness of transaction {(trip transaction-name)}. - Reason: {(trip p.valid)} - - {(trip markdown-text)} - --- - - """ - %- (debug "{(trip msg)}") - :_ state - :~ [%markdown msg] - [%exit 1] - == + %multisig + =/ allowed=(z-set:zo hash:transact) (z-silt:zo participants.ord) + `[%pkh [m=threshold.ord allowed]]~ == + :: + ++ watch-multisig-locks + |= locks=(z-set:zo lock:transact) + ^- keys:wt + %- ~(rep z-in:zo locks) + |= [lock=lock:transact acc=_keys.state] + %- watch-first-name:put:v + [(first:nname:transact (hash:lock:transact lock)) `lock] + :: -- :: ++ do-keygen @@ -1315,5 +1641,147 @@ [%exit 0] == :: + ++ do-sign-multisig-tx + |= =cause:wt + ?> ?=(%sign-multisig-tx -.cause) + |^ + %- (debug "sign-multisig-tx: {}") + ?~ active-master.state + :_ state + :~ :- %markdown + %- crip + """ + Cannot sign without active master address. Please import a master key. + """ + [%exit 1] + == + =/ =transaction:wt dat.cause + =/ =witness-data:wt witness-data.transaction + =/ =spends:v1:transact spends.transaction + ?> ?=(%1 -.witness-data) + :: get sign-keys from wallet + :: if sign-keys is not provided, use master key + =/ sign-keys=(list schnorr-seckey:transact) + ?~ sign-keys.cause + ~[(sign-key:get:v ~)] + %+ turn u.sign-keys.cause + |= key-info=[child-index=@ud hardened=?] + (sign-key:get:v [~ key-info]) + :: sign all spends with all sign-keys + =. witness-data + :- %1 + %- ~(rep z-by:zo spends) + |= $: [name=nname:transact =spend:v1:transact] + wd=(z-map:zo nname:transact witness:transact) + == + ?> ?=(%1 -.spend) + =+ sig-hash=(sig-hash:spend-1:v1:transact +.spend) + =+ curr-witness=(~(got z-by:zo p.witness-data) name) + %+ ~(put z-by:zo wd) name + %+ roll sign-keys + |= [sk=schnorr-seckey:transact acc=_curr-witness] + (sign:witness:transact acc sk sig-hash) + ::>) TODO: should the transaction name be changed to reflect the new signature? + (save-signed-transaction transaction(witness-data witness-data) sign-keys) + :: + ++ save-signed-transaction + |= [=transaction:wt sign-keys=(list schnorr-seckey:transact)] + ^- [(list effect:wt) state:wt] + =/ transaction-jam (jam transaction) + =/ path=@t + %- crip + "./txs/{(trip name.transaction)}.tx" + %- (debug "saving signed transaction to {}") + =/ =witness-data:wt witness-data.transaction + ?> ?=(%1 -.witness-data) + =/ sign-pkhs=@t + %+ roll sign-keys + |= [sk=schnorr-seckey:transact pkhs-list=@t] + ;: (cury cat 3) + pkhs-list + '\0a - ' + %- to-b58:hash:transact + %- hash:schnorr-pubkey:transact + %- from-seckey:schnorr-pubkey:transact + sk + == + =/ markdown-text=@t + %- crip + """ + + ### Transaction Signed + + - Transaction {(trip name.transaction)} has been signed with: {(trip sign-pkhs)} + - Saved to: {(trip path)} + + ### Witness Data + {(trip (witness-data:v1:display:utils witness-data))} + + """ + =/ =effect:wt [%file %write path transaction-jam] + :_ state + :~ [%file %write path transaction-jam] + [%markdown markdown-text] + [%exit 0] + == + -- + :: + ::++ do-show-multisig-tx + :: |= =cause:wt + :: ?> ?=(%show-multisig-tx -.cause) + :: %- (debug "show-multisig-tx: {}") + :: =/ =transaction:wt dat.cause + :: =/ =spends:transact spends.transaction + :: =/ display=transaction-display:wt display.transaction + :: =/ fees=@ (roll-fees:spends:v1:transact spends) + :: =/ =raw-tx:v1:transact (new:raw-tx:v1:transact spends) + :: =/ =tx:v1:transact (new:tx:v1:transact raw-tx height.balance.state) + :: :: count signatures + :: =/ sig-count=@ud + :: %+ roll ~(val z-by:zo spends) + :: |= [spend=spend:v1:transact count=@ud] + :: ?- -.spend + :: %0 count + :: %1 + :: =/ sigs=(list *) ~(tap z-in:zo pkh.witness.spend) + :: (add count (lent sigs)) + :: == + :: :: extract required m from lock (if possible) + :: =/ required-m=(unit @ud) + :: =/ first-spend=(unit [name=nname:transact spend=spend:v1:transact]) + :: %- mole + :: |.((head ~(tap z-by:zo spends))) + :: ?~ first-spend ~ + :: ?- -.spend.u.first-spend + :: %0 ~ + :: %1 + :: =/ first-seed=(unit seed:v1:transact) + :: %- mole + :: |. + :: (head ~(tap z-in:zo seeds.spend.u.first-spend)) + :: ?~ first-seed ~ + :: :: would need to decode lock from lock-root to get m + :: :: for now just show ~ + :: ~ + :: == + :: =/ markdown-text=@t + :: %- crip + :: """ + :: # Multisig Transaction Details + + :: Transaction: {(trip name.transaction)} + :: Total signatures: {} + :: {?~(required-m "" "Required signatures: {}\0a")} + :: Fee: {(trip (format-ui:common:display:utils fees))} nicks + + :: ## Outputs + + :: {(trip (transaction:v1:display:utils name.transaction outputs.tx fees display.transaction))} + :: """ + :: :_ state + :: :~ [%markdown markdown-text] + :: [%exit 0] + :: == + :::: -- ::+poke -- diff --git a/hoon/common/tx-engine-0.hoon b/hoon/common/tx-engine-0.hoon index ad4f897d1..7e5f32224 100644 --- a/hoon/common/tx-engine-0.hoon +++ b/hoon/common/tx-engine-0.hoon @@ -199,6 +199,8 @@ ++ to-sig |=(sop=form (new:sig sop)) ++ from-sk |=(sk=@ux (ch-scal:affine:curve:cheetah sk a-gen:curve:cheetah)) + ++ from-seckey + |=(sk=schnorr-seckey (from-sk (to-atom:schnorr-seckey sk))) ++ hash |=(=form (hash-hashable:tip5 leaf+form)) -- ++ schnorr-seckey @@ -241,6 +243,17 @@ =< form |% +$ form (z-map schnorr-pubkey schnorr-signature) + ++ sign + |= [=form sk=schnorr-seckey sig-hash=^hash] + ^- ^form + =/ pk=schnorr-pubkey + %- ch-scal:affine:curve:cheetah + :* (t8-to-atom:belt-schnorr:cheetah sk) + a-gen:curve:cheetah + == + =/ sig=schnorr-signature + (sign:affine:belt-schnorr:cheetah sk sig-hash) + (~(put z-by form) pk sig) :: ++ based |= =form diff --git a/hoon/common/tx-engine-1.hoon b/hoon/common/tx-engine-1.hoon index bd667cb8b..56c1b7224 100644 --- a/hoon/common/tx-engine-1.hoon +++ b/hoon/common/tx-engine-1.hoon @@ -572,18 +572,10 @@ ++ sign |= [sen=form sk=schnorr-seckey] ^+ sen - =/ pk=schnorr-pubkey - %- ch-scal:affine:curve:cheetah - :* (t8-to-atom:belt-schnorr:cheetah sk) - a-gen:curve:cheetah - == - =/ sig=schnorr-signature - %+ sign:affine:belt-schnorr:cheetah - sk - (sig-hash sen) - %_ sen - signature (~(put z-by signature.sen) pk sig) + %= sen + signature (sign:signature:v0 signature.sen sk (sig-hash sen)) == + :: :: batch verification helpers ++ signatures |= sen=form @@ -671,25 +663,14 @@ |= sen=form ^- (list [schnorr-pubkey ^hash schnorr-signature]) (signatures:pkh-signature pkh.witness.sen (sig-hash sen)) + :: :: +sign: add a single signature to the witness ++ sign |= [sen=form sk=schnorr-seckey] ^+ sen - :: we must derive the pubkey from the seckey - =/ pk=schnorr-pubkey - %- ch-scal:affine:curve:cheetah - :* (t8-to-atom:belt-schnorr:cheetah sk) - a-gen:curve:cheetah - == - =/ sog=schnorr-signature - %+ sign:affine:belt-schnorr:cheetah - sk - (sig-hash sen) - =. witness.sen - %_ witness.sen - pkh (~(put z-by pkh.witness.sen) (hash:schnorr-pubkey pk) [pk sog]) - == - sen + %= sen + witness (sign:witness witness.sen sk (sig-hash sen)) + == :: :: +verify: verify the witness and each seed has correct parent-hash ++ verify @@ -1168,6 +1149,22 @@ :: timelock is dynamic tim=~ == + :: + ++ sign + |= [=form sk=schnorr-seckey sig-hash=^hash] + ^- ^form + :: we must derive the pubkey from the seckey + =/ pk=schnorr-pubkey + %- ch-scal:affine:curve:cheetah + :* (t8-to-atom:belt-schnorr:cheetah sk) + a-gen:curve:cheetah + == + =/ sog=schnorr-signature + (sign:affine:belt-schnorr:cheetah sk sig-hash) + %_ form + pkh (~(put z-by pkh.form) (hash:schnorr-pubkey pk) [pk sog]) + == + :: ++ based |= =form ^- ? diff --git a/hoon/common/tx-engine.hoon b/hoon/common/tx-engine.hoon index 300ebe15c..a15abfe68 100644 --- a/hoon/common/tx-engine.hoon +++ b/hoon/common/tx-engine.hoon @@ -175,10 +175,17 @@ --::v0 ++ v1 |% + ++ pkh-lp + |= [m=@ key-hashes=(list hash)] + ^- lock-primitive + =/ hash-set (z-silt key-hashes) + ?> (lte m ~(wyt z-in hash-set)) + [%pkh [m hash-set]] + :: ++ simple-pkh-lp |= key-hash=hash ^- lock-primitive - [%pkh [m=1 (z-silt ~[key-hash])]] + (pkh-lp m=1 ~[key-hash]) :: ++ simple |= key-hash=hash @@ -187,6 +194,13 @@ =/ lock-hash (hash:lock pkh-lock) (first:nname (hash:lock pkh-lock)) :: + ++ multisig + |= [m=@ key-hashes=(list hash)] + ^- form + =/ pkh-lock=spend-condition [(pkh-lp m key-hashes)]~ + =/ lock-hash (hash:lock pkh-lock) + (first:nname (hash:lock pkh-lock)) + :: ++ coinbase-pkh-sc |= key-hash=hash ^- spend-condition From aa345daaf54a578e8f65e4c016bc8f60c15bdca0 Mon Sep 17 00:00:00 2001 From: Sigilante <57601680+sigilante@users.noreply.github.com> Date: Tue, 25 Nov 2025 19:22:45 -0600 Subject: [PATCH 23/99] Sync from Nockup development repo. (#73) * Sync from Nockup development repo. * Change to workspace. * Post with openssl --- .github/workflows/release.yml | 456 +++ Cargo.lock | 383 ++- Cargo.toml | 5 +- crates/nockup/Cargo.lock | 2438 +++++++++++++++++ crates/nockup/Cargo.toml | 64 + crates/nockup/LICENSE | 21 + crates/nockup/README.md | 472 ++++ crates/nockup/build.rs | 67 + .../default-config-aarch64-apple-darwin.toml | 2 + ...fault-config-x86_64-unknown-linux-gnu.toml | 2 + crates/nockup/img/hero.jpg | Bin 0 -> 902052 bytes crates/nockup/install-replit.sh | 444 +++ crates/nockup/install.sh | 516 ++++ .../example-manifest-with-libraries.toml | 31 + crates/nockup/manifests/example-manifest.toml | 12 + crates/nockup/quick_start.sh | 527 ++++ crates/nockup/replit/.replit | 11 + crates/nockup/replit/setup-nockup.sh | 29 + crates/nockup/rust-toolchain.toml | 3 + crates/nockup/scripts/collate-manifests.sh | 40 + crates/nockup/scripts/generate-manifest.sh | 179 ++ crates/nockup/src/cli.rs | 51 + crates/nockup/src/commands/build.rs | 154 ++ crates/nockup/src/commands/channel.rs | 48 + crates/nockup/src/commands/common.rs | 746 +++++ crates/nockup/src/commands/init.rs | 192 ++ crates/nockup/src/commands/install.rs | 96 + crates/nockup/src/commands/mod.rs | 7 + crates/nockup/src/commands/run.rs | 61 + crates/nockup/src/commands/update.rs | 34 + crates/nockup/src/lib_manager.rs | 411 +++ crates/nockup/src/main.rs | 33 + crates/nockup/src/validation.rs | 300 ++ crates/nockup/src/version.rs | 115 + crates/nockup/templates/basic/Cargo.toml | 35 + crates/nockup/templates/basic/README.md | 61 + crates/nockup/templates/basic/build.rs | 42 + .../nockup/templates/basic/hoon/app/app.hoon | 52 + .../templates/basic/hoon/common/wrapper.hoon | 103 + .../nockup/templates/basic/hoon/lib/lib.hoon | 4 + crates/nockup/templates/basic/manifest.toml | 12 + crates/nockup/templates/basic/src/main.rs | 50 + crates/nockup/templates/grpc/Cargo.toml | 40 + crates/nockup/templates/grpc/README.md | 61 + crates/nockup/templates/grpc/build.rs | 42 + .../templates/grpc/hoon/app/listen.hoon | 54 + .../nockup/templates/grpc/hoon/app/talk.hoon | 60 + .../templates/grpc/hoon/common/wrapper.hoon | 103 + .../nockup/templates/grpc/hoon/lib/lib.hoon | 35 + crates/nockup/templates/grpc/manifest.toml | 12 + crates/nockup/templates/grpc/src/lib.rs | 12 + crates/nockup/templates/grpc/src/listen.rs | 48 + crates/nockup/templates/grpc/src/talk.rs | 71 + .../nockup/templates/http-server/Cargo.toml | 35 + crates/nockup/templates/http-server/README.md | 61 + .../templates/http-server/hoon/app/app.hoon | 117 + .../http-server/hoon/common/wrapper.hoon | 103 + .../templates/http-server/hoon/lib/http.hoon | 216 ++ .../templates/http-server/hoon/lib/lib.hoon | 4 + .../templates/http-server/manifest.toml | 12 + .../nockup/templates/http-server/src/main.rs | 28 + .../nockup/templates/http-static/Cargo.toml | 35 + crates/nockup/templates/http-static/README.md | 61 + .../templates/http-static/hoon/app/app.hoon | 103 + .../http-static/hoon/common/wrapper.hoon | 103 + .../templates/http-static/hoon/lib/lib.hoon | 4 + .../templates/http-static/manifest.toml | 12 + .../nockup/templates/http-static/src/main.rs | 28 + crates/nockup/templates/repl/Cargo.toml | 35 + crates/nockup/templates/repl/README.md | 61 + crates/nockup/templates/repl/build.rs | 42 + .../nockup/templates/repl/hoon/app/app.hoon | 60 + .../templates/repl/hoon/common/wrapper.hoon | 103 + .../nockup/templates/repl/hoon/lib/lib.hoon | 4 + crates/nockup/templates/repl/manifest.toml | 12 + crates/nockup/templates/repl/src/main.rs | 106 + crates/nockup/tests/cli_tests.rs | 275 ++ crates/nockup/zorp-gpg-key.pub | 52 + 78 files changed, 10505 insertions(+), 14 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 crates/nockup/Cargo.lock create mode 100644 crates/nockup/Cargo.toml create mode 100644 crates/nockup/LICENSE create mode 100644 crates/nockup/README.md create mode 100644 crates/nockup/build.rs create mode 100644 crates/nockup/default-config-aarch64-apple-darwin.toml create mode 100644 crates/nockup/default-config-x86_64-unknown-linux-gnu.toml create mode 100644 crates/nockup/img/hero.jpg create mode 100644 crates/nockup/install-replit.sh create mode 100644 crates/nockup/install.sh create mode 100644 crates/nockup/manifests/example-manifest-with-libraries.toml create mode 100644 crates/nockup/manifests/example-manifest.toml create mode 100644 crates/nockup/quick_start.sh create mode 100644 crates/nockup/replit/.replit create mode 100644 crates/nockup/replit/setup-nockup.sh create mode 100644 crates/nockup/rust-toolchain.toml create mode 100644 crates/nockup/scripts/collate-manifests.sh create mode 100644 crates/nockup/scripts/generate-manifest.sh create mode 100644 crates/nockup/src/cli.rs create mode 100644 crates/nockup/src/commands/build.rs create mode 100644 crates/nockup/src/commands/channel.rs create mode 100644 crates/nockup/src/commands/common.rs create mode 100644 crates/nockup/src/commands/init.rs create mode 100644 crates/nockup/src/commands/install.rs create mode 100644 crates/nockup/src/commands/mod.rs create mode 100644 crates/nockup/src/commands/run.rs create mode 100644 crates/nockup/src/commands/update.rs create mode 100644 crates/nockup/src/lib_manager.rs create mode 100644 crates/nockup/src/main.rs create mode 100644 crates/nockup/src/validation.rs create mode 100644 crates/nockup/src/version.rs create mode 100644 crates/nockup/templates/basic/Cargo.toml create mode 100644 crates/nockup/templates/basic/README.md create mode 100644 crates/nockup/templates/basic/build.rs create mode 100644 crates/nockup/templates/basic/hoon/app/app.hoon create mode 100644 crates/nockup/templates/basic/hoon/common/wrapper.hoon create mode 100644 crates/nockup/templates/basic/hoon/lib/lib.hoon create mode 100644 crates/nockup/templates/basic/manifest.toml create mode 100644 crates/nockup/templates/basic/src/main.rs create mode 100644 crates/nockup/templates/grpc/Cargo.toml create mode 100644 crates/nockup/templates/grpc/README.md create mode 100644 crates/nockup/templates/grpc/build.rs create mode 100644 crates/nockup/templates/grpc/hoon/app/listen.hoon create mode 100644 crates/nockup/templates/grpc/hoon/app/talk.hoon create mode 100644 crates/nockup/templates/grpc/hoon/common/wrapper.hoon create mode 100644 crates/nockup/templates/grpc/hoon/lib/lib.hoon create mode 100644 crates/nockup/templates/grpc/manifest.toml create mode 100644 crates/nockup/templates/grpc/src/lib.rs create mode 100644 crates/nockup/templates/grpc/src/listen.rs create mode 100644 crates/nockup/templates/grpc/src/talk.rs create mode 100644 crates/nockup/templates/http-server/Cargo.toml create mode 100644 crates/nockup/templates/http-server/README.md create mode 100644 crates/nockup/templates/http-server/hoon/app/app.hoon create mode 100644 crates/nockup/templates/http-server/hoon/common/wrapper.hoon create mode 100644 crates/nockup/templates/http-server/hoon/lib/http.hoon create mode 100644 crates/nockup/templates/http-server/hoon/lib/lib.hoon create mode 100644 crates/nockup/templates/http-server/manifest.toml create mode 100644 crates/nockup/templates/http-server/src/main.rs create mode 100644 crates/nockup/templates/http-static/Cargo.toml create mode 100644 crates/nockup/templates/http-static/README.md create mode 100644 crates/nockup/templates/http-static/hoon/app/app.hoon create mode 100644 crates/nockup/templates/http-static/hoon/common/wrapper.hoon create mode 100644 crates/nockup/templates/http-static/hoon/lib/lib.hoon create mode 100644 crates/nockup/templates/http-static/manifest.toml create mode 100644 crates/nockup/templates/http-static/src/main.rs create mode 100644 crates/nockup/templates/repl/Cargo.toml create mode 100644 crates/nockup/templates/repl/README.md create mode 100644 crates/nockup/templates/repl/build.rs create mode 100644 crates/nockup/templates/repl/hoon/app/app.hoon create mode 100644 crates/nockup/templates/repl/hoon/common/wrapper.hoon create mode 100644 crates/nockup/templates/repl/hoon/lib/lib.hoon create mode 100644 crates/nockup/templates/repl/manifest.toml create mode 100644 crates/nockup/templates/repl/src/main.rs create mode 100644 crates/nockup/tests/cli_tests.rs create mode 100644 crates/nockup/zorp-gpg-key.pub diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..2fcbc939f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,456 @@ +name: Release Binaries and Generate Manifests + +on: + push: + branches: ["master", "nightly"] + tags: ["v*"] + pull_request: + branches: ["master", "nightly"] + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + use_zigbuild: false + - os: ubuntu-latest + target: aarch64-unknown-linux-gnu + use_zigbuild: true + - os: macos-latest + target: aarch64-apple-darwin + use_zigbuild: false + permissions: + contents: write # Required for action-gh-release + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Determine channel + id: channel + run: | + if [[ "${{ github.ref_type }}" == "tag" ]]; then + echo "channel=stable" >> $GITHUB_OUTPUT + echo "version=${{ github.ref_name }}" >> $GITHUB_OUTPUT + elif [[ "${{ github.ref_name }}" == "master" ]]; then + echo "channel=stable" >> $GITHUB_OUTPUT + SHORT_SHA=$(echo ${{ github.sha }} | cut -c1-7) + echo "version=${SHORT_SHA}" >> $GITHUB_OUTPUT + elif [[ "${{ github.ref_name }}" == "nightly" ]]; then + echo "channel=nightly" >> $GITHUB_OUTPUT + DATE=$(date +%Y%m%d) + echo "version=${DATE}" >> $GITHUB_OUTPUT + else + echo "channel=dev" >> $GITHUB_OUTPUT + SHORT_SHA=$(echo ${{ github.sha }} | cut -c1-7) + echo "version=${SHORT_SHA}" >> $GITHUB_OUTPUT + fi + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "channel=pr" >> $GITHUB_OUTPUT + echo "version=pr-${{ github.event.number }}" >> $GITHUB_OUTPUT + fi + + - name: Extract Cargo versions + id: cargo-versions + run: | + # Get workspace version - simpler approach + HOON_VERSION=$(sed -n '/\[workspace.package\]/,/\[/p' Cargo.toml | grep '^version' | cut -d'"' -f2) + + # Get hoonc version + HOONC_VERSION=$(grep '^version' crates/hoonc/Cargo.toml | cut -d'"' -f2) + + # Get nockup version + NOCKUP_VERSION=$(grep '^version' crates/nockup/Cargo.toml | cut -d'"' -f2) + + # For nightly, append date + if [[ "${{ steps.channel.outputs.channel }}" == "nightly" ]]; then + DATE=$(date +%Y%m%d) + HOON_VERSION="${HOON_VERSION}-${DATE}" + HOONC_VERSION="${HOONC_VERSION}-${DATE}" + NOCKUP_VERSION="${NOCKUP_VERSION}-${DATE}" + fi + + # Debug output + echo "HOON_VERSION='$HOON_VERSION'" + echo "HOONC_VERSION='$HOONC_VERSION'" + echo "NOCKUP_VERSION='$NOCKUP_VERSION'" + + # Validate versions + if [[ -z "$HOON_VERSION" || -z "$HOONC_VERSION" || -z "$NOCKUP_VERSION" ]]; then + echo "Error: Missing version in one or more Cargo.toml files" + exit 1 + fi + + echo "hoon_version=$HOON_VERSION" >> $GITHUB_OUTPUT + echo "hoonc_version=$HOONC_VERSION" >> $GITHUB_OUTPUT + echo "nockup_version=$NOCKUP_VERSION" >> $GITHUB_OUTPUT + echo "Extracted versions: hoon=$HOON_VERSION, hoonc=$HOONC_VERSION, nockup=$NOCKUP_VERSION" + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@nightly + with: + targets: ${{ matrix.target }} + + - name: Install Zig and cargo-zigbuild + if: matrix.use_zigbuild + run: | + # Install Zig + wget -q https://ziglang.org/download/0.13.0/zig-linux-x86_64-0.13.0.tar.xz + tar -xf zig-linux-x86_64-0.13.0.tar.xz + sudo mv zig-linux-x86_64-0.13.0 /usr/local/zig + sudo ln -s /usr/local/zig/zig /usr/local/bin/zig + zig version + + # Install cargo-zigbuild + cargo install --locked cargo-zigbuild + + # Add target + rustup target add ${{ matrix.target }} + + - name: Cache Rust dependencies + uses: swatinem/rust-cache@v2 + with: + key: ${{ matrix.target }} + + - name: Install required tools for manifest generation + run: | + # Install blake3 hash tool from pre-built binaries + if [[ "${{ runner.os }}" == "Linux" ]]; then + wget -q https://github.com/BLAKE3-team/BLAKE3/releases/download/1.5.0/b3sum_linux_x64_bin -O b3sum + chmod +x b3sum + sudo mv b3sum /usr/local/bin/ + sudo apt-get update + sudo apt-get install -y jq + elif [[ "${{ runner.os }}" == "macOS" ]]; then + brew install b3sum jq + fi + + - name: Install cross-compilation dependencies + if: matrix.use_zigbuild + run: | + # Install perl (needed for OpenSSL build) + sudo apt-get update + sudo apt-get install -y perl + + - name: Build hoon + env: + CC: ${{ matrix.use_zigbuild && 'zig cc -target aarch64-linux-gnu' || '' }} + CXX: ${{ matrix.use_zigbuild && 'zig c++ -target aarch64-linux-gnu' || '' }} + AR: ${{ matrix.use_zigbuild && 'zig ar' || '' }} + run: | + if [[ "${{ matrix.use_zigbuild }}" == "true" ]]; then + cargo zigbuild --release --bin hoon --manifest-path crates/hoon/Cargo.toml --locked --target ${{ matrix.target }} + file target/${{ matrix.target }}/release/hoon + else + cargo build --release --bin hoon --manifest-path crates/hoon/Cargo.toml --locked + file target/release/hoon + fi + + - name: Build hoonc + env: + CC: ${{ matrix.use_zigbuild && 'zig cc -target aarch64-linux-gnu' || '' }} + CXX: ${{ matrix.use_zigbuild && 'zig c++ -target aarch64-linux-gnu' || '' }} + AR: ${{ matrix.use_zigbuild && 'zig ar' || '' }} + run: | + if [[ "${{ matrix.use_zigbuild }}" == "true" ]]; then + cargo zigbuild --release --bin hoonc --manifest-path crates/hoonc/Cargo.toml --locked --target ${{ matrix.target }} + file target/${{ matrix.target }}/release/hoonc + else + cargo build --release --bin hoonc --manifest-path crates/hoonc/Cargo.toml --locked + file target/release/hoonc + fi + + - name: Build nockup + env: + CC: ${{ matrix.use_zigbuild && 'zig cc -target aarch64-linux-gnu' || '' }} + CXX: ${{ matrix.use_zigbuild && 'zig c++ -target aarch64-linux-gnu' || '' }} + AR: ${{ matrix.use_zigbuild && 'zig ar' || '' }} + run: | + if [[ "${{ matrix.use_zigbuild }}" == "true" ]]; then + cargo zigbuild --release --bin nockup --manifest-path crates/nockup/Cargo.toml --target ${{ matrix.target }} --features vendored-openssl + file target/${{ matrix.target }}/release/nockup + else + cargo build --release --bin nockup --manifest-path crates/nockup/Cargo.toml --locked + file target/release/nockup + fi + + - name: Set up GPG + if: runner.os == 'Linux' && github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/nightly' || github.ref_type == 'tag') + run: | + # Import GPG private key + echo "${{ secrets.GPG_PRIVATE_KEY }}" | tr -d '\n' | base64 -d | gpg --batch --import + # Configure GPG for non-interactive use + echo "use-agent" >> ~/.gnupg/gpg.conf + echo "pinentry-mode loopback" >> ~/.gnupg/gpg.conf + echo "batch" >> ~/.gnupg/gpg.conf + # Get the full fingerprint and trust the key + FINGERPRINT=$(gpg --list-secret-keys --with-colons | grep '^fpr:' | head -1 | cut -d: -f10) + echo "${FINGERPRINT}:6:" | gpg --import-ownertrust + # Verify key is available + gpg --list-secret-keys + + - name: Package and sign binaries + run: | + mkdir -p dist + CHANNEL="${{ steps.channel.outputs.channel }}" + SHOULD_SIGN="${{ runner.os == 'Linux' && github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/nightly' || github.ref_type == 'tag') }}" + + if [[ "$CHANNEL" == "pr" ]]; then + echo "Skipping packaging for PRs" + exit 0 + fi + + # Determine binary directory based on build method + if [[ "${{ matrix.use_zigbuild }}" == "true" ]]; then + BINARY_DIR="target/${{ matrix.target }}/release" + else + BINARY_DIR="target/release" + fi + + # Copy and make executable + for binary in hoon hoonc nockup; do + if [ -f "$BINARY_DIR/$binary" ]; then + cp "$BINARY_DIR/$binary" dist/$binary + chmod +x dist/$binary + + # Sign individual binary if this is a release build + if [[ "$SHOULD_SIGN" == "true" ]]; then + echo "${{ secrets.GPG_PASSPHRASE }}" | gpg --batch --yes --passphrase-fd 0 --pinentry-mode loopback --detach-sign --armor --default-key "${{ secrets.GPG_KEY_ID }}" dist/$binary + echo "Created dist/$binary.asc" + fi + + # Create archive + tar -czf dist/${binary}-${CHANNEL}-latest-${{ matrix.target }}.tar.gz -C dist $binary + + # Sign the archive if this is a release build + if [[ "$SHOULD_SIGN" == "true" ]]; then + echo "${{ secrets.GPG_PASSPHRASE }}" | gpg --batch --yes --passphrase-fd 0 --pinentry-mode loopback --detach-sign --armor --default-key "${{ secrets.GPG_KEY_ID }}" dist/${binary}-${CHANNEL}-latest-${{ matrix.target }}.tar.gz + fi + else + echo "Skipping $binary: binary missing at $BINARY_DIR/$binary" + fi + done + + echo "Packaged artifacts for channel $CHANNEL:" + ls -la dist/ + + - name: Generate individual manifests + if: steps.channel.outputs.channel != 'pr' + run: | + mkdir -p scripts + + # Create manifest generation script + cat << 'EOF' > scripts/generate-manifest.sh + #!/bin/bash + set -euo pipefail + + BINARY=$1 + TARGET=$2 + CHANNEL=$3 + VERSION=$4 + COMMIT_SHA=${GITHUB_SHA:-$(git rev-parse HEAD)} + DATE=$(date +%Y-%m-%d) + + # Calculate hashes from the packaged archive + ARCHIVE_PATH="dist/${BINARY}-${CHANNEL}-latest-${TARGET}.tar.gz" + + if [ ! -f "$ARCHIVE_PATH" ]; then + echo "Error: Archive not found at $ARCHIVE_PATH" >&2 + exit 1 + fi + + BLAKE3_HASH=$(b3sum "$ARCHIVE_PATH" | cut -d' ' -f1) + SHA1_HASH=$(sha1sum "$ARCHIVE_PATH" 2>/dev/null | cut -d' ' -f1 || shasum -a 1 "$ARCHIVE_PATH" | cut -d' ' -f1) + + # Generate URL + URL="https://github.com/nockchain/nockchain/releases/download/$CHANNEL-build-$COMMIT_SHA/$BINARY-$CHANNEL-latest-$TARGET.tar.gz" + + # Generate manifest + cat << MANIFEST_EOF + manifest-version = "1" + date = "$DATE" + + [pkg.$BINARY] + version = "$VERSION" + components = ["core"] + + [pkg.$BINARY.target.$TARGET] + available = true + url = "$URL" + hash_blake3 = "$BLAKE3_HASH" + hash_sha1 = "$SHA1_HASH" + MANIFEST_EOF + EOF + + chmod +x scripts/generate-manifest.sh + + # Generate manifests for each binary that was built + CHANNEL="${{ steps.channel.outputs.channel }}" + for binary in hoon hoonc nockup; do + if [ -f "dist/${binary}-${CHANNEL}-latest-${{ matrix.target }}.tar.gz" ]; then + ./scripts/generate-manifest.sh $binary ${{ matrix.target }} "${CHANNEL}" "latest" > "${binary}-${{ matrix.target }}-manifest.toml" + echo "Generated manifest for $binary" + fi + done + + - name: Upload individual manifests + if: steps.channel.outputs.channel != 'pr' + uses: actions/upload-artifact@v4 + with: + name: toolchains-${{ matrix.target }} + path: "*-${{ matrix.target }}-manifest.toml" + retention-days: 7 + + - name: Upload release assets + if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/nightly' || github.ref_type == 'tag') + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ steps.channel.outputs.channel }}-build-${{ github.sha }} + name: ${{ steps.channel.outputs.channel }} Build ${{ github.sha }} + files: | + dist/hoon-${{ steps.channel.outputs.channel }}-latest-${{ matrix.target }}.tar.gz + ${{ runner.os == 'Linux' && format('dist/hoon-{0}-latest-{1}.tar.gz.asc', steps.channel.outputs.channel, matrix.target) || '' }} + dist/hoonc-${{ steps.channel.outputs.channel }}-latest-${{ matrix.target }}.tar.gz + ${{ runner.os == 'Linux' && format('dist/hoonc-{0}-latest-{1}.tar.gz.asc', steps.channel.outputs.channel, matrix.target) || '' }} + dist/nockup-${{ steps.channel.outputs.channel }}-latest-${{ matrix.target }}.tar.gz + ${{ runner.os == 'Linux' && format('dist/nockup-{0}-latest-{1}.tar.gz.asc', steps.channel.outputs.channel, matrix.target) || '' }} + + collate-manifests: + name: Collate Channel Manifests + needs: build + runs-on: ubuntu-latest + if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/nightly' || github.ref_type == 'tag') + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install jq + run: sudo apt-get update && sudo apt-get install -y jq + + - name: Download all manifest artifacts + uses: actions/download-artifact@v4 + with: + path: toolchains/ + pattern: toolchains-* + merge-multiple: true + + - name: List downloaded manifests + run: | + echo "Downloaded manifests:" + find toolchains/ -name "*.toml" -type f | sort + + - name: Create collation script + run: | + mkdir -p scripts + cat << 'EOF' > scripts/collate-manifests.sh + #!/bin/bash + set -euo pipefail + + CHANNEL=$1 + MANIFEST_DIR=$2 + + # Get nockup version from the workspace + NOCKUP_VERSION=$(grep '^version[[:space:]]*=' crates/nockup/Cargo.toml | sed 's/.*=[[:space:]]*"\([^"]*\)".*/\1/' | head -1) + + if [ -z "$NOCKUP_VERSION" ]; then + echo "Error: Could not extract nockup version" >&2 + exit 1 + fi + + # For nightly, append date + if [[ "$CHANNEL" == "nightly" ]]; then + DATE=$(date +%Y%m%d) + NOCKUP_VERSION="${NOCKUP_VERSION}-${DATE}" + fi + + # Start with global metadata + cat << HEADER_EOF + manifest-version = "1" + date = "$(date +%Y-%m-%d)" + + # Global package info + [pkg.nockup] + version = "$NOCKUP_VERSION" + components = ["core"] + extensions = [] + + # Profiles + [profiles.default] + components = ["core"] + [profiles.minimal] + components = ["core"] + + HEADER_EOF + + # Process manifests by package, then by target + for package in nockup hoonc hoon; do + echo "# $package binaries" + + # Find all manifests for this package + find "$MANIFEST_DIR" -name "${package}-*-manifest.toml" -type f | sort | while read manifest; do + if [ -f "$manifest" ]; then + echo "# From $(basename "$manifest")" + # Extract only the target-specific sections + awk -v pkg="$package" ' + /^\[pkg\./ { + if ($0 ~ "\\[pkg\\." pkg "\\.target\\.") { + printing = 1 + } else { + printing = 0 + } + } + /^available|^url|^hash_/ { + if (printing) print + } + /^\[pkg\..*\.target\./ { + if (printing) print + } + ' "$manifest" + echo "" + fi + done + done + EOF + + chmod +x scripts/collate-manifests.sh + + - name: Determine channel from ref + id: channel + run: | + if [[ "${{ github.ref_type }}" == "tag" ]]; then + echo "channel=stable" >> $GITHUB_OUTPUT + elif [[ "${{ github.ref_name }}" == "master" ]]; then + echo "channel=stable" >> $GITHUB_OUTPUT + elif [[ "${{ github.ref_name }}" == "nightly" ]]; then + echo "channel=nightly" >> $GITHUB_OUTPUT + else + echo "channel=dev" >> $GITHUB_OUTPUT + fi + + - name: Collate manifests + run: | + ./scripts/collate-manifests.sh ${{ steps.channel.outputs.channel }} toolchains/ > ${{ steps.channel.outputs.channel }}-manifest.toml + + - name: Show final manifest + run: | + echo "=== Final ${{ steps.channel.outputs.channel }} manifest ===" + cat ${{ steps.channel.outputs.channel }}-manifest.toml + + - name: Upload final manifest + uses: actions/upload-artifact@v4 + with: + name: ${{ steps.channel.outputs.channel }}-manifest + path: ${{ steps.channel.outputs.channel }}-manifest.toml + retention-days: 30 + + - name: Add manifest to existing release + uses: softprops/action-gh-release@v1 + with: + tag_name: ${{ steps.channel.outputs.channel }}-build-${{ github.sha }} + files: | + ${{ steps.channel.outputs.channel }}-manifest.toml + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index 117c58d65..482d27fb5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -235,6 +235,21 @@ dependencies = [ "syn", ] +[[package]] +name = "assert_cmd" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcbb6924530aa9e0432442af08bbcafdad182db80d2e560da42a6d442535bf85" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "async-io" version = "2.6.0" @@ -556,7 +571,7 @@ dependencies = [ "rustc-hash 1.1.0", "shlex", "syn", - "which", + "which 4.4.2", ] [[package]] @@ -579,6 +594,21 @@ dependencies = [ "syn", ] +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + [[package]] name = "bit_field" version = "0.10.3" @@ -731,6 +761,17 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -900,6 +941,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link 0.2.0", ] @@ -1033,6 +1075,16 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + [[package]] name = "compact_str" version = "0.8.1" @@ -1517,6 +1569,37 @@ dependencies = [ "powerfmt", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + [[package]] name = "derive_more" version = "2.0.1" @@ -1539,6 +1622,12 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -1719,6 +1808,12 @@ dependencies = [ "log", ] +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + [[package]] name = "env_logger" version = "0.8.4" @@ -1883,14 +1978,23 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.2" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a3d7db9596fecd151c5f638c0ee5d5bd487b6e0ea232e5dc96d5250f6f94b1d" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" dependencies = [ "crc32fast", "miniz_oxide", ] +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "fnv" version = "1.0.7" @@ -2228,6 +2332,22 @@ dependencies = [ "crunchy", ] +[[package]] +name = "handlebars" +version = "6.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "759e2d5aea3287cb1190c8ec394f42866cb5bf74fcbf213f354e3c856ea26098" +dependencies = [ + "derive_builder", + "log", + "num-order", + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror 2.0.16", +] + [[package]] name = "hashbrown" version = "0.14.5" @@ -2514,6 +2634,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -4173,6 +4294,37 @@ dependencies = [ "zkvm-jetpack", ] +[[package]] +name = "nockup" +version = "0.4.0" +dependencies = [ + "anyhow", + "assert_cmd", + "blake3", + "chrono", + "clap", + "colored", + "dirs", + "flate2", + "fs_extra", + "handlebars", + "hex", + "openssl-sys", + "predicates", + "proptest", + "reqwest", + "serde", + "serde_json", + "sha1", + "tar", + "tempfile", + "thiserror 2.0.16", + "tokio", + "toml", + "walkdir", + "which 8.0.0", +] + [[package]] name = "nockvm" version = "0.1.0" @@ -4243,6 +4395,12 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "notify" version = "5.2.0" @@ -4340,6 +4498,21 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-modular" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17bb261bf36fa7d83f4c294f834e91256769097b3cb505d44831e0a179ac647f" + +[[package]] +name = "num-order" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537b596b97c40fcf8056d153049eb22f481c17ebce72a513ec9286e4986d1bb6" +dependencies = [ + "num-modular", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -4483,6 +4656,28 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-src" +version = "300.5.4+3.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507b3792995dae9b0df8a1c1e3771e8418b7c2d9f0baeba32e6fe8b06c7cb72" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + [[package]] name = "opentelemetry" version = "0.30.0" @@ -4739,6 +4934,12 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + [[package]] name = "plotters" version = "0.3.7" @@ -4837,6 +5038,36 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + [[package]] name = "prettyplease" version = "0.2.37" @@ -4901,6 +5132,25 @@ dependencies = [ "syn", ] +[[package]] +name = "proptest" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.9.4", + "num-traits", + "rand 0.9.2", + "rand_chacha 0.9.0", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + [[package]] name = "prost" version = "0.13.5" @@ -5060,6 +5310,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + [[package]] name = "quick-error" version = "2.0.1" @@ -5243,6 +5499,15 @@ dependencies = [ "getrandom 0.3.3", ] +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.3", +] + [[package]] name = "ratatui" version = "0.28.1" @@ -5420,23 +5685,31 @@ checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" dependencies = [ "base64 0.22.1", "bytes", + "encoding_rs", "futures-channel", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", + "mime", "percent-encoding", "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-rustls", "tower", "tower-http", "tower-service", @@ -5444,6 +5717,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "webpki-roots", ] [[package]] @@ -5681,6 +5955,18 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] + [[package]] name = "rw-stream-sink" version = "0.4.0" @@ -5875,9 +6161,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5417783452c2be558477e104686f7de5dae53dba813c28435e0e70f82d9b04ee" +checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" dependencies = [ "serde_core", ] @@ -6242,6 +6528,17 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.23.0" @@ -6280,6 +6577,12 @@ dependencies = [ "unicode-width 0.1.14", ] +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "test-log" version = "0.2.18" @@ -6371,7 +6674,7 @@ dependencies = [ "fax", "flate2", "half 2.6.0", - "quick-error", + "quick-error 2.0.1", "weezl", "zune-jpeg", ] @@ -6556,35 +6859,43 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.7" +version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00e5e5d9bf2475ac9d4f0d9edab68cc573dc2fd644b0dba36b0c30a92dd9eaa0" +checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" dependencies = [ + "indexmap", "serde_core", "serde_spanned", "toml_datetime", "toml_parser", + "toml_writer", "winnow", ] [[package]] name = "toml_datetime" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f1085dec27c2b6632b04c80b3bb1b4300d6495d1e129693bdda7d91e72eec1" +checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" dependencies = [ "serde_core", ] [[package]] name = "toml_parser" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cf893c33be71572e0e9aa6dd15e6677937abd686b066eac3f8cd3531688a627" +checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" dependencies = [ "winnow", ] +[[package]] +name = "toml_writer" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" + [[package]] name = "tonic" version = "0.13.1" @@ -6936,6 +7247,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + [[package]] name = "unicase" version = "2.8.1" @@ -7042,6 +7359,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "vergen" version = "8.3.2" @@ -7087,6 +7410,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -7249,6 +7581,17 @@ dependencies = [ "rustix 0.38.44", ] +[[package]] +name = "which" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" +dependencies = [ + "env_home", + "rustix 1.1.2", + "winsafe", +] + [[package]] name = "widestring" version = "1.2.0" @@ -7842,6 +8185,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "wit-bindgen" version = "0.46.0" @@ -7907,6 +8256,16 @@ dependencies = [ "time", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.2", +] + [[package]] name = "xml-rs" version = "0.8.27" diff --git a/Cargo.toml b/Cargo.toml index 8de3d5887..de528fb7d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ resolver = "2" [workspace] -members = ["crates/nockchain-api", "crates/nockchain-explorer-tui", "crates/nockchain-peek", "crates/equix-latency", "crates/hoon", "crates/hoonc", "crates/kernels", "crates/nockapp", "crates/nockapp-grpc", "crates/nockapp-grpc-proto", "crates/nockchain", "crates/nockchain-libp2p-io", "crates/nockchain-math", "crates/nockchain-types", "crates/nockchain-wallet", "crates/raw-tx-checker", "crates/nockvm/rust/ibig", "crates/nockvm/rust/murmur3", "crates/nockvm/rust/nockvm", "crates/nockvm/rust/nockvm_macros", "crates/noun-serde", "crates/noun-serde-derive", "crates/zkvm-jetpack"] +members = ["crates/nockchain-api", "crates/nockchain-explorer-tui", "crates/nockchain-peek", "crates/equix-latency", "crates/hoon", "crates/hoonc", "crates/kernels", "crates/nockapp", "crates/nockapp-grpc", "crates/nockapp-grpc-proto", "crates/nockchain", "crates/nockchain-libp2p-io", "crates/nockchain-math", "crates/nockchain-types", "crates/nockchain-wallet", "crates/raw-tx-checker", "crates/nockvm/rust/ibig", "crates/nockvm/rust/murmur3", "crates/nockvm/rust/nockvm", "crates/nockvm/rust/nockvm_macros", "crates/noun-serde", "crates/noun-serde-derive", "crates/zkvm-jetpack", "crates/nockup"] [workspace.package] version = "0.1.0" @@ -27,7 +27,7 @@ bytes = "1.5.0" cbor4ii = "1.0.0" cfg-if = "1.0.0" chrono = "0.4.40" -clap = "4.4.4" +clap = { version = "4.4.4", features = ["derive"] } config = "0.15" console-subscriber = "0.4.1" criterion = { git = "https://github.com/vlovich/criterion.rs.git", rev = "9b485aece85a3546126b06cc25d33e14aba829b3", features = [ @@ -92,6 +92,7 @@ reqwest = { version = "0.12", default-features = false, features = [ "rustls-tls", "http2", "charset", + "json", ] } rkyv = "0.8.10" rustls = "0.23.0" diff --git a/crates/nockup/Cargo.lock b/crates/nockup/Cargo.lock new file mode 100644 index 000000000..9c4ba33ad --- /dev/null +++ b/crates/nockup/Cargo.lock @@ -0,0 +1,2438 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + +[[package]] +name = "anyhow" +version = "1.0.100" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "assert_cmd" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcbb6924530aa9e0432442af08bbcafdad182db80d2e560da42a6d442535bf85" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" + +[[package]] +name = "blake3" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + +[[package]] +name = "bytes" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" + +[[package]] +name = "cc" +version = "1.2.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37521ac7aabe3d13122dc382493e20c9416f299d2ccd5b3a5340a2570cdeb0f3" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c26d721170e0295f191a69bd9a1f93efcdb0aff38684b61ab5750468972e5f5" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75835f0c7bf681bfd05abe44e965760fea999a5286c6eb2d59883634fd02011a" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "colored" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c" +dependencies = [ + "lazy_static", + "windows-sys 0.59.0", +] + +[[package]] +name = "constant_time_eq" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "env_home" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "filetime" +version = "0.2.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +dependencies = [ + "cfg-if", + "libc", + "libredox", + "windows-sys 0.60.2", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52051878f80a721bb68ebfbc930e07b65ba72f2da88968ea5c06fd6ca3d3a127" + +[[package]] +name = "flate2" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" + +[[package]] +name = "futures-io" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[package]] +name = "futures-sink" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" + +[[package]] +name = "futures-task" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[package]] +name = "futures-util" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +dependencies = [ + "futures-core", + "futures-io", + "futures-task", + "memchr", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "handlebars" +version = "4.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faa67bab9ff362228eb3d00bd024a4965d8231bbb7921167f0cfa66c6626b225" +dependencies = [ + "log", + "pest", + "pest_derive", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "hashbrown" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6717a8d2a5a929a1a2eb43a12812498ed141a0bcfb7e8f7844fbdbe4303bba9f" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" + +[[package]] +name = "js-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.177" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" + +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags 2.10.0", + "libc", + "redox_syscall", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "log" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" + +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69d83b0086dc8ecf3ce9ae2874b2d1290252e2a30720bea58a5c6639b0092873" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "nockup" +version = "0.4.0" +dependencies = [ + "anyhow", + "assert_cmd", + "blake3", + "chrono", + "clap", + "colored", + "dirs", + "flate2", + "fs_extra", + "handlebars", + "hex", + "predicates", + "proptest", + "reqwest", + "serde", + "serde_json", + "sha1", + "tar", + "tempfile", + "thiserror", + "tokio", + "toml", + "walkdir", + "which", +] + +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl" +version = "0.10.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ad14dd45412269e1a30f52ad8f0664f0f4f4a89ee8fe28c3b3527021ebb654" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.110" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a9f0075ba3c21b09f8e8b2026584b1d18d49388648f2fbbf3c97ea8deced8e2" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "989e7521a040efde50c3ab6bbadafbe15ab6dc042686926be59ac35d74607df4" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "187da9a3030dbafabbbfb20cb323b976dc7b7ce91fcd84f2f74d6e31d378e2de" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49b401d98f5757ebe97a26085998d6c0eecec4995cad6ab7fc30ffdf4b052843" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f27a2cfee9f9039c4d86faa5af122a0ac3851441a34865b8a043b46be0065a" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "predicates" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" + +[[package]] +name = "predicates-tree" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags 2.10.0", + "num-traits", + "rand", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quote" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.10.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror", +] + +[[package]] +name = "regex" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "hyper", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "system-configuration", + "tokio", + "tokio-native-tls", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "winreg", +] + +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags 2.10.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.10.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +dependencies = [ + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" + +[[package]] +name = "slab" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.1", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "551f88106c6d5e7ccc7cd9a16f312dd3b5d36ea8b4954304657d5dfba115d4a0" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.82" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a1f95c0d03a47f4ae1f7a64643a6bb97465d9b740f0fa8f90ea33915c99a9a1" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "which" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" +dependencies = [ + "env_home", + "rustix", + "winsafe", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/crates/nockup/Cargo.toml b/crates/nockup/Cargo.toml new file mode 100644 index 000000000..49fa8877e --- /dev/null +++ b/crates/nockup/Cargo.toml @@ -0,0 +1,64 @@ +[package] +name = "nockup" +version = "0.4.0" +edition = "2021" +authors = [""] +description = "A developer support framework for NockApp development" +license = "MIT" +repository = "https://github.com/sigilante/nockup" +homepage = "https://github.com/sigilante/nockup" +documentation = "https://docs.nockchain.org/" +readme = "README.md" +keywords = ["nockchain", "nockapp", "development", "cli"] +categories = ["command-line-utilities", "development-tools"] + +[[bin]] +name = "nockup" +path = "src/main.rs" + +[dependencies] +anyhow = { workspace = true } +blake3 = { workspace = true } +chrono = { version = "0.4", features = ["serde"] } +clap = { workspace = true } +colored = "2.0" +dirs = { workspace = true } +flate2 = "1.1.5" +fs_extra = "1.3" +handlebars = "6.3.2" +hex = { workspace = true } +openssl-sys = { version = "0.9", optional = true } +proptest = "1.9.0" +reqwest = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +sha1 = { workspace = true } +tar = "0.4.44" +thiserror = { workspace = true } +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "fs", "process"] } +toml = "0.9.8" +walkdir = "2.5.0" +which = "8.0" + +[build-dependencies] +# For generating version info at build time +chrono = { version = "0.4", features = ["serde"] } + +[dev-dependencies] +# Testing utilities +assert_cmd = "2.0" +predicates = "3.0" +tempfile = "3.0" + +# Performance profiling (optional) +[profile.release] +lto = true +codegen-units = 1 +panic = "abort" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + +[features] +vendored-openssl = ["openssl-sys", "openssl-sys/vendored"] \ No newline at end of file diff --git a/crates/nockup/LICENSE b/crates/nockup/LICENSE new file mode 100644 index 000000000..b77bf2ab7 --- /dev/null +++ b/crates/nockup/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/crates/nockup/README.md b/crates/nockup/README.md new file mode 100644 index 000000000..9d240ae80 --- /dev/null +++ b/crates/nockup/README.md @@ -0,0 +1,472 @@ +# *Nockup*: the NockApp channel installer + +*Nockup* is a command-line tool to produce [NockApps](https://github.com/nockchain/nockchain) and manage project builds and dependencies. + +> 🚨 **Status** Pre-release development. +> +> * Nockup has CLI functionality and basic project templating. +> +> * Nockchain interaction templates are in active development. +> +> * Nockup will be officially released as a crate within [Nockchain](https://github.com/nockchain/nockchain). + +[The NockApp platform](https://github.com/nockchain/nockchain) is a general-purpose framework for building apps that run using the Nock instruction set architecture. It is particularly well-suited for use with [Nockchain](https://nockchain.org) and the Nock ZKVM. + +![](./img/hero.jpg) + +## Installation + +### From Script + +Prerequisites: Rust toolchain (`rustup`, `cargo`, &c.), Git. + +```sh +curl -fsSL https://raw.githubusercontent.com/nockchain/nockchain/refs/head/master/crates/nockup/install.sh | bash +``` + +This checks for dependencies and then installs the Nockup binary and its requirements, including the GPG key used to verify binaries on Linux. (This is from the `stable` channel by default; see [Channels](#channels) for more information.) + +### From Source + +Prerequisites: Rust toolchain, Git + +0. Before building, switch your `rustup` to `nightly` to satisfy `nockapp`/`nockvm` dependencies. + + ```sh + rustup update + rustup install nightly + rustup override set nightly + ``` + +1. Install Nockchain and build `hoon` and `hoonc`. + + ```sh + $ git clone https://github.com/nockchain/nockchain.git + $ cd nockchain + $ make install-hoonc + $ cargo install --locked --force --path crates/hoon --bin hoon + ``` + +2. Install Nockup. + + ```sh + $ git clone https://github.com/nockchain/nockchain.git + $ cd crates/nockup/ + $ cargo build --release + ``` + + `nockup` builds by default in `./target/release`, so further commands to `nockup` refer to it in whatever location you have it. `nockup install` will provide it in your `$PATH`. + + Alternatively, you may install it globally using Cargo: + + ```sh + $ cargo install --path . --locked + ``` + +3. Install the GPG public key (on Linux). Nockup **will not work** if you do not provide the public key. + + ```sh + $ gpg --keyserver keyserver.ubuntu.com --recv-keys A6FFD2DB7D4C9710 + ``` + +4. Install `nockup` and dependencies. + + ```sh + nockup install + ``` + +5. Check for updates. + + ```sh + $ nockup update + ``` + +### On Replit + +A [Replit template is available](https://replit.com/@neal50/NockApp?v=1) which demonstrates Nockup functionality in the cloud. Due to Replit's memory limitations, its current functionality is not extensive. + +## Tutorial + +Nockup provides a command-line interface for managing NockApp projects. It uses binaries to process manifest files to create NockApp projects from templates then build and run them. + +```sh +# Show basic program information. (On some systems, like Docker +# containers, the hoon and hoonc binaries are not identified.) +$ nockup +nockup version 0.0.1 +hoon version 0.1.0 +hoonc version 0.2.0 +current channel stable +current architecture aarch64 + +# Start the nockup environment. +$ nockup install +🚀 Setting up nockup cache directory... +📁 Cache location: /Users/myuser/.nockup +📁 Creating cache directory structure... +✓ Created directory structure +⬇️ Downloading templates from GitHub... +Cloning into '/Users/myuser/.nockup/temp_repo'... +remote: Enumerating objects: 36, done. +remote: Counting objects: 100% (36/36), done. +remote: Compressing objects: 100% (30/30), done. +remote: Total 36 (delta 1), reused 18 (delta 0), pack-reused 0 (from 0) +Receiving objects: 100% (36/36), 45.18 KiB | 1.56 MiB/s, done. +Resolving deltas: 100% (1/1), done. +✓ Templates downloaded successfully +✅ Setup complete! +📂 Templates are now available in: /Users/myuser/.nockup/templates + +# Initialize a default project. +$ cp ~/.nockup/manifests/example-manifest.toml arcadia.toml +$ nockup init arcadia +Initializing new NockApp project 'arcadia'... + create Cargo.toml + create manifest.toml + create Cargo.lock + create hoon/app/app.hoon + create hoon/common/wrapper.hoon + create hoon/lib/lib.hoon + create hoon/lib/http.hoon + create README.md + create src/main.rs +📚 Processing library dependencies... + ⬇️ Fetching library 'sequent'... + ⬇️ Cloning repository... + copy sys.kelvin + copy lib/seq.hoon + copy lib/test.hoon + ✓ Installed library 'sequent' +✓ All libraries processed successfully! +✓ New project created in ./arcadia// +To get started: + nockup build arcadia + nockup run arcadia + +# Show project settings. +$ cd arcadia +$ ls +Cargo.lock Cargo.toml hoon manifest.toml README.md src + +$ cd .. + +# Build the project (wraps hoonc). +$ nockup build arcadia +🔨 Building project 'arcadia'... + Updating crates.io index + Updating git repository `https://github.com/nockchain/nockchain.git` + Locking 486 packages to latest compatible versions + Adding matchit v0.8.4 (available: v0.8.6) + Adding toml v0.8.23 (available: v0.9.5) + Compiling proc-macro2 v1.0.101 +* * * +I (11:53:08) "hoonc: build succeeded, sending out write effect" +I (11:53:08) "hoonc: output written successfully to '/Users/myuser/nockchain/nockup/arcadia/out.jam'" +no panic! +✓ Hoon compilation completed successfully! + +# Run the project (wraps hoon). +$ nockup run arcadia +🔨 Running project 'arcadia'... + Finished `release` profile [optimized] target(s) in 0.31s + Running `target/release/arcadia` +I (11:53:14) [no] kernel::boot: Tracy tracing is enabled +I (11:53:14) [no] kernel::boot: kernel: starting +W (11:53:15) poked: cause +I (11:53:15) Pokes awaiting implementation + +✓ Run completed successfully! +``` + +The final product is, of course, a binary which you may run either via `nockup run` (as demonstrated here) or directly (from `./target/release`). + +### Project Templates and Manifests + +A NockApp consists of a Rust wrapper and a Nock ISA kernel. The wrapper handles command-line arguments, filesystem I/O, etc. The kernel is the business logic. + +One of the design goals of Nockup is to avoid the need to write much, if any, Rust code to successfully deploy a NockApp. To that end, we provide templates which by and large only expect the developer to write in Hoon or another language which targets the Nock ISA. + +A project is specified by its manifest file, which includes details like the project name and the template to use. Many projects will prefer the `basic` template, but other options are available in `/templates`. + +#### Basic Templates + +*Basic templates demonstrate simple NockApps without Nockchain interaction.* + +- `basic`: simplest NockApp template. +- `grpc`: gRPC listener and broadcaster. +- `http-static`: static HTTP file server. +- `http-server`: stateful HTTP server. +- `repl`: read-eval-print loop. + +#### Nockchain Templates + +*Nockchain templates demonstrate NockApps which interact with a Nockchain instance. They use the `nockchain-wallet` crate as a library. We recommend using a [fakenet](https://docs.nockchain.org/nockapp/what-is-nockapp/development-and-testing) to avoid needing to spend $NOCK on the livenet during development. At the current time, this only means running a local fakenet node since wallet credentials are compatible with the livenet format, granting other caveats.* + + + +#### Manifests + +A project manifest is a file containing sufficient information to produce a basic NockApp from a template with specified imports. + +```toml +[project] +name = "Et In Arcadia Ego" +project_name = "arcadia" +version = "1.0.0" +description = "I too was in Arcadia." +author_name = "Nicolas Poussin" +author_email = "nicolas@poussin.edu" +github_username = "arcadia" +license = "MIT" +keywords = ["nockapp", "nockchain", "hoon"] +nockapp_commit_hash = "336f744b6b83448ec2b86473a3dec29b15858999" +template = "basic" +``` + +Manifests let you set several project parameters and specify the template to use. This information will also be used to populate a README file. (By default we supply the [MIT License](https://opensource.org/licenses/MIT) and we specify the version as [0.1.0](https://0ver.org/).) + +#### Multiple Targets + +A NockApp project can produce more than one binary target. This is scenario is demonstrated by the `grpc` template. + +The default expectation for a single-binary project is to supply the following two files: + +1. `src/main.rs` - the main Rust driver. +2. `hoon/app/app.hoon` - the Hoon kernel. + +However, if you want to produce multiple binaries and kernels, you should supply the programs in this pattern: + +1. `src/main1.rs` - the first Rust driver. (This may have any name.) +2. `src/main2.rs` - the second Rust driver. (This may have any name.) +3. `hoon/app/main1.hoon` - the first Hoon kernel. (This should have the same name as the Rust driver `main1.rs`.) +4. `hoon/app/main2.hoon` - the second Hoon kernel. (This should have the same name as the Rust driver `main2.rs`.) + +In the `Cargo.toml` file, include both targets explicitly: + +```toml +[[bin]] +name = "main1" +path = "src/bin/main1.rs" + +[[bin]] +name = "main2" +path = "src/bin/main2.rs" +``` + +Nockup is opinionated here, and will match `hoon/app/main1.hoon`, etc., as kernels; that is, + +```sh +nockup build myproject +``` + +will produce both `target/release/main1` and `target/release/main2`. + +Projects which produce more than one binary cannot be used directly with `nockup run` since more than one process must be started. This should be kept in mind when using templates which produce more than one binary (like `grpc`). + +#### Nockchain Interactions + +A Nockchain must be running locally in order to obtain chain state data. + +For instance, with a NockApp based on the template `chain`, you need to connect to a running NockApp instance at port 5555: + +``` +nockup run chain -- --nockchain-socket=5555 get-heaviest-block +# - or - +./chain/target/release/chain --nockchain-socket=5555 get-heaviest-block +``` + +### Libraries + +A project manifest may optionally include a `[libraries]` section. Conventionally, Hoon libraries have been manually supplied within a desk or repository by manually copying them in. While this solves the linked library problem by using shared nouns ([~rovnys-ricfer & ~wicdev-wisryt 2024](https://urbitsystems.tech/article/v01-i01/a-solution-to-static-vs-dynamic-linking)), no universal versioning system exists and cross-repository dependencies are difficult to automate. + +To that end, Nockup supports three patterns for importing libraries: + +1. Single file imports. +2. Repository imports, simple structure. +3. Nested repository imports. + +Examples of each are provided in [`example-manifest-with-libraries.toml`](https://github.com/sigilante/nockup/blob/master/manifests/example-manifest-with-libraries.toml). + +#### Single Libraries + +A single file may be plucked out of context from a public repo for inclusion. + +- [`urbit/urbit`: `bits.hoon`](https://github.com/urbit/urbit/blob/develop/pkg/arvo/lib/bits.hoon) bitwise aliases for Hoon stdlib + +```toml +[libraries.bits] +url = "https://github.com/urbit/urbit" +branch = "develop" +file = "pkg/arvo/lib/bits.hoon" +``` + +This supplies `bits.hoon` at `/hoon/lib/bits.hoon`. (The developer is responsible for managing dependencies such as `/sur` structure files.) + +#### Top-Level Libraries + +A simple Hoon library repo should supply a `/desk`, `/hoon`, or `/src` directory at the top level. (While Rust typically reserves `/src` for `.rs` files, Hoon repositories are not generally configured to expect a Rust runtime and may use the `/src` directory for Hoon source files.) The `/app`, `/lib` and `/sur` contents are copied directly into `/hoon`. + +Sequent is a good example of the simplest possible structure: + +- [`jackfoxy/sequent`](https://github.com/jackfoxy/sequent) list functions + +This is imported via the `configuration.toml` manifest: + +```toml +[libraries.sequent] +url = "https://github.com/jackfoxy/sequent" +commit = "0f6e6777482447d4464948896b763c080dc9e559" +``` + +which supplies `/desk/lib/seq.hoon` at `/hoon/lib/seq.hoon` and ignores `/mar` and `/tests` (which are both Urbit-specific affordances). + +Other Hoon libraries of note include: + +- [`lynko/re.hoon`](https://github.com/lynko/re.hoon) +- [`mikolajpp/bytestream`](https://github.com/mikolajpp/bytestream) + +#### Nested Libraries + +A more complex structure features top-level nesting before the Hoon source library (`/desk`, `/hoon`, or `/src`), such as with the Urbit numerical computing suite. + +- [`urbit/numerics`](https://github.com/urbit/numerics) + +```toml +[libraries.math] +url = "https://github.com/urbit/numerics" +branch = "main" +directory = "libmath" +commit = "7c11c48ab3f21135caa5a4e8744a9c3f828f2607" + +[libraries.lagoon] +url = "https://github.com/urbit/numerics" +branch = "main" +directory = "lagoon" +commit = "7c11c48ab3f21135caa5a4e8744a9c3f828f2607" +``` + +which supplies these files (among others) in the following pattern: + +* `/libmath/desk/lib/math.hoon` at `/hoon/lib/math.hoon`. +* `/lagoon/desk/lib/lagoon.hoon` at `/hoon/lib/lagoon.hoon`. +* `/lagoon/desk/sur/lagoon.hoon` at `/hoon/sur/lagoon.hoon`. + +These are simply copied over from the source directory in the repository, so care should be taken to ensure that files with the same name do not conflict (such as `types.hoon`). + +### Channels + +Nockup can use the `stable` build of `hoon` and `hoonc`. (As of this release, there is not yet a `nightly` build, but we demonstrate its support here.) + +```sh +$ nockup channel show +Default channel: "stable" +Architecture: "aarch64" + +$ nockup channel set nightly +Set default channel to 'nightly'. + +$ nockup channel show +Default channel: "nightly" +Architecture: "aarch64" +``` + +## Uninstallation + +To uninstall Nockup delete the binary and remove the installation cache: + +```sh +$ rm -rf ~/.nockup +``` + +## Command Reference + +Nockup supports the following `nockup` commands. + +### Operations + +- `nockup install`: Initialize Nockup cache and download binaries and templates. +- `nockup update`: Check for updates to binaries and templates. +- `nockup help`: Print this message or the help of the given subcommand(s). + +### Project + +- `nockup init`: Initialize a new NockApp project from a `.toml` config file. +- `nockup build`: Build a NockApp project using Cargo. +- `nockup run`: Run a NockApp project. + +### channel + +- `nockup channel show`: Show currently active channel. +- `nockup channel set`: Set the active channel, from `stable` and `nightly`. (Most users will prefer `stable`.) + +## Security + +*Nockup is entirely experimental and many parts are unaudited. We make no representations or guarantees as to the behavior of this software.* + +Nockup uses HTTPS for binary downloads (overriding HTTP in the channel manifests). The commands `nockup install` and `nockup update` have the following security measures in place: + +1. Check the Blake3 and SHA-1 checksums of the downloaded binaries against the expected index. + + You can do this manually by running: + + ```sh + b3sum nockup + sha1sum --check + ``` + + and compare the answers to the expected values from the appropriate toolchain file in `~/.nockup/toolchain`. + +2. Check that the binaries are appropriately signed. Binaries are signed using the [`zorp-gpg-key`](./zorp-gpg-key.pub) for Linux. (Apple binaries are not currently signed.) + + You can do this manually by running: + + ```sh + gpg --verify nockup.asc nockup + ``` + + using the `asc` signature listed in the appropriate toolchain file in `~/.nockup/toolchain`. + +Code building is a general-purpose computing process, like `eval`. You should not do it on the same machine on which you store your wallet private keys [0] [1]. + +- [0]: https://semgrep.dev/blog/2025/security-alert-nx-compromised-to-steal-wallets-and-credentials/ +- [1]: https://jdstaerk.substack.com/p/we-just-found-malicious-code-in-the + +## Roadmap + +### Release Roadmap + +* [ ] Replit instance (needs better memory swap management) +* [ ] add Apple code signing support +* [x] update manifest files (and install/update strings) to `nockchain/nockchain` +* [ ] unify batch/continuous kernels via `exit` event: `[%exit code=@]` + +### Later + +* `nockup test` to run unit tests +* expand repertoire of templates + * list and ship appropriate Hoon libraries +* `nockup publish`/`nockup clone` (awaiting PKI/namespace) + +## Contributor's Guide + +### Release Checklist + +Each time [Nockchain](https://github.com/nockchain/nockchain) or Nockup updates: + +- [x] Update checksums and code signatures (automatic). +- [x] Update versions and commit hashes in toolchain channels (automatic). +- [x] Update versions and commit hashes in install scripts (automatic). +- [ ] Check and update downstream clients like Replit if necessary (manual per instance, but `nockup update` works). + +### Unit Testing + +Some CLI unit tests have been implemented and are accessible via `cargo test`. These can, of course, always be improved. + +### Replit Instance + +There is a Replit instance available at https://replit.com/@neal50/NockApp?v=1 which demonstrates Nockup functionality in the cloud. Nockup should automatically track the latest release of `hoonc` and `hoon` on each `nockup install` or `nockup update`. + +Pending a public fakenet, it's necessary to run a local fakenet Nockchain node in the Replit container to test Nockchain interactions. There are also difficulties due to Replit's memory limitations with using the Rust `nightly` toolchain. diff --git a/crates/nockup/build.rs b/crates/nockup/build.rs new file mode 100644 index 000000000..5d041a4d0 --- /dev/null +++ b/crates/nockup/build.rs @@ -0,0 +1,67 @@ +use std::env; +use std::process::Command; + +use chrono::{DateTime, Utc}; + +fn main() { + // Get build timestamp + let now: DateTime = Utc::now(); + let build_timestamp = now.format("%Y.%-m.%-d..%-H.%-M.%-S").to_string(); + println!("cargo:rustc-env=BUILD_TIMESTAMP={}", build_timestamp); + + // Get git commit hash (short) + let git_hash = get_git_hash().unwrap_or_else(|| "unknown".to_string()); + println!("cargo:rustc-env=GIT_HASH={}", git_hash); + + // Get build user (from environment) + let build_user = env::var("USER") + .or_else(|_| env::var("USERNAME")) + .unwrap_or_else(|_| "unknown".to_string()); + println!("cargo:rustc-env=BUILD_USER={}", build_user); + + // Get build host + let build_host = get_hostname().unwrap_or_else(|| "unknown".to_string()); + println!("cargo:rustc-env=BUILD_HOST={}", build_host); + + // Create version string like your example: "0.1.0" + let version = env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.1.0".to_string()); + let full_version = format!("{}", version); + println!("cargo:rustc-env=FULL_VERSION={}", full_version); + + // Tell cargo to re-run if git state changes + println!("cargo:rerun-if-changed=.git/HEAD"); + println!("cargo:rerun-if-changed=.git/refs"); +} + +fn get_git_hash() -> Option { + let output = Command::new("git") + .args(&["rev-parse", "--short", "HEAD"]) + .output() + .ok()?; + + if output.status.success() { + Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + None + } +} + +fn get_hostname() -> Option { + // Try different methods to get hostname + Command::new("hostname") + .output() + .ok() + .and_then(|output| { + if output.status.success() { + Some(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + None + } + }) + .or_else(|| { + // Fallback to environment variables + env::var("HOSTNAME") + .or_else(|_| env::var("COMPUTERNAME")) + .ok() + }) +} diff --git a/crates/nockup/default-config-aarch64-apple-darwin.toml b/crates/nockup/default-config-aarch64-apple-darwin.toml new file mode 100644 index 000000000..0254203bb --- /dev/null +++ b/crates/nockup/default-config-aarch64-apple-darwin.toml @@ -0,0 +1,2 @@ +channel = "stable" +architecture = "aarch64-apple-darwin" diff --git a/crates/nockup/default-config-x86_64-unknown-linux-gnu.toml b/crates/nockup/default-config-x86_64-unknown-linux-gnu.toml new file mode 100644 index 000000000..31b8c63eb --- /dev/null +++ b/crates/nockup/default-config-x86_64-unknown-linux-gnu.toml @@ -0,0 +1,2 @@ +channel = "stable" +architecture = "x86_64-unknown-linux-gnu" diff --git a/crates/nockup/img/hero.jpg b/crates/nockup/img/hero.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f3dd6a13a63953a5b32dd789b494ba8658b3e8f5 GIT binary patch literal 902052 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

H|n-_KlCTu;%@)FESLm@xZ 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

?%(2WB3C=TFvFq2R7`m+49$!M$gl}xcN}bv5 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%0Ag0BRXA= zwET53Y;h*SrS!_DLEo)v(PJo>4arQaA!$T$#oX=MHdW8UkVoV9KetwIvqa!(V&}Ii zj7(QddoC!Atdz4yD1bC|leuAy6+m9&;h1>$2a%#b9(oM2I~^&F`q3bQGz7>=ZhO*g z8WLHaXSUX3r&O~#NbJ7+@*pb$6$i$S$sRh3c$}QbeE{#?iE}b406rtP%AAcbK9-?dua+UD(+2vME0fq+Q- zs#whW1?o>3#mAS=@oUv^)@XjJjm$*QUj1#UBloLO0v27_j5g#zJCK4tM_G3KZ--pU z78x@5#*vfGx(+Gf4nM)-7dNm4iNGNC2iu-%0<%ut?A2jfx9M2TQnZjr&m8M4QV68? zQeworp6>+dHRD9}qOg`ZPy*-jtTvhzh_0>FZ}X=GxL!rK4Ibo@wbp+DL)Lg>c;u{v zkQ7<={{Y8u{rW4R#*widy4HG1308dRZMXest}+y!TFodv!^I`}9hJ4&P9|v=K!-ux zJoxf@fOL&q$f=x|z8{2~QZ)!K@(hKWanyT8b1b1^A~7en3P%)XMo?ZjhO`fz^dS@6 zG4kOAbk)C3@RME&gezNUotpJMmitJne?Wdh$loFd{ ze=|tUa|C2OXHa8<*i(Xy805Vjc_L8-Z`S4I9!K*mI599Ie{^VpPQN6LblOH9O%4Z= z7oT(VrDdDT)ij3%h5#MA)3Ut_P^L1yO7S*NAzs{poD=#HVo~l`_Z)5(5Dzc-=%|eh zSWqxeUV@TWMkE&^JaRovDAC4}>x|>-&u6?xV8Ihwwfm;)hZT!5?Z(`rAF;e3}JdkcrP#&WeK&c!$I*0BjmvXi8m zmJZTMK^l{=+B)(^x(m#S@%nehbM-a0ya$Xl(cAr&ZBq=WD8o7JwIK33IV^PXVXo4f z9djjaof#?38$&8ZFq>vLT))|&Vz92nkNBiZ zxLcXak!104*2Cp!pMUk($lf~hzSZNcXUK}n2>|bHe&F;eY~{FM@BoDK_oGL19}gZ? z%!Wlgf!vPYg*7o*3zaexL25%vuO1eloCXsl7TV31kahvvHs_yFj|BFO^sT&)=awT8 z#5A9BDa(k_qSOXm`Fy|48eE0jc4{R`p7EN*VN%n{Ey?3A-Q1BYs=~4YJPme_8|ubp zdDKU^Ca5EV1s-X_Y3tK!nz8(DZ_<9NDepzx14j}{D?tjrf~2i4YB5gJzdP%sx3l|$ zCBaZT8ssjxiZrbim?UR%D_i^r3zzpyPRlzG8qHe}Jc{HG#b=Xc5B_1H`PV{|h1y&w z5gZ?EbEaV8*Y>17mS`{r2so(Qo8hits@#tKeJ#qhB^hYY>fOvtOtimL-hbwr7AxKP zBlqhMh(Ok;%PNtm?A`Y3T^9BGg1c@`aH8I;uJf=n-p7iw2)v1%J)~P^eq{!Csl7wSAMDdqu zmPzi@qIvWA{{20~q8CVHi*N_w?^)bFU$V;4%aFvKzr`{5jyf!@hB*luEN%fyN!(f9 zLnOtasT6&u++}mNU!8w$vh1MqpUjZsN9$Ub^B5x^X9HV`n&i&FfJY7{?nvk;k)^E_ zb=cHUM5bNDLjF5JA3bce`h6vJf!n2G3pna60}~Yxnan9%!=CL;z8a zwDN}|$866XOk8m=#?_jwtMjOiy?J2MPZBhr%k4l(?)`^Otd}E!WLi>;-qsb<0W>zoeDk`mqKGmP<*UwE$aTIux?lnZ@#C*N0?>s;Hr{Sl8 zRtd8>$EHoU`I>8bx5%kk$47?E;FgwU0o zQk!c=1sG==@6)YwpVaRY@D4BGxn15r^#B1tq#S+~Uun_F*T&-PT8-zD%+@oKyF{?8 zfrtF5ia+T@bUW6%Hl4sgHGn@Zb@?UbFE83ugXsW|=UP}f812bc!?Cqith!1{Ro?bk zFS{LWvjCMLjf|2zeWOF7mRI&Uq8Me0FwrLNbM!Q7E7=}r5}1+jM(2LC;^X|4IHrFYoz6lR4V9=c8TzWtB3H-U zf8|*yyY~%wj?d>?@H)=Fj!2YE6J>y}r~d#;_@rDfj!A2Cy`|)4HqR{dHEyWGS1FIX zguNVBYE{KkYT!+ zjF@4Kx*A8x(l3##G*5BtS@&#Pl~hhC)rVb#$UHL$zR*4Z-&dMI!E=z^f6Y-yNm@~z z2cYhH(G=vk<(u@eHKdNiJW<%X5lm|WGnr=q?F8f>2ggzN=$L6vVDqSMA96{2fTwT) z{b^>s+If2LE5y~sh4-Tk5s$S%0c0B;(S`%g#{U5A(T0h@)w#tg#jWHV$=f-ib7LWv zMvWstf;BD}Cv3z5e)a(DY=iOm>XStp3;~iqDqzgSiBRp!6kaEaIGmd>6Mpp3uaCVd zMynVBlno;l@#9+PNF!xBb{_Oe{OM&)hMZ=F5ls~|NK~1jl2cv&x}%rV)-Xu zw?=i$cqi%BgCi%Kf$^FRF($C>R*c#-*{in8k!#%USsjS(1rGlJZ#@ZPby8OwQDvEw z10XoqZCHwH6-4pGAVwfPi*gOmHJ@}O@7jE*Sofo?b>PZLa4Nq*F5!!U6o zuWJ?3liWbon{B=S0FI<*IL1?*QXjoKT>A6;`q3ZKME=||D`^TzKWA#WhyV%Jw9Lcg zo$Q{i_butuzqMaciOg=yMmFU{BgEH+X{4E~Dzvgm3l|9-ij0p1c?yD6SRJ45&`M#o zAtid|qs(%;olcC>?n@O>BBN%GK`dn^jbyWjg1r9#@&Vc*1Etx2mu(yqzR{pT=pt5; z$Z@7IwsTtd+%C}~isIU3GBSgwm(q|d!z7W)viHR@G^S8M`x!@Kh67&>@xLSdbZs7D z;2fYhAB82UgA%F+lY>pjqHlvg)3b8~MBFc^KI-N7?{{S&R zw!u9RY9~?19`w5?Z24HnJoT+6v|{WgNf=nM7?l_n?54g-ro)9YNB}?Msb(?h4Zco0 zQKF7ArHhh4J67MsB!Xs?#wV`?Z5)F`wAhum8k7G3P>n{2>UZS%A3jvLMJNhwAvnic zTA`B?69Y#fAEcJ5!6^PrRp)l@3$Wd*-o=3$>Qta4Cg&Adk+u?+z~xEJim3~`*N{XW z2d*5)9EK=}Qr~a{7H{*{lt-OfcQj8jXuv_dc%fpgTB4#r7|i8jj!8Vo%lTr=2ngS| zj)|oJ)Pog6>II}5bQDb3T5!yegZhZ9j`uy=+ZIrX+px+Mmj3`f0$E7L4(Io&hygBK z5V)nq%NVTws!3t8bp$3!J4-LLps#kpEEJEQ2WS0>VkRie!Lr?|lUo@jI&vdn(wvz4 zCpTncVg>A9nmU!Bjg=*u#kmzCh}qco)4N2MAOJP^-&LCW-UtoN*eeo@@+a~Ydu|sk z%!y@c#K=g+7v$Iv;aNn(a!L(XAxbFf-9bEZMJ&>jByu&HvP>Bw{{Zeu0BiQ>R`+(C zPSjpm5K!{wGrzq}<)!Zhx@!xdHEo^qjQqBt@%8bTYm_aT^{%XQqcwJgCW2;!st8-& z8kr<-N%G!%vd6E;1U^Ke(2o zu#W0tpy8@Uk=a&=oe!U$mQEpSHNdryYu~PGZ1Ah=doQ*#qNb8@tF=4vyn{K2uN*a= zYY^AHB(jN_Qq0oWgnRQ^?`3lY@8?glf6n^QVFC}nDi@Fd{?*R;4I`Zz^5;3__)^j5 z{Hh$iD_Iz=+J>zo6nO0c15_p22904rhB&wL@Op^AL8P$IcIQ-kOGMPMsTW;I=1FZaed* zt~b=|JS$Qg7R$_IN$gk<6o=fIzW9i5v^oQ={ysVzN~-x`oOB!hG#9hDW-M{#as<1NlcL)#~>&?6R(dwOTVNSUAgDC^Zu%hXEK8#Fw~=- zdDEVx*xAjAD_W~v?8|1lOoBNA%0Y{@QZQALHt?W)9rbTZfsEh|dFlTEG{4??z|2WF z6m$91);e}tv&UXIz1JO`(+K|7l&*^&@_eY!AZx0IicO-T#Mt9da=OJp`~r4EYZ)DL}ql~K8iU|mguwLQ4e^$SKmP>1u-K5GM zTUO2Ai7X|xh(l}^Q-Ij*P%cM~aw?zEQo_7Z57nc_%=4Kf)PB=;=rWLYH{+tUmB8O% zIn@^C$-!cO#V1csIbGGP!C)A{Nn>!$uhSMKNYj->0FOVOtAmeMPPJ`O4p`D?h|5YP zGgoI4LhE8Sf0h~wJ-7a)?B9R~K-SFqbASdp^E6@?Pp6R^g)H?T`xgq5l5>0O%f#4as0*at1kk>Mm`folH_G^B8mGgY8y(g?fp_`JP%#e<;Go zb~|*Xl-Qocu}te+ccOQa1fin?Upqg)L|Nu27#+DD)ta_XV+dVsjO;1kJt*OM%MsP% z8OgAAB)ufjRJ6YJb(X|JG>lAG0IYTW#<$Z;b^y~V`g@8*UCfgoHFAB1^w72GAE{hg z`Id#qF2_xrSdx{1p^{GIc-=`W_W}X^_teL66YZUO4XOA*fT z@y%*{PErVK$`;SH36V;;+(bnVm>;v{c6@YGbE6>`V-;YQa=PKhK|THw2Es46 zfDYKhGMzSn2l92(EqgSqLXf|;WDXH&3$uuWMt!KYo^)rKV9!#9X_caAFGA8oW*2oa z2Vc|ZNWZ?p-|f^3=Sh62No8S;wx%PoS5N$MQymW~REFhT?n*SRYK}2ug4@cI#+B(Y zjY+QF`h#KSl>qQh^&X7BWl&_n(hhp^sGMF4e9Sz?)BpoKsD)lnki*Q8+mPAESSuVW zW421n7-;CY+JbaJJJ!7ZdRAFrX^JONk?o$8%h|-rpbZk~>9_FGzx+#0q?rDn8~VjA z(8*YAqPnOG%mIy+&VbnQuRR*hVjojy%j-az-%LXcOM{$O*xqdg3Wgh%YzX9?qFZoS zC?TFkEB#|*N)GxwtqkYPMmPr;sy)2Y6)HM)qj7WLiZWKq!7f6oOeI^2$vlQB{{W?B z?d~jNM%nn;@OleeA~8(*K&w57d^AT)pRGEr<#5J=`Dd6ilbhx!{ndGMTYYPte71^=zm&l+J%rPEKj~MLt!}twnWwO_NJaESzV&7VhWPh_9Ip{ znp<)t1rdP(0VT))56G)JN&x|NX9pBlvnh>a4f>spJ22c|oBE8fe*(Zwn}KAK3Yp4P zPkpF+fNyZCdkyWjyNMwDk-oL;crO&Y2grcv>%Iq0^@GItzXRa%f8|{yQa4QX{pv!) z{VMvyn6VrcW5D8U*^}Eevf27{FR=tHLbXot^LOlkR1?;O{uI1~4>6J^VUf5e^{l4x z{{RAxGa_rbi8+;0arNa+_WEnRRhk}E>tCjLoQ+E@kG`eyH!?W;5u^6R@K%jlVYTgM z_y^~#NmAm(gt(PV3fMaglkJX`p>M}+xSFl!53_QQjB^JkzC|PPk4t!j&|Gd)%rmy; zuV(!!v2px8RIPAF6pN0(6|3^Bk=K&fYdmFpiWBGa(sJ=sxAesbk$L2h4=t&ym@j3R zV8C<;N_As+&kVrga+sg^Q0H!4lBJrQi;&MHTiBWEFlQBM)F!Q{CLh$TzuTnG z5fjcP^3@{);m`b4C$PA5(k_awFmblRj$P%}rK;5+{uzp>9xr@iu@$LE?-y`a77{aK z{{RdCPRH-_v(qziSsD*2A?2QV29%G3@c88umoDS3e_Bet$OB7S8SP%RJ<%NXsl2xA z3EDPQ_g$oaZUj4aant$As2=872~`*ziLGn$K7E|G zElxLshA5d!t(g^80aMxq3)zsz2x5z{pm!aejri!1%Vz{+yB|w(8|Udqo(Uj;J;N~t zv-r}Ian5sRzTRJxE+^WK5ezizNM~sso7)kxD#x|cA3M`r=j`BV11 zFNZ9DX_>u6IcGF=Fc_AtEH)@ev!r4{F2j@i&a~T(Ch{Ex-J*l^65jk4>$fs zFcg~2+?Fs?TVpiTZ2tfe{WCyomW<-Xb)WT*ZDiDCrE-A^lcnDEW20e(+>q*jHrB+r;?{YX@C3vTTA!4TEi@SGh zDP*q$xdZM$Z};hIo1}mcjk{AeoH?$ec_KMLPC@Na?T&9^I|jA}m1w3??FLXew0`K+ zvl!0fG5d^l+zC4Ux&+a^)M7cvFqPc$=S;yT*s(3B3hH+Uy*9Jlp2X&B9E&Ytepbwa z8p}#J;F_?H+g4)?aV$NnzW@!B)h$$6u(d(=N0PFjdw@&nh40%gT z9q1g0frW!g`I-o9j?$#71}p*%=&tf`E31ObZVri_~sw6=m-gsU`?XqjTH6a*gw^XIE(<-YmUW0$1h4E4n;Gg%jPd9fK^ z?mW-6DOKY6X`yDfV8tWeYca_WxUON=gajc^u#!nXC-)r{VYtw8a0oS?_f|e)slj3S z(&i_O+N(lZEK<~F<6de`(zFKIP>AtGDP;Fb`0`2D}jeAbo0 z{{YnOT3*Ts-fHnzGQ(foiG7G%#!q?n-agVh_ua1p_x}A(gpd-)WBF7nssg~NaC5o* zsIiL0^lDP$r3+<8eaX9rx9sXe*qQ~4Hi7>D@pV;&QT1dHDTbd+Ya-!f1D8r8CkwYN zTad+;u~qNJkvwsUJ)s&(j_6RG`Rycm{O|G9ym1BDMlGd)*{=3eP+NTr=gAh|8ut?xi@sgBmpR2riN$6ABLc#L4jYRr-6kQ-Be z8yIRRV8?`^0!b{U9`eubNaQ0H4W*CIjs2h<^w5(+kh#a>#?sJ-}c{2hHT^Cr9Gf9CL@K<^OZR8UY~L&lEy)8 zEGliM=`r#%e?EljtlDMmD0^6s&yac_ktsf-k|-A(O4$dLMyCS=`_u0S>GM~&l|xoF zqb-kOtvSO?Q8b3dl(Na*s^tB;KW@Hy8%HxR)qKa=OJH`1}>fr`g+^GpakmgWr`t#Xv9URkF&jCA)JtSfpX zGQ^G-y22!9`j2kZZ{y>oWVm&VylNTr>^W0L5H19AIr51a>qex0H+D-9V{-PUwG@q6 zg!G;jcai2%VmBv8e$YBy{GZ2JcHB#fOD>Cu2#Y=c0OGc|mkHpBY_nS2{r>=A`cid% zFUKoOUgmnFS=%+N6hQOM2r;}eAd9p*5`f5D>%r~*-DpiD?xb6Qxm**0S=>EsW01As z^HUs+IL=>&;%LQ*vkO+r+U^yt{;p{)PXI{P9CUF=%>vh|Rh$s2+9VUwkZ}3W-#Z2X z_WuBydi#mnX&+^_3mDsBO`O+0;v8oqUZc*_WFtjasS_D24$xS;cL5oNVvsOUOK<1% z({7gfw=KW(6j@SKoTxw&6w~Wil~|m*MGAPH)v7R3R{BElkncX(vl@ zYDHpuqKOZC2)q|Rv+G*%@#A+YriMRvpYl1;mxtLp?3rM5xTyL&*&E0*6EL$OwQ7u? zdkYcCRHHM^rczAm{x`kr^U_9F1Tf}*3UWB2TtcG=MtAQ|>}q0UhB`GWWOEWtPK=XB zkOH+clYo2HQlPHD5m-NDavhdo+SPbWor6-2uxzNBUY;I|99e~YSdO0j@ zW}}K+e6mL#K}4=C!!(i04VZ`??_`VIjCZdepFLkp!0S=;rP-~OO3K0JBN-$bdBuXy zUi~uA$|#`?aatCVx9Q#pE~mGWYpSugyC9jFke|Q+>*N;_jOreP`BLj~Zh$7KXX=BzoZ>$8X8#w@5wkerZhcf$H z5w~GG{15we)GQ~<0N_ejC)nb-1!3pq@M2Ox`DdLV$;t8U`ibgNr&hEXC&fkGfBs@= zUMH}&1Wp5c9lir^LtQV!?q(Bt6Jd$udr_~fqGS_Wk~6sPPQQLzsp|vpzga3nkVzP4ykK zB7ngl2kr!cy`GgxbQ7!99UbkokWMnxQqE?kMP+ifMOoG=Y$3Dk*zZ>lBFEYaqsgxy zust@18eKEE{{R&!8^tCGB`F5Op63;$En-;gv`|=ZSt5&POy~Nfjxc{7e;rAkj#TfQ z??(}blQ9faV&+oB<`k06tMiznuMD;(GRIXEGb|9H*jUt$1M$9-jHxdtda$Yu-W zos@E>PF8VN+?Fg^drSWS>XD3#1d;;2&>QyGT0O&G_UUN{lQw1Df1IhPvzMuy5 zS#t14A%eP@=b5B;Nj&ML*R=izgwS;TJn5#==EyI<(d?ZBVdbyv_~m&TGnL~S7@uK)p$>9tbnLTkw^rd=Lh#2 z9S(UN7^(P85nzpn-5fO|!weR}PLRz4%{;9Vvh6}FfU_1L_V+sL&w>w2d1}n1Mt9s( z1)4Avw;@kCu@FcjlDsB6()O8=54RmnqLAsL0h#+x{rb~rumMqp9@U>E%%dX;MtK@7 zVmw_7f2g#=SKJNkAq60u0?#DR+p_)Soevv7k<PbKTPK9vU7(9rp+9<&vI!Q6isRV{(l20Xj+iZcmP>uLL4$se?s)?mTjBQr) zSzAU4Q%X3hmZrBSvl*6kDGX65SyZI|0Ld!5`&1S{c6NLdpgi^CD#opGF37v>?N;*4 zuCT@a={r-)DTtd9aWT1ClVb99w?o`KJYg93JF*IexWDJe(pW@bN zHC(-}(u&|TpaM1d@|P8~gNPu4G8E(0obumcitKzd;*0MND#;2Yh!0Q+&(4`#$Jahz z%d+>g*$kcgcokn_j~9w-R4|x`J+O$l%yd)D9e0LF9EnckAo(YytoTi(iiU)J;+}NB zAMs}z@eR>y>6k;&dQ?!BZ{CLeiDmA;R=BZ4?G0d6ia7*w$tm+Iclp^nJM-4im7NLB zqo4S$Hb`Sh1bgq2PrW=IsFOXF$#(YB$Yb$VQ$#AQyK%>AG86LRP~@N6WVY$%qxzq#8gk)|U=_~f1E4^2HY{{W6A&*idN38-T6iCYaUG*t#d>ttb`&#_&k4;&Cj4RtR1Vtc!K z5WH7o8eyHf9-C*ieM9<9{Va$2h_^TQ*9{L1wm8ZRu|Hl^tiG4zcxO4`!ke~31(MHJ zh8&rHV(ua7#MgbP5)dPEJapT(KvaC}1Fo*6ZRHMxWrhg%uDbjBF?-?v02y$_vzX7p zrkG^78C-9_PE~4bOhAvhW17MVWUVaGtcE9bDAnO?pZ2+`p1@x*)O$zQJzvubF`QWmvItaf*7 zg5duEvWtiF%7S z6u@KTnzT4vL>8$m{^%LG7FV?cJGRy-BN02DofadarPG!xFZ+eLdwv!259*hMxGw_n ziMR0Vx~wCKayo-i#v;{>y2{dHR9u$Z(w@n9TD=@(EvAO9+&OT-?UZl+-7{|?lXhOe zm+;rm?qmBiU^@U#PAW*mt$8aGS_FozIgM3F-)S*Yz49tBR$xxN`2_Fr*2S!6Nj{(k z+n;LZ?toxqk%?wIdkSIUxU%K>-a@>!1em(qj`mwJT~sNGJY7ep19tCgC5f2%@v+uC zHmuj|xIR(KFg;CcPkOQN_~y=KD}hq(A7TAf5j~$w_pKdl1DsLSC-~k-eGBJ!99x# z@|TV01{$>E8ZRU?qdwemMh}LQZR4zao9Xz3g&3bN+uFBncvXi9i&Cav8~xHA)8%bul#kV9V7dA@otXD9(&&3EPQ2`pI&#G!glmYOFn7)=OQ|H8a_XWFl`mxZ zmHdV`C5y^dOr|D3%a`iq93bAgD=PdHE-!U2z%Zh{ZAoj~|641%y!C$21ZZ zc0D)xR4-o5(=At{T4^oPLey(R31va?9hU=Rx)qbhcY;S)(Ofw7!lxr@>1Bx{3>f2A zQ$aTjyZZwgQppD;XkC!L_gtNl$Sk1gtBu-kpxoXJsdB@o7DS#I9xr`lQ{V0)08d(x1h{C@e0`nzipaS#LXt~vGbBfm>L?bekfE0U z0Eh0`n*CUf}KcH-??+(z5aj0}6y z7FM#o$#>c;6Pn+S@mz$o>;C}66UT|;@Wz)W#=|prUG9uL@@1^|Y>Aj{qf!Trbz5r( zR1#h706T5D_BBi1BMU#-ZHB4auWWUvCHr|tqm0H^LxbZ9G>=-WXrb6dtN#Gk385W{ z2!7$%8~&XipN6J6059F!AHdS~;yBwa_m@L$`Hz()(7jDEi%%12GjiC9RLFrxix1;gZ5?I3sZQD^i&$Tlskt6#=<3I;(`O-C-RhKd_#x|>F35C?YWGv4b zvdV_B8ZoSLjc6Z%(z61Sd1G|^zdB(U7(UaIRMd%MkjHBuJ~t;EkCv%NBP5EF6^7hj zqn(z1)zI!&BYOEGXQm-BBYG7<1E2u=@~o?vr)lGvSeQ1*$;CN&A1ASa<9N%KRx1-6 zPB$4sq((iKG@?|D0K4~)O2p0_O3c+(&|G2aIt#aaD%qb&S~? zN$<@I%OHxo**)y09mWURLa=X=KO>?;ZxSeZGpn6mMx z`rO}&(m3SC=W(+cvan%k@(Q+cm`fr>ky>TBq0a{!p4HSJTe9Ak`t4^A&3>M#rS3mN9~LF3 z(ybOgLmUzQqNElGVzS+PJ+FZ}#R+31IXMC|EKlNWjdy89+ zHx=&>EUc(Aje$J6n&+Rb-k4@9;j)?hrw)?`md96(24DDk4ZoB%s=CH*S&fPN)3_2lk7)24!0TGx-HxE-X$P75ZC{h7luH0HuvtmL-|_m=Y}|5$ z?lN;!_vI#MYSF2VC9fQ8kVwdY0@9Jb-_E}s4p>5Zf-ax_JgJqMV4hfFR1S3Amm7Y( zH66{G_wC8;ewo~`S})k9%7s5S$RkSV?b)*0E!=$hBdrX90~W`bo>ZJ@8sLsm1DZ*P7j6X;TOd-0+Nyn& z?V4#(kg7-Ab_AYSu>P)*2t$WC0P^imJ;Z7>#xnl>sl@V^F7Xv#(nS*(!m^m7NepqP zaUG@GyKUp~(*+%g-wT}Pkx;6|&oXFqW{w4SeM>c023K`%#xr*)2mXMdZ{zd*`ej#4 zCqAJ;n^c67N{aZ>ielhR9G9(#zf$oh+YMA@eJpG=jWZ|&{(4hNvj7GF#@*$_!pvulmO~qANg0q8Vx;!=F$8v~Kzwv&MkfOSdsR~AH7v*C$1apy#X7NR)-`_O z1dt$AFsmsg!6W*Z{(g1wUAJPvf3=8 zP5$2>o{6W+8j}T2;wtFTOxl1tfXT?GUJn5I<+5SP;whTLxgf|$Vv))KURP73NJ6jy zx1#Nn|s}5+FDXa83m^qOh~vuTsP;>y5A#Y)3Ae^s&h3 zmFp5bL|<$VpB+_3-jWD4is0KnmmmztagcM%B8gw2UQ3flQr(H6l4&I}yAtj0D0YRw z?I*TG*8c!*i|If@?g{7fs*>!i6Pax9TmJwKMwRFG&$#`rOMs?0{D2jLx7rWLJ$uSf zLyVRKwLq3-Iv6gTQCTtY8H}?Ql*r1ifa*1LO>8L8J)r#g^VLFvcO7$HSx5yJy%=Ow z3}bcLp@5U3!DRFDNA(?bW=&ra1*${gNX~bxJ#ew5c>qYD0p37H+!gQ?=#>G6{0(&i zMqH@@v+c^Mr_+SW0yoCgdY>E2->o(3n0%d@b;FBSD^syzwPsQnEb}TD;Va$Tc1Mqn zgwk6k&gco~PUpTWO3Q}vJGdHq8)+qpP75NI{BS8UYDT&kJ49?**}z}Do!4eZSv1G6 zjSUm=G*OUP767cwPPI@D&cz-GbN#pHuQKRd zY&XW->(Q~0A2fmo>qY84dUaH=e`ZS(N<*+Rw{sELH}a$%_}01_$W#pOaYU0@VA%V- zQj0|x#z$yP3*4I`U|NkM{-z9gi6k>g9=igxqyT(;bw!!{?^bkv^j3;ChfqGcB|MS9 z2XrI{kcEsWlewHLF7z4M`+(Pjq0!_pDb+5@Xi*toD-g*P8drvOmM9}C%*i86>-{B2 ze&2@eA-^BnooHCs_Q9Yq=aExOY!iq>6ZozTfFKg%()`CiEshFxp% z=VzikW<)W{6r*&iB(p}sdwc>pQPm8(mc-3Gt?t3yYI!AjS-afr-Mfo-H;+A3Tg3{9 zp&Az*2HSF>%`}dF(Q&Ft3Eq!immaj;B|7*In)tD zns#DG`TBOvGr~o;+Lbt~AF_4pj`a8BJX?tog30h~{K*~VyFIKu@EX?|nky|Gcz;+1 z?Y+9m?jFzrY>$!WD}!-fC2$_wjKERjzzzMc@*jueT6}wiaJwgp>w;XFt)&~G7%S{) zn(=bF6%s1ZpruY*F0ly#lFZXeJ1oN6JNDVOc7Hu@DN~n?a+v+An=+%esQ^X?8|{%w z7|v_RD@{`OB!=Xk>~PkrF(0Rb7%D&X&Z+jy@3H+v1NhfK5lwEIlmZKVX!ApFeISlD z0I27hop4->2a+?FoE9&Zx1wh4z)>P^CN69e_{I?a_YHTz%lnXRlh$p4HD)8b?b~&BjB`dr%2qKbEL_z z*ud6o-j19NWEkZ85TeM6EP!&g{cly=L@-l%DBOkRjXY`wh z8uh1&y>)i7XHv-__8~#omDyB{#VG*n`1AhV4TZimo!oCsz2(aQkFdt(nAsmg>q$Jq zrGX3Ck~thCXR?srd1yd=rnGg?)nInXT(RexX?UE`V4xV~^QQIRM{?0Ug)v2XrC90K zjs+_eme<@~=>~$8*gtvwza1HEVVE|eshXGLNi=N7D=M6S)oq;NT-){4G7!_FLY_6N zQ>z6=?Ge_9h}bhn*(9uk?roirIuurkZn_kQ&u*PLQIm+;7}e!sUm4%MIO30<;Va{D z_11ZWSK8&f(K*{yS4U8R!9LPc9}CH+o8+=cecj6f$^U34ur^2E|P(iiclytdZfc5|!yL8rtV zvy%H*`-Y`kYbILUR_RpinWCVMMQI?Nm)n>=z6m66$4UL7(U?UJaz|Ph`fO~aq{=QR zYrLlAwOZVtjZnW{S!Ok26=HwV#o=8`NfC}I%wUG{PX7QJ9UI6bjU;FAp`=D7FKs*O z6x7P_uSahop06Ia$L$2$S+8%RWsIbDkWG8%M z^`{}YoK5A;r^(#ok)}r^^n;#bm`RMajpbC894z?TDqJ*VloCc;xVr)H2?OJ&Aw|24 z$q5?A_MUYT@f&eBl;}}&yAgqpokx?<aKrhLl3SgN}s2Gt5!pjx4?N-dg{s%{{V$d zA+ZbiiQ^-+XN>QCz=6N<(`mvuyXXG^9zZ`T%YP8~cg->Vsvk49dHlWUaeXfJS_-!< z+vE2sVx+NLOJdD<=dP03Z{$W^A9M8cKDNN93;8`U4~DqZipC@wM*DBQ3V#;x6p<~O zP%aqY1CjVqxP36>@zzQ0=CL(v+?3a@+nT|aJrH-<2<&BK*1rS|bXV}d74jD5RZy+Z z{Ly3aFA90hx)5elf$niruLt@<=Dd!y7b|AuK&X4;$K$O@BX>p}(jz0P_bhG?>_<)7 zcvH_@yqL(_0Mp5LB{eGWH;;uiayh8Qb!-Ly-m*DIG&fV$bkMz^& zF9kyQx5%hx9j()^m8(vLg3=o7vqxhWACS<9$Y&?DNdDbcUMJwb9-dviUSosL2S1%A z_CM7wFv`+m-XXsD{V9JX>!+mNpHW&qh4E?bTziVP?sA(=TMb&AU@4OukD^a(k|y>y zt&Mfg-uTCgUAd2kAdV66k+#FXzt5d&LH#}9*HV46#S*lant5-4eYw;>&b?8+>ULFm zb*w%I0hp^jbgy>`f&oi`$ZRCFP#M9qqdg10Au^Bz2Op z+%WX#NI&)XR`tAcsx`a|gdDcOtttA6CQh`65l8q;U?WA8%l`l=CV;y38nI}*w1{>| zIyxUcFkodFow5%>&W9{Z8Px1c_U}j;4^pdEv{CNd%w!pnViRsUVnA3j!wg$5<#+w{ z=cBEpk}gvV80vY_(Oau#Q>{?+G+usF$gHYbntHexWiE>>%#qBE+;q{zb=a=b0k_%F z1EDwB<2$o6eqBG!5n_chk;km_47Z*>Dk#Zdr{*=9aY z{zp-!Lb^c#Pd=4YwPkU0GK34rb)xfD<{24kOD)(jv?H{|qn^SiwMY^>)R07FTzsD! z>(QG_Z&akS^T49aT4z}ZF1>Th{^HBefR#wypDhP$DGO3G#^e&Tj?!(K;4SmM``qM3 zpH*7CHKeyZnJaa>VtE>Wezl8nw_s@KLZ}o4ZG!&*X#W6WIwlcLn1)-IxiqrcNpM55 zv~YCA1%>8va>=2FacZRq+U(6(#vn#o>QBETL`%CfXayt>9b3rqm(40Qk;wC@p58c9 zNs*Xj04sVthl#ywrtVh#xnjr3Jk=UEtj`ghqG&5gqFkwO>EEB9-=M$S2^a-q&OzF= z60&q#!(yA&m;7b`!Ar9yR0gN0#eaw7Olf zkC*(?>t*J)mpWCqCX%9xmQq_)<(g_SGTNT9WuapqxmiJ4Vk)g8M`qe=cM?~$`SI0c zxw=IdOyuo@laG384j*I@ftc;cZ@CnBU5=->UN~z*53}tE*R_g5qfE+*!Bv4@I{|gC zKOHtAmr0SwG01LD(z8}Ik@Wuna3`KXq#WKoa#ZZWHd7;ysdBt^rK?_FVymEIBD|qN zXD$?-?_b-htX9$yAsn{BJLlew=H6K`WEexX$3I$3#$&M+8ohiJ6WDtyOwA-+s**(j zX#k#2GJ+HXt!twjeQJErPz}a6+v!%Bp=g?QVrC;Gf5j@w*~u}}Q>7LzwJeRA_cF6j zjH_zhi817wa6^7*q_axAA&-1$${%_^0Ck;l!>y*dTZtY0AdGbR)Y$Qf?a^8|mr}SO zoE}`L{oIW5%Z*&+<*|0HC#8$21x1PDr4*j{3+)9mr+MDkpU(bALg1V>$AX!f(V2|R zmdP!S`yN#9iMaO?@hq^%cA-)swg4ZdK9ttU@_bDfg0*y>)Yg}_O5dcN8U}OPvN|kn z-i;y9Ctn_V*jFHzKka@V)sGFZk~WHU%Z;0XW9{e+>ykBVBLPlOrxh3hVDYjf{>XYv1gD(iIL& zY^grGzaf4+^sUgASd4HTM4$WpX^UyT#c;Bq8Zo|UMhtsin#J4pEL@h>q@G&@hcU*a zq>h8e!tdk9kB*sv5(tIAKr=~QUOm;zT$DM_CF}GQArz`RMr4J5O9IH(; z^U(p)_+f<~DjLQM8M1VaW8Rb2OwoBe<8vbxSf%#JPhwd$36K%8z&D?P`Rb%+sK$Sv ztsYqHn-8B>JN2ajjmdUR1hO+FVHJgvCoGJ}0WLkl0Dq7*`2ORi+@NiEz{V=C_i+Y{ zoo1PNni<^EN-Ng28_}L;sRI^KG~_Hoz-Ie(7-E0VrJa6@wFw0`YBw;pV?U1Q&AZZ4`Z3o|temb%-!3XEcu47hG z3Y?AkQU+g};xV$tT6w!+o&}wzE!+H}#HeAm`-tAR(B>mfS&d%aAt)vh>J)&S(-p{> z=+UUQma-WPlKr9-QW0Eu(=#ggJJ{-won!96)H~5-f=f~wbCAZ8?DEK|MDiNqNg=RO zv{5&4vJj1;0L$9jW25u`0Dg#NkN^*>rU&_>eUdh1Do9>*%uM$r$TiDNa!8vGE!U|^ zRAC*drvXtImOC|JZv)TIRb_HZ5)_HcyKiZA43$q-{3C5aXYEcz*!T-=ki|T2ey| z8gaF9>hejMf?mTCfZf4Lf_Jg!_UNl>#Nn7@=Ro^RO3Vv?ic+oV4M!GjPi9);#h8(! zG4~qsm;;7=v-cugE%+Nz{{VZfnuH|dpIp-CiAX@ubzuHq=9I5`Umi;pV;@$`w42?A zcdsHTjvF;&iYJmOSUiR~e&!?{2Jk-}UQ0ow1p_-&ClZo5{Moi%TXOo-uNCRO0}`F7 z5He5hNo{+(noAR?LVyR8+XzzdDqOH7R0|21@eiXge5tiainJB(8{&Hb~e6?0MgwpiIDE1B}p`(U^wcRtu}rM!E~k+k?o{k#ONV7bJ!QYDT-s#PTFW`%KV@10i)iib3(P5r|bA zJJJ6DZmg2Fff5iuo#@3_fU=XQd!OcMGWJ^Ks_bja6=Z;Wa*1W_5{b6jjaOdhU<2pI z&rvB7VU&|7>}t`d3zh_~ds9^a+dw40YFSZbB~*o3Ox7)TE5=b3Q3OPIGwb#thPUT^ zE>t6A41409iHI6ldym?jIj&JX+-46D*9+3c!6M_MDGUu7xk;sFR6*`LIvtWr5>G+4 z;nu3}a2T_bzW)HuwB`Q*6}*<+Y|3ejZOBt9<>f-|#gYf_XMZdi{(F?U1T%)$j+=Pm zL*>boQZ;6gh^sG_xiXFSrnLD9(rDYVNcH^MkVmv~5rGrRbQfhm?XG}J1L!#b{{TNK zX;CrvigjnND#}{F1gUlhN$oeg6ishtA96)?c{W&uR8!>t0B`ZtT*yGvoYA9+7C=G3 z#U{sK#*HDF2~x5msr#6bsR|(g6f$gre;Vj#SX?#-zgnz}uO=8AQ6Wie_k!;dMJK=5 z%7#e{zjxf;M`+jQJ$0|Qh7fzSCZWzl?DEs7)7H3he)zco(6bP5JZ@0W4fg!fWNa5h>#)*WLqn92fGSRJ6;id_K*$TA`E{k}hbi0m3YOR+U(RK~~E zn#4^Vm6kc8?-;mVSCIC0ta3PTgF|GO-^b&sp+{f5-GHUpWmB&ubFjznNOkW}irjL0 z1!84)SR<2B#LE5Atck65me+yudZbbyd~#ZFLh?tM4%8)_)v)rcaDrZF-?RoHgM*-? zD)>Ntf45Ti5}_&mwM2?FUs26B?4-_2Y*uRX6`ssxnPy2&fhxy;Xy2cmbgPLaKm2)9 z@%ce?fr4l~}Ek3nIp<20jQ^_uuuY$T#PZ8K4Y;bJ8598)RZhi3gXq5lAR$Hj_UlKzop|y-HRt!}%)5+?Rf%R_ zK{)L~(XaYA4EG?KOeFag#@(`pQS8BK0a;XbkQkNzq2qt;)z&GL;z5W{QA?R4nTnPo zR2c)lDea5mk}>EI*yOJ-dP;sG zuMqOV4y1<2#XqUyG4Xqq>@gOS8YL@+jQf=HL$^pFOMUpmN<@eojLvg0P3=3 zxRfZF2dCPCnmDobovKSD)v@KS#| zdr^vD(W4r(2_t%i+h#V7c_%?5F)UwR=YN8Ke!L?-P;@iyVcv1mK^1cCM=CH3+w}54wQrCmF`v{*|@f z5ue3cY<6E6dWHJ+WU%qi3xL%Upva}FReYdVS z^Zi9g=_u#W8hJOB&!i7=*X!+Am(V;(9i6wL5Za}4vg4UKu3dVIgTiM$Q02K!J&=U#R)j(M!clQRdoqVES>K)J8|wg1czH2^VX-bTDoR5#?Jw>;_Z@UGCy%{@IHeMT5dpn8K z7Lf-30Koa`E@Tnh%=~~s_}8s%4ehP5U6?The5$tc9F6Wrlc*e!tg9oCN4j2D_bpE- zRRGH=VnF%UhvTkt>O*xR;CevmU4^Vs*`bk%4UBtI-E3v(L8NDHVwW<%ttOQUwJhPQG!Uh^pb|)DeX6{& zN%E@4&bRsLxnp-Kstjq@`HBQD3l=jrq0nb)a%1@JR{a_2(5q@nvW2HUQduEIB2oPF z8I!cUiWlua0FNW*uTN=pEP5f`2buMPBa;3_aD+N+XXlP8m{ZI&pZqLP>^#x)!q2cw4$0Rr!dMu>}xt$rGh`SMf1l@Q$LYnrNrMwX$tSdL) zxb+^LsXik+TgJAw_$|nvdVAc>L6?eTr#ik50r%WiC)>m>UmH6+Jys~h0K7VUzuv1eO?2K|`N%&?XmU)f)-Bh`SAs~)R-`F2hAy<&sX;8D z6$UbP2mSg|_T+%48;+v1tnJFFhEg;UwH3L@?Afn|vd-q@3v(TAUd;*Mmug?ef%l+q z&--;ooU0^?GI<Q ziWwB_56M5bT6WS~67yUSLT4X5dsasgg6SZR8y`)~m{NNRT*Y#GQg`I8W2Ue&rb(^L z2#y?FGe{$-5g9|0JAl&u{V8#EJcK-BD954Y+v!Gv&NT^cWW?w)PyX2GY2}d1!-kf*128x=m6O=^oUB znU>CGz_F3A;|($P@fg~c-mFo@A_8(bDWmM1*_+8&k8-M={Pb-S7E?LZ4tWlLI;0MZ z7;7~0&1cOQeX=d6AuX&AdG zp9uhtxX%9ot#zCih+W4Y+g?H>CmirTLad*lKBn_tA40|zt%5af?S4!8?SNm`d545-|9uT2D}jQMJ;aJ+n%5Lt0y-h z#%8GGUcO%&CBaa3wsS8YF6^D)D#xs=M$<@ud?Ua22dzd+ISR;PLPjyC;s@MU)^9v_ z;l4k@<+QiIL}_Ew>uoy`jsg&L83`4uJaMYRH5YYqV|95Dvibb) zreTmy31Un|xpf}gtMZ2(;gRrb*(~E8YG*3raC6q1QOz`IB#f;DQ_UfV+$9OBglJ3| zyb<5_fEa6E+p4&D8vy!Skmv16MGf}0^NlWZ(W1Q zKORr`9Z?-#Pfx|z)I4)Qgf2XxP3du2DQ(`bT@q7=vn&joL;_ZTOqy5Ughtoy4^^7p zdC3d24%Euc98dz=k|yL)PrE{j(|uHXv)0OCs~fY(Hh8z3|l1q66 zS)EY#wSRZS6%-N(?O!eXbuzD(G6~LdYUrWImyMQ{DM+~%V~(D8NMndynsN8E+REZ+bVBG1s88vIme@U9Pn3y6z|sgx`;o<9!x) zSQA#9kbaaKbLO~&XCu7@LcS*SHLv9PJ{tyZSemR-yQz|ab2ek@cos{O82+W+e+hL&5gcY>A!7zw)Kp5YYS7?OSPGV z7hr+fHJKFl$?Qn6>;wDs%yPW22~%`olD)Ig))mF6wzzne{K&x|GLwcK_U}QYg#$!G zERJ3{Td@7B5)w~x4TS)Y$K#@_MW-?@7*H7kCQh#}JMEsd;k${z)5DAO`Gq`=VWg`Z zxCrt0V3|y%jhO}aV+#s_K>!W0K0NfTq{iY`44zsqB2PT^AM;%g5b*V_rw@+)()lHo z)GXq zW^JetK_q~H1AAeS06qXMzdz@q2Mjd>w_f$NMiPr*oGKuS5!?ZF1!ts z=j8NjYjuV%EomM*#_v2u#Gi`ah4b9*Lt5=ntTa9}Jb!7CS)Wn?Lsk%;d5_%E{ueoWjOMYK ziXyXL!rEK44E5672{NN8_aK-$IsnINN0a_K74@>+LM`7Rv%V?YOO~~e%D6px9qT2` z&0_4#vCWaSV)TJy3X(};MwPoGj0A~8zkogf@zW42HulRN{*jI831yz*Qs;b{86k#F z?KuSee5eEh3ut*Aqu}qr>c&L_NZeP7X#sT*wXudUBUMGRdyJrH9>AxxKF7%I`2PSs zd080xxr%~JYp0kendd_L4-jbE><&vX*JE}N0RvmmZS`=#`VMi^`O3T+M94jM-ixhd zLa_$-A!qHe6L%rA6E|}pku+3)^g64=WEkzxe)VYGlzBtq1Dy|!c`F%*Z|UGmENeoJ z&+Sz`qse9^m@nY(uPVr>Ra@2iRq&Iy-V6cx(}w0wwVTQOEXxfFk(gjfqxPQcuLzCo z-HBVaSp|r)5M`b zC<3afKcAhRj(o_QLC=+(?LhL<=Q7MknVgCZbJN2#u%t0X1O_LTR?rVSe_d+ifS+%{ zC#wlO#xA%R7#}KCBL*VUZsg-__NOj8l~q{%%F>{)($5pa+5s%XvNrGnk=^a$JYeE6_C+bG7sxH? zEcV7`Syo@_jb&z1N5})u=c@2$yt#<|wZmK?!N9=lRpapRxF-jLqpiE+a(a7HB0NoL zZdl6Eu+UD*t2J~iefb1IBY&OhAU**dZ73{AWn8Z=m5Js!0b4o+KXE+I{u-l88)x-d ztg4GX<-c`MM+DLdBd)GiNXYgi*OCs#y3LX+sbnmq1m#UNy|m-bKr%-zwB@zPIK7$c zO=lfxC9a~(ZSME0T8K0;AwU2xBz?or`wop1Q$|S-lo|KW^AwtdAdwqnb;i}3SWYXH zdbR^RR%b@Y_d#ZOX0Kt@l_V?)K_FQUmqhvfx6xMPLf{ZrJMBP5l09rNcjVu-VhjZI zp@|{MNnz?Rxn5gHEvuDs0?P?O_b13<<729<(2R@_4_YY#NW(6be2ue6*Qw{^iVA_J zk~&6KS)-aLWynR`Y`Z)|%#9qnRUh<@mP=4(^U=K&^ISr)h^6?LV?8rS)vaBlT6qX=s}AdKK2ofcbD78GEH-+Bh$Ggx*fxMr3CB+)`D zGea>c_Ahu{x!6DUJz3BJg<-Mj%v42ht%1Vn9Z!08(&QM)+YuAV2$Ld^*LIakq*6RS z{DtI`v;;;P^Zx+9z-82_o!bCaE#pZgk~wg%oDYBdr3oQ>yBV~Fan6$0jVsC{FABE3 z)|EC%F%UpsljEYqVKD|#nL3JE?8y`Pagtabv;nVDwm!5NJeAn#RG7tHNJ({yMhDu8 zc5sZy%>I5n4wY+LaN(r|Tb|oils8v1CYe*zF@Sc>Ao6}O&ZVzeu{oQTrb%kso&>XA ztus%3(xS*ok-^y@H_1La^E6QpyEz&8?N_yu6ER8S3WOiW>sp>?p1@nXkG+GxmB#l} zDq^QX$`R~?C`sl9Kh!HB0BHXJU`~fq_H?;XIzF8vmn?hG%ztN7EDkgSo&NwzQ_0cD z!(N>D+$$p&`977uw4WLiS{B(Oun^x6pJ`SFztw$2JoXVU= zA~XzgJ^RuhznbFrD}IVRjf$~JU-ciSDIlFBcQVaN26&!LcYsg8*MNLVm53r(;Jyu3XNo$on9(cK4fn?+WR{Ff`_hdO=Ur&E z{{Rz-i82sZd~~d{!z^T&yCbw`uI87U*PNy%x>dD%Em>?#f+P#;`S(`seW;O5JMQY9@m86!_p;7{`*uA>#-%M~n81XI!j`5E+XJSsR z#^8hUs(6RO7JPN$nR5-qOX*-R-;PB|w*IcMmrby~mHKwLkK&c$vyHEdRkpM%BD2j_ zzyVfkwg$k~#`V_Jf7M&HLj~QcIuntA2Hi*2vS0dP9I>R!eL7wPl0m^g^H%rL52%h0 zn#N+~wPzK@E?uc+Ln7Tx!8fVC2+cu!WMzT+0!>r0O79iOP4Bi>zk;(EmzKImEs6JzW ziVofBDQrC0%dq?6HvW53et*>4dAW@|Lp~QAq(&CJos-U}RIq6aD|WiB#>U6Tp0ib? zl5ItRUV@vnv5pIHvl7!|dN0RvoNFa&-cIZ?Lst$KxLYo=zDm&pxt%^tXXNyDotM&n z6SqFKOUrrEHCTQorwz_(nOPpJGbC`J9^C3Af*SSfAc`m=8VZ^KdGq9N3N>9aL9^)tnyEe4hkwp*%9W zm4$T1diDNyrLAr-{(NAnWDF6~v1^{=_{AmtE*~FPB|V9c=<7%AKXsx^DkK_a1Mm-n zt)8eY(kywdSpM`0-fkmyROM85Svhi~wB_j(veo01G4zSeGeJW=S0%wY&I&ia!CYrB<_1&%&1scSi71eRdPNx zl3FDXtiq1nSZlBZ7TEr|u3FKsCl0b;esLQV% z!07qvvBn}&K-BrqK2$<=DI=9FF^c-6B6L%;to3Fxbe}DP;H7^{?eq3c!Z;q*V+YumF4m zukF{GJtr9%`R`X-Why-RN|CV{rxq_2$ZkSzX53+>S1{KdzUD2xIu) zQRd|6jO6v~DrJt^*>y%sb2!Zz6|oei6y?Fqdeqh`t$f6}Nnmel+gT^C4Mh7IIas&P z$B)~g$p;fJt~mfecH8AlM|ET57nMHn^XEmfa@ogKw{sy%1*1#dnWIVl=^CPKk#{D^ zU(7tduSsukr`F(tGgN4;l0kuo1U5{&R<}gxyhJhD$Xm( zY(+c49eshMDtk$O{b_gc{yL|e9fN6689fK1eLQ~T0G45 zC6YKVP`xz?$aooQLv~)%y45Fs@{#tE>IcU9%Hq5>_lQiA+uW3d5K3TU1Lcl;*HPim z5^#D<5bi0tr93>LW{- zqYeuF2LAx}SCUAjjF}a&oC+sVE7Q47YEkz-B*%1nlYXkbyG<|KQr%f&G0R#aip5DC z!@=o$%Xuy#Xw`@gH$6wcI%kFPNjQDVxJA^PfWQ!YXEnW>mbo(it20j(6f^r+`>$6Q z200v4X&$mDh!sfVXWquUyzhM{!?;za4~S?boE~JH`|~xe#=KL-_^SgdMu>1QsyPY@ z0mrP1Vw|u|Ha-sNib&g9!PO9rf$nL>v^V?ptEq|ClfV5}Ip#?NW@XAg`2vjDzlWza zcrIG2QhAajtrX+3NAE0(r8`Ro?_Zz59T@2esg8Tlp^iz|$pmDm>x#{oE49e!nO&w4 zxAIX7Lh-C&iI!ocWe7S;AH^qL6n!5xm) zf;K&g_o~Rsz1kYu4&n3Uo~~KO25@^--ro#RoQ{0@(2(WgnXFeaS&?I%;zHm@?na<( zqa+mvyX5?M>%~v^9sdAIt09O6QZhOHD4d3Ch-_r6LkG4!i5bAH6jhLgjpZOJ7W=%h z`8{|89^-LCl3zWv5tGi1ElSS|x=LhN-}KQ)lB*$90FntBDo5?(tCoE}ymPDMG12g_ zLU1hCyaX<&i!44S=lgS$0`~ z?E$`fKs|d?s{r9h#x@m2Rd2r;(4)6%_)wt*h5lkptddO+Z(;o_g3x!^NdExpkPWY$ zkaxe&R3sqOTMQ_nO~UHm%VD^qZ&IX4j;jQvqy0nnh@61Ov_~oueW2eRy8}RUzzHhr zJCb&w#$<>X(+LyY~%{F ztLy+Ib0(V>`NcS;F?N`}^+@GsKprIbQCPQ*-z02o6R*!y52)!JvrMhCI_5E?{uGIm z=P}V~u2m*@-brbc2ih^j!}&68g0WKD*8J@M0B)t91QVWB5!kei<+=By<>q&0vjpS^ ztsQ1bp-{c+DxnZqG5~vtf7ta=C?c6_715xdQ0x5v02GUr1avj%;fyI#NnuFyN0+%OK_rH6b|@J%(lknuI=qr2fO}OC{lk8L z>^hR=fa=s>(V^9xZOV^V$5vj*(oCz4Scf6&S(aRY*pBO8E{q+L~6f zl=6@~_~_aOhRl)2QBm3mp;V2QVDtAm49Wl-@ORK-iO`lOJM`y+Pe{RG2saJC<4Mf+ z!lVAKO6elJ&_yFLNgKj3C{>rVxbQ!5{{Utbyp8E2%fT2wjR$Op1%XkIV~R^eh8s21 zi!(^3(_XNW0;Bpw5Y~+AU(4Td^RjdYRR9B{8v~P#s%4s4BP}Tk4=UFEgp^lrZCVIp zVcI|^z0?xY1t0)~k2~;p`yE8mvp7}&r|I>tM6oxoKY9fwv@^{dtvh!1#C^MDfK?PQ z2#c~vWCZ?t2H@&U0jio<-HxdU$8YmV6J(=G>aDqv8?lNnXo+RfnNfGP&>H^ap1t;y zxNKFKHTsimSKq0tVsqIG~12*D}Tal7yiCRb~_)#R%BF+lL9gu|W1IYgX zzgA-CglE%^R5dWle6R>}MWwhVNXl4e5=jckW|`%ZNnnV@5N_kJv7ih6hsRZkl*OfY z-+Huz$aO{=WNtgrF)E`xk*u$yqZ+Yac!C>6e&ErDB=7J#8Y-bAZfes@tJ3_14+V6H z*lAUL*<5#akm($0{J^37`TfX09T5UItC693b2h-?D8pTF@s!XGr9kW(`;WGTi5fl+ z{{T;(y#B3+Gr9i&6>;UKK6$NSiW32jR3*{DmVcIW?lTyVjgUYe?0UL36LY-{iDUO? zaajV>D@chISDB1ZE4YqNZZ;bLY!HWE1EK3J>H})5g^YTR_&q5fAy#U0!(!4Qqdc*9 zU4v6B61y^meRmP29|u76DN)*8U}15;Y*Uuf#=}9#Vmi_!<~(>X+Y-HeoHD^}6bF$K zW1%f(A$y^ZYRcXT>VbliuY(yJ>dg~7>qCi9^6!dPn+-eD#bXq!OD0Nvp5UJ1v{dWI zXh;vk)UG6Mk3G6eZxyycVsqYw1<*Ir+=6fy1Rb~k0J>DhQ8_CKOBL=6O-KSqW3@zr z5XarKDJNd!`)z!k0n-`)#7J<6IhxLA2!`f;S3 zP3=RrI<;0ZS%a1=&GG>s-=e66msF}wlgxFX#PT8?4~ZD(1kt;5Vsf~*r3$K1HXbXH z!6^!0mL^K`#uvwQ5`c7cz@2n?E+U>Y1R!Z5msl=gxEFF~Sp3B$;j%N=ze@phJLE9G z@}|J=Wy#=dC^e?uz;{V|0UwVe_vvVzqj3-SsNWrZ>6^3?+Pv%VKR=xsN|}ttMU3W*gDg#m!kH6WPbw zj)Z8+M|?HI9BkhVM}2oqpN+1M=b{PKV`yAub#yy`MP1>TT`ouw5zidWeQ@JqiV7mO zTOn%Muqxwf)D3D(g~xY_YAql01NSH^tpW4#(sxW_8e#>PN7N5`YCBoxlzAEZLl6`W z{Dvy!`en-Vmbjib5$b+A)*F;lh*$USMzI{FMeIrzjH<>YykOEwz8RfMAL{6~%oZUW zV-1b<9B1EuZ}_j+-_!p97Dqn}kA--eDWc*r0HMk@3W2syDw=Tc=3)2RvPYGp>`Cm` zX+$=nbV9(^lt$u4@t~(?&*SH>dEa#SIGY5nd1o7aGhb1JNpBNRY)sZ8bB=iH_*Y>4 zK7-P}DfIc2aeRiy6<#}*lG;NRtT!LV<|LV>M~Q;inr<~6JWMWr^@0W@?0Ncs0r1Zew3S-o zW95U9>^YJ1&ef0M&Kn1YZl&T9&am)v>e0({*M3`jS0BAa_2zF?qRU};71(f`W;y-t zL6x_8DO=0KEP)0&377R>r;<~*3H?Ju_dR{1gxWz7^IYqco~M>~Sy9X5$#} z{{V(VM`AJ^*KJ=~fNa^hi~?G8;mSlJjbyJ<389V{_1f(W+9W4($C>vglFoMrn1SJwA9zjJWO|a(k!{5^V3?+kK%kQe_#@+> ztoHJ$$O2AY`Jx+p+ZI^~4{4piImo6}i;-DG$7dcn#D#xPXGvHX9GO@jC990eTxjX7n{VX@X$ zj8oapWm-jyS7V~iKbG;s3?th>n%UX#I&|DdjpfJcYR&sRvniE_mj}H(YQs%wOK&|| zjX2g5C5o!W;;kAI%x}tcvOkT5U2EgdP6=a;e9M3_NW5~p`4)^>{vuEnMh^!>A7#{{Ewcn@A#CCz(MJRA8P7&X9$8Zo**^ORP)Z+q%L31 zG8uti)it+X@42`0(wE#>ofaszd~QWC!}ttQ+_l`h#B+t| z^QgLbEzGGS-X1GvO}HrQfSwjNCk1X zIunt|_OlYFab}h1#+nSEtcxFTP(B!w{{W{`bahM@`*ZnKY-A}BBeCmJJhwy?=Al;N zP@`TXk0EtHUv4!{*2h(1LbaKJAo%gW`)c{Xz$4fo=4&1p$e6UinG0+^=%xoNMdywh zQC)0m$DvBRCP6ih?MG(caWKc)GzZ6@Jqj5S92h|{_2;>#?xCISu_;xXp~%=%>DJx# zNTv4ibknrS63h*|qqqZBRgAQC;bHwpWr*-K)`Wucgq6_{<~2o>ZZe<$SrI;F&ZSD_I3XQ-)#r;*Du@19*gv|0>h z?{nOxDYc1^_FIB(!#zlJ1dg#w+9qB2&|dC=zZ8XmdkDYJBiN=zAN9E#I>J;u`? zQr+ryT5N4%_helJHOz_;1ZvD!#|!v%K1XmP;Mhy^`eSa4=)*z@*llo2*u6c z+7y+APUrTdNHI6EsvkR)O31F$)WXMB&Fp<5&8Wr6)b8!5RF&>>$^h^_I;3{eGJ@@b zIuZN*DEAyi1c%8$)wowzVFB#F%*G; ziG~X9DJfOg7ZDumZPXt0$HHW*v{}5Jyik_DPI>Iy%sgu?iEA5E)5g=OGXm>cD)j?w zdG2r9psR;pIH)HCl5on)0B~035f*^>v6xHk+TkVmIr*=}wF-#Ade*i!CxF@z*lEp6W*6mL{W> zV0H-oj{g8YItWHbD&IVgpIRFWatV-WJp+DNqsHDE%&Cg2W=W>dNuevq73l?Iu=8BmDq(3t9)2 zfbAwS31@_O2f^rp;49_CL45C?V0Ed2TbSefO~GLi?YQ2fnYb=d0eo{Tc@?z}y#mNq z7*av*mPQS-(X@ZLANq8^meEP*0fu@0wV<;!!_~5p$abRm*0(H*kh{K1Cid;wc{S|C z8_)W2a>;Esv)K^~`}}o6r&(y+i5n!0@*dR<67JJ~vZHVLrP}y;R3vXAMIAQ%Me9z( z-JFL3W(ol9V1V}M_yBAkuBoMiK4>Q4Jh6o&lYl*_-MoET&ASoHZXBICro~SrXeWw9 zb(UIIr2dgBsP11oE90rq0n1=1sEx%yq4vGwAGaU6!a%E4ByHHAYNy;U z+&~-9-^W*h%q^dK9mHvH;g9y6`%|`jtPI7-Gs98iX$)Wxs`g^71G11uxg@7xe#55P zc*_-+o-_Xd6pYaj_=hUQ91%!a0CE>)iq@mnLTEIiH49l)l@-@rtjvSw=Y1{fVg{@r z_M^Npkh4e&$N(GBcx&EPiZ~bAtg*Zi#iazKt00C#8^G>z4#4>T0KZj4ZCOKw2XWqs z<_Phe`Cw$=@})WvT1G7_qz57c-w<&cLmUO9Q=$l&)q(N*bX`7dfb2bq=R+4LRHS9u zSoY;bu45L&k6=k6`Dxa=0p0Es*owonm5rB2!T23kRCVe#4gNx>Ol^hrYWmhzD$%;F zp3cb@D(I`>QR{a*Z4@z9c47PI4W6Ri;cTqg1HDg|Dye9IARP0rzl2m(ib&hrMvbFY z4Y!d@zTwf|Y5RxmvDMbyLDYjQP`7d+B1K?GpyYD1B=%8b?TO-zR3~Uu-<4L@$p`-b z0QTywl^Hzw@6xVxFb;!%&ZmlAidb$vQzMw=IQ(V%al>N080wqU)(cRc)57eW97f1g2LO5wlL*Sz4%e?8J9@-CpeV;=tX1zVrnyLzxIB$)eC zW0=R?`eM#yR~d)q6LF%VZ;-$_n;w zNf?^cOSqKE0?Qysdsna@Add&2z^QIDtCGC3cQr|+H*yP;3Wvt~4AM-#(2n)H5=)K7 zW37IgoUD?~4pP`9V`V}%vPgqqe0A(k1lbue5uL~BM=xb{YxZqAXU5c0%&Qz|k|eR% zva0BW6=VtFgpv05BY61G(dnge@kjAz`SqltL%dTDKs{)znCy&fuDy@?n1GkNUtp28 z0ANRs(c^!fsKR)q40q3e+N`q0lA%t*gSjWRh7x5G6^gtut7t1ZA8-q^@CoRQQN~#M zvs5ASN{>wmB#n)pYLc*JuMv#7Zs{CP7K06)lDq!hd0hs;H5yPQ(xfDozvQ?)H4t=c3Qp?U@^HHip9GZG0{L~s`ATW<%h6sTi%5BOaa$xh?wZ>f1a+a$%7tC zXKG(M9K|l&tAEx@6aN4)0MuyD7}3_i&_6mpN8{(OJ3MDwe0=JCO4@^x}gmYQx0GMt8*>leP$?nv}=7VzEWsy92zC z`qG9OZAE*|_k<;Z@ zo>0gdBbNF8H06%f`l!GT)I@R@?PAiup54C50UKk-j*3jj(xFukUi39sjAYBRp44uK zA#`Sj%_&Fdq3y0!jISsvURe~7NCex;Qc%*UKy^lktvIXlG^s5| zoro_P?o;IZk}uk)w`W802-lvUX*7C&yZM?-MoIGqe4OJ3l~b2R4Vv}hQpq|wLfwSD zgkqo>4u}e)xFguK`cFk9j!mh`-s)NZLVLymO5S-ddWMPm$#?{g%fNc1zqUtUsZwzHKrJDyn z`&N_YviPi=7|OP&%|d*M{vV%Km8<^%4UTVZ=DAq0cu?rEU!8TW1^unXu$o2k)zd~tqr$05UK zs#wTOb5)~`l%xcj3#6h+n@k-`SC!VO)=hN7Z2^J~P$R0pt4>#%T#6VO+ zgKBsEJyF%;d?E(~XMSHwq;g!LY?2sq+KLPnYVRvh$XLol1aB!zC9KXX1(4cU%EAn3UnQhR=RqQ&;O0aVh^ip4tg?#FoKZdA!u!p|f&Cbc3_xFU{7 zNfatbf9ItlPzYUuf42G~D~vRF(7@Eg8eE5%g1SEs#*=2@uuWpp$?s`%+nicW);>RVNg=uU?{g2q~ z?adejqp8pUNCPvUKaY-z$V!%VP)Y04YJ`$jQqB`9aY$Kg_Hz+(ps`}ftkU+15|9gq!Lvv(It_35m*G8koP7v4x+QCGNZ4*vk$blsm6`$%bTL~QD(nB4NL``!w?Htr~F zCYfzq`ye836XBiynh6NF0X`>nr_jAY_N;*N0QKWjCcCxrHPfD1Xs`*>&;IKob{pNwj30JBokd`0e=LSv)_7c$VV)aZV?4QXN?l^ug)1H+RK6J!NKi zR}QigIT&dUbMqZJR5`~uW;TRNZ#i-DnHLWnRb||eq>T}8anO_Qvu}GE0Q`9CwKAEq z8wG*w`PUCIWIAI76FmK@Muxb8x%ccvS04>=-R-KzNra6g3PO9mqz~!Y>Y*Y~b+H7} zX6Gcw5^8MoqZM;WD4lA@dU$IxptW_KJKfMLdj+@N2EIqgJr~l8nD=hjIW#%eTn{vX zA?P{^*2uARrC`;q@F_dkPcI#Tu*XR2ZSR@(L6rifda;yWY1%Bq7*s7dTft}v8 zj4M?YqY}bDOCT~J?G7{m&;`DBMzzp&NC7dAsr|nSqO6Y0^BjV90-lsI*xPba#Nj3t ztyr*-Q;G#hf~1Pl$r)!z<3h_B&?j5a={vy`c5QKRNYFZDlTUE?=MzT^81=R{_oo#~ z2PH&3Y{k#vx9&?$s?8L2Y^2H-MwUbp1PL34C_9&6c;5QMkHvNnL=(#`aKo;wV4nEr zTNj=S;xBy4WM3~%dUO41I=x`flPevm6bP(YIQge>?OjOLC0SvTW*_*Hx54r^&O0UYpCuXbu!sf-M=+YcAH0Rh1Y z+*ks4ug6mI$YoIH5%%||hYe>y>cF;;-1ea{mN_lSW5m-<+EW3)V!;vj3vw&8OWs*s zV}zdzt$sQ;nK%TM3^@;#1jS$hy0~H(dT=|^z3Vx(zaA1F)JKe4j`h+eAN_W?iyyif z{^J#BL1Vr7AbIiRXd|7ANOT|lbNSRusV%(ty-Cbu{?rYO6!z^YTFr%yipx}c3|!kL zaXf7A8-4mM<9i!l+o&-}50(yN@B7p(^1ZQ1R5XK?I8hip%aFYt>+>2b9qW}K?xDlang_5$mC;c(qha4RJCOp zri>lcHQTz$jDpPJh%2r9cpWmvO$OY8sKcLX$%Dx(Wt@Q8vOUguP_yN7IltAzpMoi< z(ppj0j7q(;%*nep)I_WQ0F^^8k0YgcZw`vto^4v2q4vcy#kjr2(xeKZRabM%B7+%f zkfE(4DHVfY(@23}8U;PQ+W;=WZ=WNlN)q`T`R`e%eHxJMo|Gm4wm?b0Le$TQoMP!i zX66px45TY)ce0WGR#znHe~4D3*2!i(`*W>vy(r{)ZWfJv z)*l;?SZ`rJPA&V@tV$?JaTG-E^V4Q{)wI9F1!JrewWpsJ`9GC2 zF}#{yaVB22Ix7&zj=8uVP()MqtM()Jj?(PizCJWRW8`l-&f6Wke=%B9TALM$>d9AZjL}XVbi50Z|1=W zi0&BEdn?E(;)sqpViUt0U6F@DEUhexv9!I~_j?YspzpV!ltzDE;L}R8x1~lDRC9Bi zNoy=*tsScM?B25tyAVL~E6p)Lo$zh_n5!26@9iEBRBMz_B4fzYgNKQuF2|;Dd(c@7 zhD!CBt67R!4Rn^I=_cQE1=8Dx+hB%A<;l407iSdu!>q>s07-6SB&*mtmDHS~=Gh?r6Pke+`<(VU8Q!Hx5 z;|H-zj`=nT0ES%yuk1e^3`i3x7|WB+s<9BE+>k)!(u}@Vqe<)uk>ibE5L~eftjf&2 z($$VNVv7-XZ4>Ypsn%P0Jt@==ruE>VkEeF*dr+9X-5MD>wsM%{%j03U47YIx@VZoq)p&MCVIEup+gZlm*IQ<9wx7Umqvj&|iq zsd4;A9j{gyYrQ;KoTTxqZWooMc6WH9IxVr@c23VoSVKL9u!ecO_UD{$o%33}V(!C= zT_o04X=xDHh3ia#~B0FLcP;k57RD+Rvz5MFlKutwq3SQ z+KNCPf9dFvu+!@}s>q2TBy|3MG%DmJg20e4EM!R4fE)Xe?FiR`!)HW~Bda^Upn77c zh^>WUK|KvDrD&qB9o&@0acw7Tc0D8wE4eN``x^(^uM4k$4%~yU>~$)tUQ!4nZ}UsB z5=w321!Z2i+wq~6Gi}Su6>5p?#WC+@g0V=H4{Ri3yR~9aPj1+--$b`5<^^T@ynodS zWnE!ii6aN=?^uGw&`#1MfV6Q*6ipCSifXgQ_21hP1!hNy$DMZ%^VJ3~c+N|lX z4scNP&1p1{!30qPB$%(hSg;BqkySmtpaZ-S@vok*D>4l@!LJg|eJh0&JWy6*Wp*V@ zu}2Fl?`iFsGzd~WNJjkiV!9L)&z(~->ey}t6UfTdlB5;S^Xd5J9JdIv?1$KdoKDJ(%Gspu)C zi4>e{yV1&j$1x_=VIS(&(;~}_nHt1)Ym(A5E`Tvk zYFKev)PhZ_qt=*4%HDrt^U)2=BxD`?RGVqR(&|_aqkpX;)pV_XD{;j0S~X=hC^IuO z($J8|i=YnKp=RyZ3pmks7HXLAv?h-gVQ zTXsFKc6xAFh(Y?gs>H8#RANB{eWZ^bM^>Co6PM2TJh8~eKc`E4w-Z*zN@R;49olxMYZ6T8i4v))AOy6FvUC@JAa#p0i!`da z3jDd(?TP;YsXx=ZSXGi}q~M4Pr)~T~*4nP3HtAwUcXcC9ItmSwzLXHC8 z65+=;CmGCT7VOn+%+TQOXJU+?y^O|4s8$)24UzUS8`oT&=Z1K1+gA2?P~M&O3}^h; z%=o{L{3~sDB%C;jX55jDwQ}0I$;*8~^nmd z6<8o$7PB!VqNqDM&_9pw*4sFV3xk|yxkf=D(4RYD)-CGL(5jjpE>8Rw&2Y zC-SU6Z`<5|2hUkkuw-8?K_ujy*8QwXgb52QSlDAV5#{q0l4pxCkGo1q8&7FvS)g_d zI|!ORj^y9Y_BzL&;v(cqgdT@AxdbvIYqcPwa|C}kqBhITR@CwnKj^6~y4Df4ZbYH# zEUx~0C5|g^z5Mor_~E4^%qm@C(vk%9UDHcBokN2Kw zC3jl+OqF#fwWm)85dIf?2VG}h->ucp+4G23^Q~?j!~Vr?F0_*|gWNvw&a__MgASSP31BdM`71_7ADp;y2% zGZ4eWI6Cvvz(jZC)LmB!bN!vDUfJ%kj)jP1Z3g`_1%_4pc7rE}-}dvqj&}el$g62T z85^1(HHMpy-A?}Lsmm!fZFcs#@&iFI8&!}f@H}r@>({oAYany}+fXg;R&_C*nQ6Nu zZ6P56KhAYsZ(y?!pnM$?M!tNGm5~GCM_OTA>MfjLRHaW3i!f2{u4l7K1cBt2?TQ8) zl4q3;gwy+{!T3-H{Pn9HveVDb3~)5J^b^nWNBDej%b4&tTInpG=b${J5m^=lVy&ytI%0| z#|3s`H~s0g2@R*5H(%%HPsuP?SgTZnCz!_BmY7wK7?z4xdu+f-Jmna*iRNN*ae4SQK^uI9(<1< zjd}D3p;ekjvsWcjBxj%W?HIO$TOQ&N8^Aw} zblBoDTgj$SB!{Og^rYtD_VWX!S~*Wz75zfNBYRe{*2oC1XPUi#(?+rn<%g5*J0N)c zj=d)tfSd#&Xi30sSY;+BsL7z7emBTu_92#fQ?}X}MBe1A6+ zB$mqe&<2*T)|5VFtSr|Ad4%)Feef$9=HVx~`yG^cgbtlgb4uUw)+(85_*$~$ zmNhbak*dQ00efU&-g~SfxYvM3=c;|RG6a63p5L7p{WfM#Sn}h|GwgC~o?`Xd>0Y!K zzfUeF8zn|)+DWr=#T0$TnVH&}_aA@;kKlBj&DQ$B$vYF+9)E>sLu!_x268c-`_#c- z8H$6BM?#W8C0Oy%Qj6^xo(LdVC6K5)EbN8*m-2VkoNEz)DD^2FJ5~v6$*$gM8%Yhe z{{Tvjxe{X`dQ(`O(#AruRi{cg-b;35Rn&$WaRz5%!(GH356??a98rW0PnJ3kW9M4- zayv5={{VP@apjsJE%>ll?|=07;m25*x?|7@va*?WHX~0lk3XM{^eH5Y6f(vMg!lX? zrUQ9$BbNeTIr~%B2V$&(G_S`Y;}c!1msDG_w=85;4`H!nA#Y)sm=X`c=&32e@>2-i zj^q9*E4xrxeA!FJr|JIybs$jWmavva9iWJB+A~Il}CP1G`(^f#~1q z_HtXi%EfOK^xmr{5^GYimDg#Uk&UrNYUBAYB$p*ejhtjVr*3z@dE>0mEX>K>36;M0 z0#W1ePmZ!yS#9K7Yo^4HjXQq0t=R1);-)#cJZj80Ae;qj98xHCP$YMoK|De4(J{HQX-;z2ZQLq0My)Y4Tf zw4miO^<2Xc&RL$VVid%cDJ(ejkVV|&hJu+RNAh}F-pySMt+VE|Zl!pPR+&0+y*W7s z9vqEi$k3IU4KZp6-Tk;ByJP~E&vES!97Lbk0o9CUfKiP?m$$h4GqfZzZ04fbu=4w@ zkKC({q*&R$OLt`e2`q9B#u#C*+}F?JLbj@nX6(sRy8gztA4TEhOlyW z`&Umbda5ADLOVvw$z_Xn{&o9~lT{;Qf^(ewx_8RegE^GTH|@+|jsxcp^; z1;mnjH$&DF2|+Bi+0L1f3G?y4$5)ygx!5{jCtlx$O3Q6(QDr)qoy9(B)4e-1^4B%u zhKoRwHl*&cMVdcsC{^5zz4+Pb#95v)m{7_$_X3n>k1Z-=%Vs?KeCP(q%kNvpq)!vn zf~dJ-L{(H1PB)T`IM!KJVu`!J zVHlB=86M_!>=lUNCzO$wH04Ai3X4_G&-<}%q;3x?O3U1n6J{!q!t*3iMeIzB&l9b|-U)COM`$DD zj{~TdV;g{@(@>F_(7E`%=?^1p*H~n*Op}Hp7@I{=c1(=md;o*T&sCmGFg%M7)}6J8 zND7UB6b1fEn2qDa48}%UNX4twYjcR>?}2^AP*?)dr;)z^{B@XOc-*R-=a|k#YOUqd zz^G(@Ej9xtk*CfHjB$rG;jW1)FWV%qHFCkpln&z4vHb5tqb1Z?(&>UfoT)#v9O?y$ z&UeKdt(Tf-XkmYFPNL1J3?ZbB9d@){RI)~@$7E~IQRWAbFdm#^9qJ|$0uYcxQk87( z2@BV^++q@w6W5K*^4Vok1WN4WEp=h!kHG1ap4j?G(*FRpQsNu6LmjvWC$Icd=C(#6 zW{kX6Da{pWj82UtP)ky7HYjVjktN1y3P(Ik5s>TbK*vbi zA7~}dKzQS6GNgifRid~om;jJXCyp~_Ycg4q!p@2&uH{)B8bu&CZ+G3K4ZkPPP%Zqn z1+o;LlvyQ0o?3FW2j zKq%jT5j9BzE3IVgB4r@~ z00!@jp5`m#&W}YhI*-Oj= zhAk>%J-bzImT@Y_AyFPS-<>Fb9L7Jk9DqYJ#6l3NvS~_|QSsb$`0C$kjz=nk5{`e3 z4Puf!i8hV79`vgevCAt(BSJ%0Vu%@7vlSq)Aol_glfJzhqn!tF_@8=*iKE@Y(T$G( z0G(|j!03Ty6{ayX732g7HtIts$tS#i7~t8Pob#3nuQ$k0L$in2-D z-P)tb0mpzpZiEA5odJO~$dIMF!~&@p&UscU8<_cJtR7?SMI@3*Fbrgs`-uoNN}2XF z2>fVu=8ehNs+@B6{VKZRK89nc^2aLP#(0QTgR-bZX>}ls84R7qIZ+Dt7}-X*@PEfk z>(WNxkH>l;vk#PDK*v4k3Ot)HS*$oSJbj_5q|(VEJP5$b(kMO3j5NCb{Y#)=buM$; z^{dASi~$jFROk8p`qZ&Qn%2%LS$eqaMdgU35y2St)eHs+B3NV}Ye;bR?x`D*azIlE$aDI+E6>XYR;}W&#Ok%T{(}L0P8xy*NR$Ru&%gPvt$K~>MQT`H8JEo7yTkD_U&`aKTQbp-)zpvm zGT9mg$n6;I?(PafKj%IU55gOZw;G8tcEfX?znwQH6ygJg9uV>o9&$(RO}bpx*+r>7 zBv5yfLefPmk@|mdkdX}(Kv?Xl@xLRjT~{&5q^?Qid5^6kutMk$nnn)er{PZACUcHW zex(VsHWJ{cEvTdWW2uj^9Fj)`JM+%!A`^)_i3i{h9Vd0gC9)or$?K2$rx%1R!igEc z40#^atoce&nMKQS2@=%G_Kd8spKXqsDM@50CcGbwb*&x5GFmObT(b;gwPZcC@kn4y zX`|e0KUC#l$bFn?^Y)`3~oF+`x zni>{dvF zHO6xJK|P^7@0zVT<=VJZCWe2KH@zyj$Zhr=9TSFkkVmJ#E#U8lUTPE-{JM6ce5Ol zM;*Hc8z!=+n_a`%ie`=np?brwI8`%TL=cU|SN9k~-Klq)+8W`hWydj1?vMEyq>PBA| zYD{>wXA3NywB``V{`Kh0jL9sH-}Euoyldy9+uvM5>m(8tDszHKoCPm z(tBvi&m)w3O2Fum_L4k&b>Bra65FKeV`zxvcly`LWVqtc+`LS;G0LX|pgL+S=ig5r zn{&7-Pg0gohi78n@Z7dG6!v9JpXAR3pUJZr4YJDJ%s;nT+)sgUE-x7KRKs$lVWYqY zuHw2b0pmXta2IAt8s^85Nq0H+&m&b^mHimD)W|v20KNO`EB}Tcm|&9$ zatUP+N8|wn#=V9tG(I=iJuief!pE{Kh&vtYsI>9V0lgv1v|yf1)7qju#}Ve()p+up zE0$CU(f3O(Cf~HBSb$8CMDhM~JnQq;9NZ^~rb62}$6;LrTyKTSgskXS=S@i$BURQ{ z#$+KYGWMD4PNaokbiv~B*xq_gtgn@WNfek~dmXo}0mN+Ca}%cHzA5eR)u|klT+2<8 zuX4PQwLEG~1hLqD)=zZL7=*_QDDn9_=%zELEs#3^f5iKIpB<#NMAwsvfq=h7%5Puu!T1UpD=*>e$!dy$EQKy}Zn&a^v(t^<&E+JKsRVFe( zuQbLmeX#GXcLBd0DI&zJ=ioG({&ZaiQRK045b4sj<1c$7VmpRODp82MQOM>)8_pG# zT@(74N3*}j`5iR`9$?oi50-gSmor5H@|SkoYIWP^RXFm!d{Bz+Mn350$W8>0Vn_Q# za6nIL#Grl$aU)$8SyddQUzb|XULP&grb&(<>?p<-?o{^WIhE-ofJ5Dj$4<(g)G`w* zvL=VH9C-NNy7Y1mat-J=ep0ZvutBe>{HWFc08*`AvLVF7UK;Tj;AWRc+JZM&*`MxM zN%r;Izdky*+H@S1C2EVoz>m$jbLRg5JJFc=t!ZPgI>~M4X(y}fK!Q)U2~sgR8w!ai zK0bB+0qBD1$;^C*^Li857HvG@BO^Wk0L?lQY{is8kF1}lmKuHLP*gxNCvds#?hL5A zKX0Cd?<1UQ#(C8|m{fh?$P}vB=*FHj|7wXQXG)xEj)lxo{PZuAdSqw&1vYL{B@Chlw%&$kLvjT-B3fVNMace zF`sHAmjd165qBGohLh#9%BRRx$B8mW_0dUkNaFWlr3BUO8uiJMM`CxxJD3s+Z+{&N z>=y7i5=0apUyVIC5R&6EtgKyBkHVeScu}4>MpAosYp=YoQ@n`kMLML7ByKidRhdch zG!Kn<*G;M_p&EUF9KDTY3!k3%nZm`8?>mD zqOl<81^M%@28ak_8rW*iMgiyAqU!2fkV2x6a4FpeBLscp?8m7f{V|Q&KXNt%_&vjF z$FK!q_jN8&*oVQ-^E8l|;y-p=y6uj8Q27joIyh=tuPiXXX{RKT_C`d^r6YyG00{#f zF8u!h@6Zqu*o>TauR@+_(K?;|Th?N(Ya%=lw3efVD{S`3Vb){cB0{7R=2E}|t@-}| zZlJ>mCP|nK4CK)ju4Hj`IW!8zheneYB=fK=z6# z*dH4I06hUX=K~I4hIBF$rvoQ^*3`Zh@dQzQrtZ>cpeyk*5HXH<#W8sGI$4`0X(#r7 zRVaDqkhakqazO&WaS=%KB84SShaPJlW5bdjW4W13ScD-|o;(f9{mLFlOJ%0eGc z;Y;2^&@PF9^G_^PTgkZwPYIZs{z8joskOLM7?_9>NvKg+VHao3sXf3grTbZYXz2AV zBgcaw*x(FPR@U&`S&SMo?60K z^z8%=-bg3O*W;w+l&%?0H4GD4wsz=Mt*~%MS|r)q&3^YR$xjv=6_hq5j~PPPw3lR+ z7fo(ieaQ+)zjtTgpN@wSVIguBg!%ujN@FVWh-H-BLZs5LF;C%bN?q^(aV{{X`+ z!b9X}V&22eC3|eegsjFwtG#JJ8tG}}5=ra$hiW~&frE#vN$S{hrE6ZJ!JfrtaQJkS z0aoT~8%Gy%mwY){UdMQjYf?nru|iBB7W@u~gwC21)41Gk*R5CVrrKnd;fufr!g&gm z@fiCYa~B1^Pj&9(nkXyeGED7ac8E7^WocO%-4RZ~{@>@VaKab>kb|~%tQnE7?n$=* zL=HgdwGrf(p`%Hw<1AObc6I)Mt?|Pc2(ATYzQbWyq|I#x+S1w#0S~pgsW6>(It!O+~jJ^h=j1 zaixA5P>$6YTEx(zMP$Up$OsHfvHNq0La$>qouD`W08XrtL8~NWkF862<#MOfp7euj zBP69976;pS&$a@d(03Y^{-@u-+1|DN`XOb=F)lIIicl(G!3B-HPEJgM4b2k0Ghl=$PfO#skYfGvC9cy?<-jQ zI0WU0W4S+tKYVPZktDWU5;v?@lF0#wxXWL?q>xOn`F`?{3%*13h~(eQTAxYM3zso!NKhYIN??d%<{MZ{CM9$OOUKkWU1I1 zk!aIFiAt=Q+qEik&r`Fx3?3s1m6jL9P$_#CjGc-bar0vRh{{=9$1cZdM=qt9D(Lw0v(uO?*aPW64iU8`PJc>6 zu{D{Qdxk!ED6a(F@0Do`o(sy!7lB8G)SW|Y<#L=|FCkLlWZ%Jlp ztoN2x8%S6nUf*sYd%I77%;0<<9y(mBq`Ble(_;?;sR~rC$>0u?=lmyaY{PRyNd?)N>ors@3DFU-eX38N?aw4x1 zc7Up9MJ*kTZ0H`*{rvR924>u4sTOREr~7&5OZfa7O;qpy01|a3U3mLe(?n)0OLsG9 z{{Xj5M{cf!Fd@$_)t@!jlOc_F9L+gsai|(quEE}|scZX?wzN$(Wi1?_g1+F9j=}f? zrUVF~LyV8olzW+_```d^%RQ*3R)XbgHMjOcoM@``Q>(^50K3qhWUZbzQL?Z)S z!_qr`^k~G3HDzSdTCFtX89RhRQqn}aeS#J;d>{7e(Ym6AkZN)>j-&CaOx|X0Bg(`F zw)G@%P6?9a`HnBeDioU6B)2NnuwW*RCY(+mC6c=;g0Ei@7%dqF|esWGxXV+Jl~J;Y-Q*&RWp9|iy5h+ zl7x7CelkJ)N?TC|kn8m17{aqWtaJzOp=@|;aHZAO0P3ebIi0#!)O=6<5Ai<<@h1)8 z78eUOo!cmO0D1=5C*@HmHsP3jKQzuz!PT|=4xUEL{MasQb`_l z^Z4l$jc!1^MG>nxz!~HY-0SB4HL#Pz9xA=?)G~>Z*8^60k~ih=N_gstUR!qRk8SO` zv2;Oh)E%*-2+3WnOmqqO&*S;kcg_^Bo-FxOB{%Q>M7g#*&r|XUZJW%S3Xg>K!l_PdUDMTLpNG@ zBh6R_OV%yfEABt?>r<{HM^@LwuJbN|-}Il`sfEx4n?HDZeQ1)bo@3jm`%1aV>S?pj zv-fiORN(w>Udv?=L_B89y9S2%B3bXQJYwLmN z0N@^{+MPHtfa4hWu~9+#i7Un656^#syMkG~|JU z?^#zdU2!RV(8UlPy{WmJX1h-%Byhb9VWDCPA&i@%4|ok*eX1mB3;EJ_Xnb_F$Y+R! zTtl4T9^Tcb!{Uwu94`9i4bQ30+tLO%D;>;J5pp_HWK_0SDDJFk!2npHiZqdmnI;2B zzn>@Ppxa0$jNpKyVoBw-TJ|X+yeR9H+<-dKTy6kmB)1I7HL4NlW9H0>tWNDBu9_cd z(n@7^-v?uR>Jrp5;DS&ckMB%ReDTDu5eqTqrhgEjr4PD;A&Hrpc}a6B`!eJM)(x8xuJg_dbUmhS20@~XT4(zi*hxA z`)wl~uY14B?Ee7t3?)e*Wt3u2F-jX^w=tV%*J64el;3=%Y^NZkZd#1u@pvkeM$A3P z;g;=MA>@g4@mW8sov48@sI_q7c_srTV${;tttH5%UebyT5+c`*q;ZbTv$;{@S|9J$FA-n1U!D}P|{yLkRA$bui zfim^)Q)$Id54vA9iM4Z&ku-18%CJ+y8@6SX-fV_KIO-XQTEejqG%W}nvC2s;_}|Y} z;fS!WnInDX;~yjc02OXCdo8#sO z@u>s%@;W8Vo@g*T0Eco%Q%l>%0)3z;EN}qlP`*~Pfa%%TCepR!|!wd%1J@b6>@!mH*n#bF{X+;cYW`K?4@9%XB{VN0jmDhpe zuB(a6wj4WJ5v=X`S0}@5p6>7&LGtsHRbB|=m$|0zF{mKAvjN^U+F2XBWwuU-$vtqP zvO|AyHQpR61s?zcmEwvDkgHf9b1tK*8*(@1H?i^p0q{RR`#6fRu1VjuD=L{K^4B7O zsgAdkvt_F=f-|<6!D#ER#+U5`NJt(4`069Kd7IP9t#NXesu9LbE+-s@HV0O2Sxq{9 zzQh_E)0W=AKs+6fKkw7)YN_#`P^Asbk`1<8)3W{OvslH3!$w^SexXATzGJ0^#Cy^2 z?jyV7=c7FT0ByG>TzQMj<+UhalG5z5XUlAZO>AByQIbrKRCZR!%O|uCkJVmXv|tZC z@Wi9IpU+v=(Xh<3Y=f0(UC6pbFz)Oq9ET%{wvCA5)|o47JWq+uL zl`^ejXk@Qi85Zo-C5}jKPhb)3!m8Tccvu6kj~hJ(IEb56fZ!j_jVz(8v`6Y@!2D?v z-d_IzCd|n_$9Zd2z4~~nh3tVMt_w}vp^8o1i=xMV4^)!kYnzOn(RvSRatnAZE&Q|? zo!6Nhse2&y=VOpDM?_Yx?@AibEJ6rkN3^l&3GeP$kCxy4y7Zz_>o3Bfsr@VUzMTSl%%N zmlr<@F$xduQUx6KCBIh-j+5S=y>Sc>s+L0e1|Om}F9qj$>ePQ#G@sW_xxL+qT6|PmretjJ%kTmZk^YjzEyX z9ELT!G@X&d0Sb1>BWRs`5Ao9tl*7K3bFe*XmUGP$XtZL4FG_XY<~+X(j*9%5CA&_Q zb(1ZFw;IV>Xr&;L6{U~fBA6Oc(fdDCCTQ;HoJ*0FXZ?rx{s-paWO~C%-D> z?xu++mD@UAOn^FM_M(~mGvaW0jI~LKvyZP@da$`?SuYt?Bq)B~($6=>za3UU=nRb5 zQLzK{p~Wi6cN}cEQNDgujg89jQypAfH>*u*3h9kC<`O{CNFwc-pK77szh{4)o`@b3 zrZm9iwO(j0Os{lCr3Y`7IDvd+Iq)+|Wv480OJ3k4lTBoeEk@s3N9!b=Pp zBFtFO?F*_e zz}@JjO1WFNQSq?PKElK7b~&vSFvqgfJGW$Lu_@8t&&SVF!_lp=tDo>u@obDiI0vPD z$SP42)yPtkz^i(wliRcxx*3*E$AJoXSb^4$?Z55P@yX^fc?lYS51kS{X|e&MJ@=+{ zwUH#Tba=I9XIeeSxnipy@?@D<{{T#~6(B2Y5I-FS!-g}*f~J^YgoHxcdGE@TD^`wm zy(GjlLywkP^@DaxJ-H-Em7(ke6@WQ*Q~g3ces|Eq7A21}%i66beKE(jI*Lk;TX9Pw z#U!zV5J_E2s0knp$s-+}(ig|=uBus-kOoCC7BIs+>*`lOPiiEVKYT+laTA3K%sWhe z;MRg2>qP$me!Q9qEO#^s%j(J;Vx*jQPPRtjtrcmYXObWS1PmM#?+DQ}0yzYNei3|h z)trv=d25XXW|X`dATvq3Q|da=EY@1dj!0yZIAUo#f~R5I`)6fA3^nI}pN_Pi-I7mO z8ck+HA_dHQG5jfT)5+CZ=GHpY34NYiWV6&+w)YF%R^ zvCjVW>0rBweA~#JLdRy(a7WLTH>Ap4l-R2?>L7sFk$@%RuP|+QDems>9)Guvy>81g zt#Irs+I}ul@ETs1eZuSd00jFR*_#FV*LT3{Kz++Aiy*>7cp4i0XV4RFo zTK-x!xh=emb<_1SNJq00MHJR0C6)-0RZ=k?z#Y3Fb)cbOUoEy%){u#$xngmKQ~FYb zxACHtG=ijXtz|8ko=LstX(lAbpbhq(*?x(mJ`!P1os6-37Rb9hVNfR_!(} z<~p@{`1lmdG}a@!v$>vnvcicKSBJEqNMu7IpBp|p2(@**Gin}7FK%A-H(WyA;>~Si zkMGm1H>2aXsN`d_9iZtUFhvWjzVMH6EYduLfRcYv{{RQ0C7P_QEN6a{sp5>*hTW4b zu;uyDdB#*tV#K(~*=k!2US9F-yewG^>!#C{2jlWjNwkuy7b7P)+M7u2#9|^qCwx)3 z=Cr9^cqN^uswa-rO3ccisB$}|jDJf+KH>ZCqb!A7G95=8s*e#>C4f*m_oq$tj+4P+#|N);}v_e$BNY;4&aRrY8urKoa*8zW3*k(0hO z?ARd+tN9~Ctpv8SmIk%}s8O~~^<#hLTqV=9A;`$&4muh;99Qnf^X2As2^9U1%nFmR zV^SrAD+rLUk@4rH?Cn(`DIQKa=a@gO2Z_#F`DR>*h#QVn+O-VImF1^2s_hB)qm&_m zWh9u`GkFpAs2@MytF)6SE3D+6N1i!tUX;Vhm04Sn9R0IJC$9y?q_+_RnNV4|SoXx| zJUdurJ?|nr9exhFs$`I@@cV&8XO%{+LsE@^G~zWY->xy2WOT+v$sBf749x587}g*g zWtKKOKi}u6Wl@~t1EBu^imKd65EDnck~5lWQI~15QnUX+v zUTv?9`2_U241I^aHhj6h@MO19(={StIVrysNpfF$MwZ-Fp`HD^6%jj`R!JR|Bt}4W z{y(=<4aBg>3z47G+vhwWdjK{I@ic##bO9@|&MSO}p7V)&{z?u(nE@N<@-IK=$Eb-0~0ycY-``$oU7N zF!J&?Bie`{j^N=~sZoF_HcYjAlBMc51X8N^&sZM@2GUI&k&PcQ=y@9he<#mX#4bMT zn5t-&Gv!LUlP9=nnKP8->;6Z_B&N_&>gX!_&@hSGUY@v zh7u8snr<9!Cfv2Ap_7u1RPHlj8|+pi!jehW)Pb$&0nl0$bkM;`C$99=6Wv;6bn1tf zz9|-L)=Pukw~fd{6@!t2Aok*Qo$NrRWLaaFmNCj)4QtOz$d<)`xfm<3!N8`JT*)8) z22>7tr-d$Ggqx3KE!V4;gOM%RB)2QuuGo1oYqa*HtkEp(WXx3WxG5er(y$xIxR`Zu z_FQ)T>q6qz&f|r7Qm6iId5@Pr`J~*JAjjpaDR3ak*85?Wv38BCcI+d9{i=J&-hevs z)6>f&mprvc@4rvhvn}lY%WlFTG@a>_Lz3FYn^^0~{7W7z12tBW`~BGDQyht=@!Gop zd>wyomz|7KWeLW5R+Luo-J;1S#sJ>2nu-j#89YRf4AI7|Vzlz@y8&XY2|jz3KRekv z>%+{P`M}_gVAVz;*KWg79!KX*OSxMXW`P>&Glo$sCZl{$z~Hv88Xc>+{o)+sP;r z79n+HeLj>qWJ`RA&@_u5@oep8m>SMA|iM^r3qLPD#f29I}*Z(U|u@vb9jbM|}B zGb?~L{67lcyYR1vc%Kh4TE_yzC#bL>9Q#%C^jFn>d&%>-j6QpfROA@Ev=TdVEs8Oeu%=hJb^2HhMQ`kF?jeh-o8-ZV3-%aGr8PEsBb{lf%-o7W|z7N89WxLN| zbQa+b3KP%eim~3QVltTSA8RYXIQ9#Za!-B;Dr0kV%T}zgJD+^C)y$43h9R&QLtaVi zq2kXyrwwVWQ(WNk?fF+9z^-j?ucNc#Qi!eJeSGRR!Enr%DdN>G^6oDm9OO3DpCN+A z+m3oMyiUbGO=5isQp9KoEZZCM2D;3)l1qybJ>U^YMsbmY_1d)V?qRUv=GTSF)7tDc z9ONJPs@cssR|c(whb@WXIEzMmnfw6AKsUccc>ImX#z``mOJ=RPuUD?piqzP}BF4b! zm?<6)j&kdc+ggmBE;j^znXb0mf^iGGpR=^{A~FUIp1lP{{D&jyNATvQHYE*daP;!- z)G?@T%bS;K+dsB9vALHUuP1L6hMoS;1NBozE z@as7#X<)EW*udVUkD8r{TiVD*lUIsc;@qI>8RL|)00zKcJ##bhNaT=(i9%~dV_P2!zO-Vg9@P*a@XX#$ zx>o5+Fjz(gKbfMzI5Vrwx*qi$U^pirOOfSrbY?3Qq2Pjk(=68+kF_+h=>Gs9FeHM1 z9R+QjK-clsjn(jB<=Z!;^QZl65JVMp zgRrG8c(cobM&RO~nEn&TF$xH$5LRg=V9_fmLP#i7?UXYpJ_z2rBZQ4ky^b@rB`w^t zk`ag?=jl!eqNf@Kh%9QA&txO8H)=O*L+=A>fOyulKOI&g8*mTL72?Iv1|+t6bFGB9 zs3e^xvsc}(kCybJNi`s=454RW0U@;R28Pd6n+n+{(rU4hx&%79WKkP=j5R2Xv&$TD zc0*-jMB9NoBCp#RSM6@#Tgma!^hf3DP1|l&=Sz)C8v++Jo08nsMu6?!uN-M?%_FHXw<7UJqTt@RF z#q3&5ziz^9wXJWXol3r5o!fn=OyStLAW4IYe?Cs_jD>p1DaOQLjpE6qsw9gfvIef{ z8T(t?0a%0ct#k?Dj%#*~RMiOt5P8t-Wtz(2FCrmsQNbOu4rvf_zGWV2M6=0q*KxIS zHWMQis~dZ9tY4;bN}>;9Rd8EJ$vtH7?+x%crt{&1WE}0x9+|H9kAG17RmANDwbjOL zv}w{n!5H3~l`_?;a;q4sYa*rm1h%op6B^c8>Do%x5Q5~Z#IVI6{^R@guf(B-cUWcv zr{&K-m2o^iS>m^A$jA;)KsTi86UdfJqqLW;Kr883hko#5GfbrG*#Le#c>MKps;D81 zWe4v-ZBoc)l#ovOJ689v3@$Zbv2r-6#cGo*!?>zQ)*&(y10KXZy7(Xxe}262EK8R7 z2p>P6=U0rq+{=x~9ci=bJ^X%drY|Rkc#|(UNU0UEB|7qBrIlu}CRnE6TF~|{dbtN= zeEbDvkt9_sjIZfVS}WVm(agIaZolHIeqCQ5W+@u{l1GwRjiXvQJ7bY%b$`>F9jx&k zZCoe}KOHA45ZSJR+eVFs)Y!X`n!IpQuq5qBoAe*4R3z4#Q?ld_B(dJVfBjRELdOmK z>rA2ob*2IN(sNygI_Zx)N^#7R7^9Wgn?-dj>)uayx->LTYJuQ&=aiKR+N`jFFP+yE za)x%!G(#j8C5FrrFs>6!uvXa%+z4cI-gXzOBM87?bL~>_tj&h_X)fK{SLBcrCA(KF zES4k(zH1Y+Xd*)rhB6jA(D9@3&>mJQSb>8@jy$iHfSsw`#xk?uBzWxNVus=OVUzXo zy3QvdlAA4A5lFJKK9~02k3BgQn&1kfA1-FICym|xdm^2|*i?~kmtxRiN zo)DoY7{OESdjn!ZqD^RP@$=S&yDXy;w!@_ktff^M6qJRHa6;&8oo}Xuj!SuDY5s3i`zj}>Jq8l5 zV^)$vsNd4$Fi=SRlhX+7R|B4DV+me-f!n<#Ni-Gb?XZ?REky+IPcR50c|Q_s@Uu8_Lzh)w2+Bx6ZR5F@$uGmz@26Lt-;$o_NJ{Nwg9!y`-49^V_3BX zixJG-j*~)R5jg&2>8FUzVO^iHav#T&+ppiFg)tyk!Ov>0mXNlB;0|3gNwQYOJ1q5(|ai{N8YPTBBCGR@!OIVio9``isK~1-?!mJZ zlH%!QVd+7!_opqUNFkCX-I#At>%i3R(~iJ-Qf5=z3GJFl#Dm`0(OBJM-of*}hGKSN zQT=)4OhF_h6Y3v6RC(zkj#*4EC+V!qE{RRUjgWh0R%HOnYq3-Pmw$oOs*r&;z~*}z z6G<3hxmD$le&1SIqlo=2RCQC8E`%%ATniA-PWcMqE`S{mFU%Qb?9a*8bnCR-5 z03N$kk;VA_cQcj8!)f&mKgUP5Q@{808EZ+OBIUEbDvN!n1+`*UTLwAQ)@Nh z;gRFT{Elt2B4maO2vSJVdsRtL+w-CDThIRhC>|I%lW^`CWjw()1P#gG=e2%?;a~nv zd{%fMy5g6STfq1yQNRoJsyXYw2;e+(ncd=kkz^k`9V^*sISz6e+30%>EFZ(@V}*^h zzoLlBWso1anRVcG_0I=!ZY9PzLvao%50YeWKqC!<%Obu{{YKy%Gs3l9G%(pD6oXO?2e!tK8oqjxS#xW9yz>JRPVr%)`;>)?X&xkL$tS|S|T(RH#fICqunM`If0R|*i zf}v-Ur?Fw>7AKSWMWzaN?;@xq5_PX5qZW=e0t~S4n&%@)UBSF05Jtn5eZ-cd3sE$o zGY}V+)kZ5E&>@h>lE4>U;<~rbpY!q2_;bR}%GeVP@m}K*%Ic;)M%TFM{wR6Z9ggG> z#$y6Z^=qWfy~J$7V30)75w?~{m5C?+054Kwjyte%yAD~~=S#ekPs0zE+rmfGRBdA9 zSebb(0ai;=(aEybsz4#)%E27e5S3T$6Id*u>;9gz$ym}&E2-7+KD*Y^sJQYZECfu0 zC;Zl!99YNLtpgdP%w{Kf^0p!Q*IP4;j>iEgP z1DW@%xK`rn{J6G{nQlN2^Gx1JcPtu`DoGYb$&2*ZN{8Mu)stT9 zx%)~LUT3ha8#rV07%9MC?kDo5{Z0{|bh`uM($8Q zQxxJikGa3O@IPVJlxs99B4UT-S<^H(B+USP>fcbu;a_B8zRz~Yd^5yklF|zLG8IQ@ z1b*CLs+R5qf1mNxP3Izaw$rp4=3Gc}g&Cf%BH4{|mICscLp zwN;Uh`%HU3cb$%fD@S&U_Sn{=fBDTZ51D5?Nuy;vvrbI* zR<&TQK8Wie<}RUw4JCI)=M>~*`eNL=ih2* zQOskh=XpIZ)(*?JV7ZPFDReUApbKEHVC_!RreU$ts`kEoi0y?ZBip?^VYz!qT^BPb z(sSCHd3HJMqvLB>czE_C1tP3Yg6zwp(n~oV^2+Q;`?uSze4X^WFWTqzAN)n@j&$m* z96Ho8BV9=b!o%2uYS$^(i z0kOAgkT6xCifKmF}=9yK#fD%u0^ZfUL&5~Zt5dABN# zVs!vGPy#RVQ0S;T@gu1D*T~<;Sz17gZeZ4^%O(!TG^2~fT+H7Uq9wQ8C8_t^A%Rca z?Y$N!eX5}BpB{SjF}(6@Hlf^3wvcP))b`0qjKN0@I~NUBgaH)@Q{J{mf?CG5vB==> z?(^28NfodNNCva5gqLb$lw{F+SK8$e_{%_4n1q@_NU{K5F-fttX8e49+vubhO>rEG zh{l-;%r>U>K3LvbkV@K)NmNL8;Zc71N%67vFN3cH`0F;@fZ-3rPhBglTE{wh%?aeD zH!Sf{h=Cm%>D@a*z7d)BBZS!BlEnNFI@4&9e@5DUt3EjdOQ?ch-m@nknySq{FBy-g z94fLFLM~d~fNs`3$XK}e2dW!Kw;`b(nxVN@KYHg@c?w|UvpJQVn%OLrZyD?gWH9Ki z)PzXSfVv%L-b*(k$K-qKY8(=10?&5PHq;%k2qo?BDPZbBrL`=n@Vpa3%y+}d;b z2hUd4!pmEesgGI#hB&W0xW*7K>S;B$BLZI19Y&M>w>$0ufc~W&CbtZz4n0|>mLR(w z_op2U{r9feYg0n>X5Z<3=u!UwMJfaB2pEp_MsLTEPg*fvO>y@Z8^S@ywIeN}$0wSH ze{R2xBUzF{EX6W=bGt?EB8H7SqHf^GzI?ik{(SVTu93uHW7e8f^A2z`N_Ho?E;cx- zvH>Jf$vd$D{?EOFu^-fA{r>=S(M*o;1Ox6zQ_iJOML8}ybfVZSg!M8pTZHP&B(-*x zm1{4L<`+|-NgLO}+22&Rm6foE(mGIYBNHb13Mt!@&SaTjmMhYPy*IlY_2g(-EPzCj z$U*p5J5iuD)017@Ck-;pC@%jr#wSdNs_LX6BLw%*c~4860usYTJ# zGLMts^_ON!bK>(;d8T~Uc22Lte_A1a-}`@u@HM$kz*=~g6;l<>+|!SbR10B@eP zrfW)*i1z?O^QZ47wqe} zpXM^UM6aXyBoF&(A?AOEwza3ZCQ>ra{xhvY1dy z5F0+^P|MKV{vb$Yu}YB? z<}DvzV=qqK z;~ZHUvPP%cibqnRqjpdO9dDqsM>%Mi1#mV!N6x+WYnvT60Fwzmq0CY?KQk4Jk=e=Q z%<%EgWAymRSs85Qu0+L*Ob>aEq7XKG0(x8R4zq@qOsM2J?@pU|+7d0JB6$Zs!{<+= zhyDM~+Q}-uJ(bw-)LvxLsuEGuxMZ)=OHwg~WQZ zq@H~DrTha+3^Z8Ul1Zklvt0*7PVv-12$Q{u3tk`>Tk-ySh(nzv^s*jAblaKvRc2V_ zcbYOw2EYy8gyc4(w~DP*rdE#DTOA9CT)2%QnA(@UdxLj&Ryx3L&~B3Uzr z&*$2v)=9kJ6q62lbf#un{!ass#^0JN2>5&IC8BI4%C>QqWA@}#ay`V5mhJ<)L&mxb z$kF}z-);W1+);@qcnKI_bkm7)%?->Ah>_!?YIr7D70W3}Bt{7?!5?qO$oT49hQpxy zw&rPnC>vLekKe0u>qKhJEo%78*^MNXaydz{wb+SXdGfbrv2x^*AC@tdPfMN_EhNDhqcdlAO>}EHU!`0E!X2Yo=R!aivh~dYWg9eoEbkwG8b}Otfb#@Jhbw*NyFaIJA~I0PQlx z7kdvMBdqBxH8?AcM|}4FcCA$6rV-^$lc!<)??;mEdXiO-A!0f2))G~&pvY-GH(=<9 z8M~u#DA*;Ax6?7i*5^^#nD6iJR-WQJ)L7W) zBef&({Y+bbZuAF5S7Vadz}m5{E!12rQ8r`PoS)W^F3_J6S!^$4RtvnQC==VaEuu-5 zdjzw*%I)XlM4b+*AsREB@5-Bb#H_6u$Ww|-p^t|vjDxys30{xup}7Tlo*3TEe&(*E zI}q%{K!1`yJuI*p^1z)6rmZZiX><}r)Xp~RO^lscsmD&XT>yslNn)gtGLszdY88#< zRQu4zyWYNkZi5_c%1n7=;CHP!rSp`?r%7Cc+KNw&kw1!4D$P8Sh-sLi_swvP+VdXJ zSpyFnIs@eV9)!@WVS^3x`qhKZLb7Qw_4lN!Xvosro$A;%Dc4kLii;~GB_fa*6lkm? z{{R)fNZI^#K^pGMoC;&@1CSieH=v4Kjfg2QTCrHjxRJ@^)kIj?PE8cBbq~l> zgA&j7nkB%A?KJ~u3nEp@fC zU1OS5jxVr>EB^pkJ3YPoB#?AE(la!JQ}f2vkkF|K@>MovO3?rO9|+L!3*K^wx$?6~!dR#RT-TbH_E=vQCn{ z>oEwGBNe4OD7x6_yd5tgwDPZ8GO6DK0N2g@ZhSj&#f-igjK^g7IPKn%7b#vuHrf0c zYCvLT(g7S`DP;rT0(brY04J?>aWM^$7x&~VmxW|Vm<{rJ_N2>vmH{laE!2+0O7nY6 z2}tFHX_cfPc^$)Rug^hw%4HuKa(Ph&nQ^F`5PDI0GBC>n-GwG0%ti>oA4n<)PPr|ZJud;2|i?;os=dXHRGNe3!w#>~|OCW~IN z9av|OyTe*(y}Cj}vK+w!FL@sYN$YOj=3@{^G3WCqm(IR>b7f*BZzV{z)^vh8bfUwT zUaLP~nPsm`N^VBeD~qoa5Z>H{Oo2l%BS&3F+9X%?;PfBoY6;=!K?qn3W3>U0fDjnYEtgXScbYQNBU|qv}stblFKxu+`Ao+6#h5x zI+cmakTO)%nNfw%`}W`TldGutjkvp2a&- zJl3NTRPP;;KqCMsCH6Emt$g%7Os7yIB-fFKuPRgkV{_7ve-vu)33An%DHdp=0U{Gf z&noE1J+%8fo5^B4b!>+@Z044d2}Ff#pYG2wov7FAXw{Zl)#}M=(Fr$ts+0>mDN*lu zVA1P(-;H%$DoIT2D$FJ@8JrQI<8HX3c}!q<Vkd27|RwY6eAJx5Y z{Pam0Sk+lh#ESNaE`dT9P+Z{Dy-$*HsG(Y!=rSb*3rmcqZWCt9y@6IwZc24pMr2=YeU?^Gp_OBekKNtU za>mZ2C<7n0YTDih(-Zt=wa4<=R;8LxZclhpXmQZjn?W4_RE)4)ltoX;**of%_h;cJ z01w`cHHi*C^ymKo6mq9N%wiwdx)N#AwQNCT2|H3xZa{msRr{5<;1kv4nWJojsT=p8 zNy9wW^CK9b^WUJlCClK0SCGLJ*J+Sc%JUZYeY93pR8h10cJE+OGjD{zU7r z*c@~J0GedBO1+5VmKHB^GBlm9_rjlalQ=*}aV1y?3Ld^{COvtqeb}E=gjBzh7LKej5*(8npp@wB6TNuXkM3C1{#is=|j zW7BMk=Wje)z<5Mt$uXAYc024&J*xTo7xdxHxYlzLG901nR=+COlC?Sy{60w~E$pLD zZJr1Zx7zE+`kI>H;a687laB2y25t+o=yx9EZdN`b2B*?MN!oJQhWFSR? zI`mrG24)9cp#J?YbK-6fYXdc`sLnH>0n~EI?M_Ju+ z=OV!6xRS0T9VT}fkH5gw zSC#u_m`qG?OAOkNImBNn2~TVCLI>qw*2r;21twAx`Ux33qn9R!u)k&a zbw(VSv!rv!wRvsiXowm$gT74`&2!6Qt-El-=+RGK+KxZxMLaS}D+r_s+61u@WMln@ zP|Qft(DMHPnrhnC7Lj%5Bw+TY+j6X?N;g>~WxDlH!6L>K6@|uxVE#MMYhd}`QW=pn zlZ>7Bsg~B=E;Xh=_M*?4$y)a8$ny($`Q}Sd$m~e>6-&h7qtvy_0kC{*>~&lQbyJ3q zmpVKlt;sHOr^5WwsAy` zNoJ9efX0)G7KQ@z$rQN>r7dUQTNUIpG;z3*OW94bDMe5sJ~EwZvD2!Hl%AwEteU$1P@<88G~tL+QWikPN0Y9XA~@M(*yQdz z8iq35dYqLX+uoR&94=Pf_+*O7tA&ZGN+?$TS)RcqrII;UYQdAk4fp{5-BL%mS^X-y zIsX9F8Yy5%nkjM^gTK8CQcC=9klV9nh4Wo26scY(j>qv;l46p|z#naYOXQC26XW;m z){+KxX#rdvv+}69ib-r^SQr+~IH>x+g}a!uipEPRSp_=STa&^Sg~=sFn!U6nh@%Uz zJPi@mh3sZRBW_*(bO|_2%L-20Fc~795@qsJW~kIuo+Pev2HF_H*La#S`e}B#S78M8JbGAW)ay6W8ZIl zj-;Q3U!IFIwYr}y3>O&y^rG8Fw^;i@AyzH3$e;5}UT+>zEK8N4C$&DA_H-NhToU9$tdbd~cuv2|^V|~0_a5wuBrNQK!P`4_vD-F* zAf$Uf6nF<(=u*C}RDUc&Lg#Zqd&q#ZA(SrBLce2o6XDp6>;ifiBJou{^zo1u;ifD|OrJO4CvsU4q>^gYNZjz(Z?)VX48!N8$zg&rLG4V*Ydb(^ zLCT$jEe#s+!tga~5F`=HP0^A?bzMAUcx51fPvCs@s}0ePFaC9k*6na}f~P%2AmwwG z8WPO#h#JzzByTx0q;amnO&x?!jgNwQ3*Rv~W&V9?@$l3wr&m7In-+FAi*Z9MJQA~_ zXgZWCSUb!VunVGp+p3vbL8&(SpU$uLEP6{4l`8so!ue;Zcdk;x#}BGOj_c{;1={yQ~&VSZcWUd2Csl37u59`x}3ab&JCMX=!@CQUbCwV}9NF zS4G7<3&bqr^JHbg8QAan)o!;gP~W+XYRa~&PSLoCgCri#nq^r5(Ff#yN&f(Dx?^zV zvZ^$4`PVDufC@7b%eTHoVI-0OStMC4+DMV=NT7yD*-ERGU{*pDu^T(rUREK)av7mW zcNxhPr4Dx08_}!6T?vp)4`>N0==UdX`%|$(`Sa6aa3lviP-TP87*mF)C47|9g`F(P zMkCoXS?&8?MLp7}K%g?ltUli!dNWL1hF!2}7Pm2>Nj+aJ>lQg=lU$CPPa4&dQj@rj zHhWMb7Lc;K7E(|2pdfhh`*mmHP8?_hMG7tz6i3g=85<5?jS#FQ%2=3VF1tKGv?^|{}F81?l1TxHVvVu-J^34;oR!Ubc zW3o|Lr*f-8W=k}sg2agMvZ0b0(R9QOXb?*E5l?G6d9eDEo@$p?SJrczTfkzSNZ8~b z&X7IvM#dswsdIde@=f_ewTVB z##al;KA-Vi_b-XRZ!3@Ia^~too5w-j30P?^TN#tb)`Algb_Ut+13^aj^M4t5D~@=U z;7eFU79&wVwGua!saeR<2FE|m>Q3OCN*+PT>T!#!XhhE> zlTD9MZ^up;C7SHDlkSa}H@2i7`bqKg)LXPFrb$TvE_UzlTb>W#FXF!u!Qu57j!oII z(0=vM`WZYu3Xo#!O_7eJDs?DTsU>zl@>L;Jia8cP<&MaQYitv-`;pSqK#u-@HanB~ z0lj|m;g;us*hRuEoxF>6R0@7oVf{DcJQIrgf9lsR%FLrR#CS}+pN`^kk(eZF_{_E? z@k>ReMv`^4Z~1a)f|1Hd-<2OM{-ie^ekEnX?2LlOa57Z$BL{zD?L_03aaa|(w_p)a z83y3<{{R)vkEm~}D!!s-@)Wq$3vp*TrfU92GhUJ#EsT#TdVG+G zZnSy=IjVk|Fyt_n6^bHNOebLLdq1ALrMGtUmdG6bee3bF$rgPoRg{yGcc#vF7~<_y zjg6v$85y(dN=WL!k-IOnlFur#L3+cc)f4aS2lgE=dlE$=2o6Z*2VqazO$oDebU}VV zpURLf*huD%%@(_dzRuCZdQWw`dzn*GO&|+pdxo{~)h?YOI!SmyK>Kb{(Iwnv2kVYG! z1E1qd3s@q!%m+4-j5?lc^rCp&ja-K@8GZi%^}0?sUn6b@jOnlACY}lrOTRtvTDn)y z{z28@kPaZSt_8VFu=nXjk_WfprXkhvzbw>$Q<_>iyse6r(&g5c99}NBV>f%5Oma)O z*pu!`$6woBBA2Z5xrucg;eEa9T22@vhBa~)Ff!TY@~J+Wt`1zLsk0&zdV0C)*Zvmc*I+`mBV8vBy3oZ+Kp0& z*_6j-!LxMVOIZsF36kV~*l*J){;1YI{{RTS0Qu^+xetlKFP?Slw}{#VfawH}tsAv+ zo$CTQEm~U0I}(O6832z?dzQt-T^A?iLGjZv%4D0>tIAP;2TE4n7K%J+UpJvN)#Z_< zGNR3b<7UL}zx|aj>TW!H*$*l-tP3#%@IF6omY(Fmr^tzk?@n35Ze=NTC{J!enAm?+ zv-o>GnHo@At|Z*`)}mCNFsiCdI#70?{(r{0LLMzNa(R=lqn7=@g0$!07ILPTU+;08 z`_}x2F;OiqFXQUMjnzfWu3a#8ynJ1 z!tSK=AW{u0Kck17NnQI^)kti>SG&Slf^ZpodsGdv(dk&&fjN(0Mt-K4l?3uxq`B4` z1fObC^z1{&IQ_0eVl@UzvrQZK5BxM!9eA|f0hVRnwl}_yVHB3O2H=lb; z%7>LOt#TU|Zmru;t+^j0GFD>%l(NcKP`aX!v8n(yzL1t?oQa%fLC&=!wh<_|2Tlev z^ryT}K2FUHmFtgcwV4ujqgDp3jHMGSkt|j_8{CnXeWU*XGVi8srD-J%Fg`%(Nl)Zj zBdxH^lGW8|~r)s;Q@DoUjbD#uo8jWc^BlFDOLAbIk2)g*&m-;usp=j0Ci z)!?~9Xm0cBQH*rYU#&^DhZ*B^>)FoOYS4B8PZw8a<(|{N?#T4OG4fcDJpA>z&0}Pm zF7{5C=4%2Uw%*E3x{}%4ol=N(L?qvS=s*p@q+J5 z9HeQ7!T$h$vpAiyD+{Y)1ZU!2#<~s@c+;q~x!zUed*YKg-J-cr?oV>yvby_Dmqi^4 zg&_ba-UmXqb^A)l4#7<|a*ohR80VUg^VXVK?%Z?kUdmpXj`n0qkT^snY!2Bqp|6m= zWLy?$nq}TrW3@jDAdMpgfTMcF3^q>ssI6K+2s@s2Xdw%r#QTgFXS3iB$m+3MT-=-m zBPZ~qPjzho$zh+JJR{9P#qfQ#yaopX=vVE0fMkxeU_6NNpq3-kHPLH0Ql$$$$ zDm~@uaap#=Z>BQbZ4&Yyy+!lOB=gHO@;sAB#pRMlEYdq@mU6_gW4}FRpz|9Ja@DNC zLgFEXr93`Q_;r9J`>M`TJ4^c}$PzBUrJ^T$1AmXn>5Dnq8A>ra38Bvvb4Cc}DN?kN zQweF@#rlV6x7w0^p1P18RgdT1b|5c}Xr87mI`wA)re$ek(l-=J#&;;z5$n$NBZc=~ zydhB|L*yBiL0M&ecQ*Ds_|Wmtrj}%v&~SaKBZ459kZ+7q{yUdW%pl2GmbY@B`P!hY zUgrMMJxIr31h5jm0cSijLF!lGzf)KWRzg3oLFtGcrhg zA@lk8CtWcbMC@Kul{BMXNf)9pb&73dX-aIRTx@S@8q(N_eiU}eFm{$kBd{*cym;~X z=}VY3z9Kn~^rzyG49_w3u%mEb5FK2SS*8k6qlbNo^RDLrfINKkM6*V~Jm_*=whobu zP+4p$(upoW8`%u2DBpRAk_sPi6$5NP+p6DLydX&(O}zedX_Mig@ln#dG>0c>AYW}% z$s;37T$LcOM+IY59)AOU3eqSfZC7UtMY(AeLc{TV>P^M*N;yc!=^4#+C0%P=A-^g$chY=xwC1-$-Pk+#?6m&ek%>_2 z+j!$eZq{uN#=3^#Ff2zvB1)`$W^npv3S;)DqU}6}Ce^GZ^=M|;l>zST5}-cZ(J=Aa z@r@0Bdgomwic=h*fDYBS6tPGacu(D)btLh^mSXKl%FS~gqrH_%#_^R$AP`Q=ug}k0 zmhK)Q9(E#-;wVJJMsd!W`TDZrWv_Yx?$w6EOv;;&up@<-IV0hZ!6#e(!=z?UE(aiwwezeeI+d-G8akiv6R1j{~bqJl6~%jBIul zHql1{myn&i)At#Q#c{>29zi0>XC{SQM?0Z{f+$roPRzgZ@S)a=4YBwgJ#P#)pd^Db zewB}Pb9a2l%JjevK6GvJ{HFDhUmY_^Tu2eD)~agJSjvvo6G{WftH0YvrXFjxlT1f! z^q{r9gmCM)8lT@Z;)@r`J3~okmB-Oz8@0&x0Sr-PQP_L~{lD9zGToKH!*}UQEY^hS zNc1mL&X;M_vJoY)^JAQ;clPcq@riapR828u?a>?aug6mybAUBuH_vK@WS9N289}UB zf}D=Eth#=pZ@QL7Ah1m3NFLO&f)Sg>{OkPna34`t!k+a{Dn>e#og<|q*u*5#v^Fh8 z6zrX=?{*{^B#aMgDVak_9HIUg9fCac384Y>o#Z1q?@ftjSjxVpx7%;-AB`aKEoC+;_hObhVw8xGgMz7gL&U{{CYGe`d;tz#Zso zP@Hs@EGiglWnoNYFvL{=k)~COau5>6SXl3&0Apv7l=&{;3=Wh-<}yYA4AFv4epKsT zNnag&$0TyvSd6&JHL6A)#i`3gCzu_uNMTR|F&aPZ)4qBnniD!mz#h~Ffu@ag;6_O- zbMH$g%()BLgtLu{8i;Z?;eqCkc_*Innze11AFmx&b^ddT@ww*)1; ztrHj3>B;S1K06|AMQ%wVHDXyAs~IGDR&yV+Bp)iQoy3pZzv|DjAWL+lt?}}|x$Xl<)Z{gk<=DC2%M-4)eyq@)WWmm4tE{QCt+HHJvn)$17?;$#E;;FhByH+1s;8en_r2v4+M)TE)fRo7Re~ zu~%hXlA*Qv1o$02GRfwG*bpese4f>xYZ^rmSb;OM5x+s6we~uGO_sWBQ=wKkY({l! zCt%d5AwrC?M5Hvm@u4Ap(mec+o~o}qlKMxt%8xQx#}=92?|Gk{F63dqD;m<*q>+emNw^vx5Hss)9C-Gb#rDoqgY*?bNuL*;Zfz?Y?PyK^~Tv79?y3??Jv*ip;z1?;78qJ3SGjkt7)(c1N`#41|eG48Bj#6@!_Lp_YxbFC>{6 zDbtfcr?S=*MzWLPUuRw3Itg&359}ut)7l4vHu+s6IQOlw*!olExi%1^{*s~Trwkht)xEq%_L^9*3EHh zlPg%a+%DFo479&fJmsZnP5U#*(?uINK%gk`v-#NPgs2Xg)<*gMeL7Js?u1Mt$uQ&d zsI6ek(8^M;Y7(tYtlEX%1SutOj4Z(SVOt-mrEA}tvju}bpc zs?o2J%SQ|ThCEdHEJTxNF-(o>l%%C2Gk;xEF+;Eph&_5}u^z6JD|uSz2=j>){*>9N zLd}$uDScY5E-6Igy?wKBhKzM;#1u0^%sW~@PQTxM07aw;2IDl;(hxk9$D8|7Wy*rr zxn|A5PSn{lg7y%x<6_App4}g4i;*B{aisW(~eW`?y#-UeoIg!0N@R*#2NQ&}D zG`U74M5(C1POJ*VBv$Pa!Wp($_U}il)rMAxfzwVoM={myZMw4B!iV~dx z`h+?WFbBC+$NO|4YFj$rNF$-I8-Uv?lD{pn){V&|@>fc*#sR4sez+BI##T|T(h2aS zljL=8#AkcsE_KP)tSYonutKpY(vM~st|ATa0e zLQgq{C~`5tuv@lTI0Q_wH)m&MmLQT$Vj>jz{rU@tajb4XN|cvNXas|feJF*P?88*c zC3lQDjMZ{uDzS*!X9Kgi_7p4q&c{+!H2IC4KQ4dwQlzkD2P_V0B3ark9j?5{qql!< z$ss&8f!Fsv0i;lH0T~}!Dy*x3*yZ)j2_TLL>on3b#d1H?R}nJ1dwtHV7y?Q#Do(n9 zx>r#-Zq)>;jZK^$e>yMv!mt|Pc0^@njr@rSByVJGe4QWgI*?gLmiSKfxbE7t1S~37DpIu55>*7< z-R&bP9i1HykDi=KB!v1$Iznr8xLirN2Q=}g%bv`pB$gTEO4B+ZuA1qISNZ|2;@YSiawpfNOXHb`zOfXo|xA9 zMygZGhB9Mu=>oJ@iDI21u`G8MX{tkQV-mEoqjuXRSG12WpvUkx`09%$`nM*mAo57S zA7^Sy8PCy4R;|F%p5-w!jl#e`W4p9^1&JfZ{{TPl)H}9yIoSSHVE|^=20zUy*9hv< zO*Pex8p}sjSeRg#p7xRD9zC{C>0gn)tTGfZJ5?G$%jE?gVcUA%hGtv#9!l22!sq$X zN=%HkdyH(qcp8*w==|vQ0#JJOtIKR5k70xDTi8oAC2P2+mc+3$Mf^S{GkcPK+h)@q z?!{ttza3qe#x?Dq-hm7Gs9}|K#AxL#+!rfPR%xCK5=0h%ZG>+Eu-t@bu}v`3eEjuL z%W=%)ik6AskTf|lW0y)bdkU8;#A-423@H7we>F=$2@)%S3agNJ{{U_DMAA-&0RGew zGY8HHB=+e|SvzKp=&YX9(aLQ{B$7z1_5Nl+NDj_)N+=`^bR|_J#vB3#X)y>yo0L60 z%{Ar8**BxR?#UxYRXdmk_EI8ZuiQ?VOAYuxzxL@k?g~gt9O9iQ+5jWcN7kSCeqSC^ z2gBS>&w3__trF76T3G-PfT}xzBq%-){=Ghu!E~en>K!XK>e@)hn2f_4)OX41O^C_K z7icvL%U+`oD*n^|05xLnb|9c{CtiBZnO$50eLtmY*-sqs?c}hmaxD|s)x<`;6 z(8O$yjq9Z^tz(bxBF~oHxmNYH!%r9h#6Ua0N8VK!q-?%O&m>OTm21YR7|5ELH1h1a z9hO&a&`$gfc>C{{fPek zXs-g5*`$tOBPa9cS#t4I3;;rO^Ev+jj&zQgoA~LSWSVPG&*2`rNa0+KpC7RO{CU@& zmTnz2vmY`*%1t(x&`dHFmo%Y`;<;?SnLkI3$=ea2nWR)`BMywcr@g>Z06zov=(gM@ z;&}v-0%H^j_=UUxGe6TmsHaVS3zv?;Z}?j?Zrp@3*bOm`7IHkoP@;kNc-cQ4Y9|aU zC?s~4dt+q}n|C<-G3X9+S1W`1ACiL{W#wPOzgccYc`DY& zL2?SwkO5X#h?66D0FROW{dX3=7n!ZJ-|6!K`U?5w{{W0E9*j`I zK$fJVMAu|ll`T@IP8j1|F_JWjyAVKLRb?leAR_EpTtev$wZkhR$HTThwcYUl01EJndxg5U3>*$! zHvQ@ePDLFgimaDqHD!`}z|Ce?K|R2~tDH(3OFgGhUG=sfJ#((n#uPZpkzG%h=6&Mt zr`xqhd5(7?Uaf`2iEYDay@+l>DPL-7r0kA46sotg5~*XNK0iC(KxK?$MlqiB^j7Zb zXLlknISP>?wT;14uPv()$&j2&?pr^3dAkgmyW4#TAz#&dY&FHlBcvuCpHdvh|Zy~YURAy}bEl?U8+=VPcIYYc;oRpz$K2Gn$o zu}M>Uh_DKkav2q{EB>J@GbuC4*9KL_LX9EFqAPI5n=TC&=!GkI!5 zW1cEJ&+^yEUa2))jo|3gBSmtJJ9Sg8{eLb6z%^2;Q9VkyF(O8 zV13CnTIyHGT@pe3cQJabu+1o|6tc*RDOPBuO6tJZoBZKM|k%nl%f+CfCrPmjdXZr4%m3e6s)kS?Hl?- zbgN&2^i$AC_+=dTB<0zRlh1{xm93Db)#6+=ne`?>Neoj;4{ee{#>6`!??83&UlVxu z#g@EL_)iD4A%VaGy^u;e(5DTfwD}F6_^89Zf zVFoh&=++4`k(%i8PSu#Q$EgVbY=iQ8`p*Lwvv8Ov;;D<)9<}U$3i$^ZejYfa*1SBC zWR5};)DL=W#fCYXiqJvdudUs5^EyxXtbYLFl;)0mnON1!Mu`+j?pdqsu?PPE z5Bh!CJZO>t>cU9_0VF}!{{Ugj-m{BH(Kxkj47u2XHXNy5=On?V4-p1a5iRVNKF#q% zD7I3f$wq17l`*o{L;;zTyKH>lQg9 zkdi$hJ{LK!qmF4Sl!i&76lFE$q1=Gjtg2o%WMD*T?42l6@K2vTT}eU}Hbcyx&oxOk zd8h3mLovX|T5091P_I^NG~pKLWg-c0P2KuAO#c9M#J6)Kh^Pl{z&#G)B@t?g!Hv28 znW8}^x&FyG(I6RIb^idGjv?W((#%@DEYl?mX)UoLfLJtuFiOawd#s|4?E|njN5@LT zV{a4cVn{g0zx-C5m$JM<5TU10gYy3XH0*v|kib`s=61V13W~naE=F4p2Gs-8oD4{|3_k?N`LzpJxnv?)4eDgod2 z9a&d9nz?N&o^+`VXwSOR&jg4A7~UDtV-lc4-u>hLkPesfHPO}<0J7(LU7X0JF^Vl# zL>41?B%0KUz(-u{>L3d!9mnlN`5FLqL`9BGS;4B}cU4&!nAB`D+P9i0`=bKlwbh-> zlB>HSL;nCmCxz`Xvwr?})Vz+zAQGa99)>1BaC6FzRIw!~7)KAkd6j31TLcveACV@V ziXQma=gRM^fWVMObIPZZk`52GKdJL;QDd7GH|qZYTUH5Rqjr^umP)o}CTSw%OS+14fC}lnT6{yF=Gs5#T3lP+>9%EoMtp!R7{gmm-m1I54woFd%HJ`j} zd(6jPKclpMI`q-V(P>TFAI#BGRA+e!+Zf)H=~j}gV$H<=0LF?a=OJ2Gfo7R|rcWV} z{{WayybX@1siHi}zQ5tGNX;Z*DHsHM?N@h+`n~Cv7XeD&9Bj5fF^a)v={i`$RZ5vT zA)wVDu_fr{3k-8_xDXf#82CFqa`!OtJKLs)^+Q`66#!=g2c3Pxg?OXF+!KUBFA6$1 z(#N?9H*9hys?RF*3nk3*RdYGXMa*nbysZ*;ypvt05lzcKVo2=G+6X5^Z$qtXTUD@! zOd~1F9XfQcm~rkkf8smieZ)u-=WGmYDm%x(nM;DsLA!3uXG;M=La|Pzpqe!!N|1Q~ z58Hh?5Ph2io_Fb3cM=6wlQ>{;^QVp%0hF%1q?%k0`V;NWexJv<` zeh1G_vp~Lp*zfbC!%Eq0Bm#D(CnCY&e~9BVbRk&V4|g9vP8d=+>q|gdgqcKYI|Ib} zEvl?e(j$2EXEEx`r^fe@8tcg@K)34~Gl2*q+incD= zwK+x-HoLnkz-0h2IskR0DKfT_N0+Bi{3{~O3P59An}AMpNSMd)l-2Pt7T&p~`c=fD} zY3f^VdnZKN+<&fLAlT5d<`winYC{RcAU`1Q&yWKsHzYGkw1FpM#hOicqF zc4?e|ra+q>cd^zBUht{3h$1%-7XJX)t}u2u#eTED@eJM?b%T%bc-s6RW2F&}fA#e8 zs$=VK)W1CE_zM`WJ4-Qx;uX|OV%t}9l+MKq_eUmH{79=;6&tT|Q-}F#$_o}{1P0;@ zsY$tLw8a?o=X?#hf5m)n#h=vH7E$Tsq@t4T-VC@$)4*(1)N9RpTex4wO-a946RE@ zG1lnKZaXq52~vzKW}fV!0y@ehSC7a2W3Gjz1~OEgM1Rk2wZRmBv^BOhF@MXgN4V+i zqa4M%l0+{_C5&AY6qY24n=&LHr>!#x+aY2kR*~3u=zvd2!3*Qem9+1$>+-EzqZ+Wb zf;Av6?c2ZBl`V3rFhtckHZIM0CM{kWB}K53_(vvIh_HrVY=idiS{vzyxQzf8U<4-v z<=^Q_3pumq*vaNFJFa&5ew5nDQl5fCYZG)|!O0@o#!MYvS~froD=e$G`+)du5xsP~ z$1?roZLZXEJw-VaCCMUG@^3NpG|w3!O*tLt2VuENJ5`XeKHJ;4jL?9<4&=KN?0DB% za>yf{oFBU@fllZXtTyknhTrK-?mp>?viUbXmQ{$T?7>7b&Daf>*&g3vZs2_R9a70f zzu#w{D%0Yi&!*BoU~f})6D3ZyT&{NP>H2MyhRc^LDypkXKj++*+~ax!ZLNGBw(aFa zCgJb~F`DJCt>gtPZO1-fRDY563TwQIwl^M<(4l5}>6EhZhRHl`54&2wwf_LvsWfDF zXm^qM8tXn@Bb&vBFNC({wbO(~mWE09bzOnvG4rWMGt6-OMyw5O=8|b33tJIc1arK9 z5wo8wUOyvje3QQ)j<&9@;IxZej6BZV{r9eV--q3C8A=V59(kyPA?nX6$k>oktxB{* zYYlN!+>sN={$%I082~!?9dpw0oB10NqWMSKx;VH6j6fNgjNNG=&p73A%8iFyq;M~L zq%>lRKzFjS$c~bkK|Vr)dIGNyx*5@_I{{6|I7Nv9NwhiTlZqvc8C-RX7^cBwWLqr$ z_BP5q6IW(V$;!0xpqds!PjBF_k3M>|W=Mgyc+L+)xA0ZujwzLIRzxa;Vy8LwqX|aM zC^Fdj{qET))`F|ZR6NyE&4Uy6Z$+byPT&6kXU|g%PLazTyK2TeVusSJ?G40u^pl^h zDnp&1xUbOL!Ov$IL1PS)P9ioXs#SZ$FzFpZJ3ny%kKd-g)K&o-jK((hr45X~X=##8 z6FU<_R;$BvGAHnhO@Pak-{@@r04}tJPu(B$Nm8&9lQ61nlDRV}}djdgdK zA)RlZbE6pdq$9QaL@#8ZE+o&=m~%~=&}gnJbq%6xxt`#UmCrgx@}dVJ=;v`s_KLEbz@QlVp&`jiOAp2jgGBjVI;~WRrM1~ zaVxu9e@w0w0Pj{Jm!`Kd(f+d`m92JQG%@xoO4OnyeglFSx}P5!-(6L|29*=dX9c>C z`L0ZH`#GfY;fU$a#rxA+KY_xsNSE-{mOYgd)$pQD-F>XRtfymsck%vu&bYBu)Mi|v z>MLH>9#N}LI)|_n|s2(HeJJN*qPrh7s zx3;D@0|g>5JQ*YdL74tOVtTP)8y@vtEWl|#kwl5m&?F6=1OEV+>+(;McmDuy`*bE| zQ<0Nb0T0EB+BntRdycW9GWT}ty@vi2j|<(tr^;jMlVM&t0}&dLF~YC#)Pj>VP}&wd zplct?=_;W}K=}w? z%WMvkqO?T;QT)#3W{{A4`PV^9 zi9?JZO+F$eZdq12#Nha z2&O@KD@v-a>fYM1Xqclix{u1AJKm4ytht;i)6~;w3mt39of!L;aq+_~3zF2n7|ZT` z(obXTED@wT42S!J@zc-;w}?uNLvzMyNu!4DOiD7N^{K0ZdV&0X+ZUJpHB5l*6=FwV zxfr{Dno&t)78I_}@9;X=u;MLr{#=9tKR=ydUhub&c{RLa?57%=oh5B&aVxdr5(1rvLtF9wKTi!Qy=vIFG2kU7W$pJZSRkVn ze;`92$piteg#>ZJJlyi+Yu9%(+yNRU3Fq%hwzBw4b*mO8yH4fVWDO~q#k&SUqZo>` z0ijsq&Wnx6b^icv zlo!{9{SQqWOMDPx8gWnjHzaHK#wN~A{X4&@cpSCKo;NS^I!a%*FbDV7T9%h{Ou{#O zKe?=Xi#35f_>dU;W}G?RGs){f_wo0j$cnwz1S=~^Jh3R;x{>zd<6sQ}KOG+O+W0S+ z8`DVJU-0*oMC+zQNE~~YO=ZO_gPH1Bm$C_F1W$^(nkxi^g3S!hqz(KYl!EPyfn?4A z!0ruc%V>b*IcI-;>1i?+X=CjnE-YTZC2w!Lzkd3>Z~a4T6-guKrcBQaWr^9F^`Loa zE%-vebs7`(rL6tP7LF!XV;^=xlWA2R{sFST2Q9X-x{xcjDPAs?bT!Q!7>AxIsCuk zm$++*6kEUo+b>?GhqK9Gt?geB`;2kw)nXC%&m&JEZ8K;I5kRG-U^o5EBG=?u?nnB=O_3vMh=F9Q?@#UReYmQ#heIo;$tOVg2cg4rdvFPX zh(XGkn$FJDD3KM8Hx%20A)UnE{vR!ilso4t>25YqhEuP4PWE_;l6(MkI#kcZV`CGN z5S{kxO^Z7j&`3t$WDJ^%`Rh_W zR{>*`gNzV*?O2S96pbtem!^}-l&M;&e%OX*&kjz_0a@y(dVNeyIU8eFX-Se7rhv$kH- z+`YOT#DY9^OQ>!VJlv^N=yRW~2HN5Y82ycd5zHN_QnpEQSQB2Y)wyznVd~pcxg~1b zldwh{5KLQB{l`s3DobW!Mpi1PeDAdd;wyZz%(@m$vB*(6cObuz!BvsvFk~#jWqr2O zC5lr1uq!Fp?RJ5?zsU2_h*CiiJzRDC&$Tmfvs%YI$&W2mjAy+#7UdS@Jig`IO;(fB z#Uf*BrW#DH+7g!);-KL6nT|$Yzz{xoarO z_267}Xe+W+ioyxPk8q@9K^rQ2%^%!L?Z3BIGfgl1SXe;Y_-H?6h7qcE^KMDpbRNJ` z)Db960L)>fedzl&#*}TpvC--k3%0j?g+!9#GGmN|9@L3*8#Yo~6;%~1 zLoHiSI{+h%RCfM>yb~Hq2=5=c=mr*%%BebOxa5xB1-Rcs56+5Df}wh^@Vt|VD<#Vl zK^$`Zvm(h}?DbV#ebLE1Kz#lNheEi7Hk3I4ZBrEO6rX5|nH_slPIneBtyr2lB(p5R zu^8INv0#A|d**E^AE&Pi008*Ye3A(Bz!4NK=QLQaPn^=Q^y72f`%^C|V+(>l-Aaj? z%}YDPEXx_5yfa52R5h!n#yo%Uu9RtVdeMxbSdWY!!szR&=;|cma&m#mFS8}HYj zFP(KBBJl0E2Z}4J8&1SY`Bx_6xGqs1Z;rq@1#0@wSo@Y}?M<+(r?q>${Phe%pktE2;NrPStuAdY8;4#lB_96u8gkw(lHwU?@!1=A z`;yN_3z5r=vMfZfPu?O)0)S$$QZPy#kO)7XmsyO2?dcq-Qby1b7d@%J11XjfAe6+@ zLM0Fowi+doJ>`U*r@YaF+rPKF=f_?olq|m=K~oy2Z4L0^o}8-I4fFS7;&w8!aabqq zPbqDbLcGMN#7InipfA|w+t zsF+zDu(xt{z@bUmQ>{Cl5PWoKkE_jW{dv&kg;_MlcRY{j%AWLjHM{qt6jBQF%w7`< z%cvnpqh~#`zuKeuXFzf_@l9J8TQmqWN=%8e*T5Itu+q-T= z0U;BR<`4=F;``RX?$PnT9T8{3Wc)z%syt%hv?w7x#Q~AZt5jHK3e!BtNQFdEI3hw7 z^jKp&)}q@))}LIL(ZG#>ZWRdzO!%ZqqXcSd?u-fJgql5mjA4Q?_VgW{t5Z z%F}q{a~O+@8_e}3jis1M3n+Nl803%@(uH8hL-VeXpUzn{=jB=vM{hiSW(F&0r?Vte zy8CQZ5E*;GlFxYsLbmqoJ4rq^ch%Ngh&c_a8YtO-QMdE??Mt|xO*AX+HJ6GgwHU6> zb)nkqy`hhB`TI|g`*g%N0lx6C4ce8sveY!78K>gO2P>^JQ(D#SLESxBYlK;SphtL+ zllHQmfIRP})nmBMjAV>fa9M1e2g@dEBgnMWt=gw*FVoKi-sGLz31v$bAB ze~z)Hjo^^QC-dIAH7h(~Rrq^#qO>oM)-1At2ojdEHp&xN(0G}V{rY<#<3=T~rZ3^1TO6PiG zrJ)&Dl~Z6JY)Sz|+iW=m=n>$LJLy5`8qNW&ToN@L14BWUX}hbzFTFga6bM=NgfQ0o z6rk|GC*$L(ST61#5sZo!n3c7;+xhv^?;#zmwj=cOJal3fYuJ!mRh&41f*8v0+)noS z(fB=d>aX@89I6Hg=ZeKQa3#U=vRIsQpudD;qed__W7`_9>DHt^%z!U{nAwl+aSCAf9sxw?_D=T47Q__{O*d@vnG<;wl$pi*?CJ{sw0y~1qJbMHm$4tW< zw_v%p0G_;nq^Fr<8k%h5r!%!nTwBwjmnjUd1T%Y)K@D@1sSSWhQKCKB7y>u*`0>{F z58w-R8EGE_9F*22?}$>&oe0dNWRfW;!*c*?UA`AxygKJZwAD~0l>uvW6;iK*JNA`yj*z3l(kBd+`laGAUi2L zK%huV9d+j5*R3vXGzj_+^HKi*)I1qiUE?{F4t*+C;U0_fN`3Nj*alf-3+-2@XwilN zzSG?9efgJ1$C2~ay`Kc*I&&wM!#sySopbzO#GEQpzv<3V)MS%OJd-Q*{{ZQI#K-dd zZ|ZpojtOSL^Pd#5d#ql`lnAJ+$ip&-ARfupL9Dj7O4Y$XRowVbe z4tN%HWftILxBT<0OWpzE+bhSl7;@*U0NK*Ts`vTHMbRe{i7S=Ky^TXz>r}{ggKrVo8-ai!$;Y$UXbl zEAMi<3mW6AA7Mn&*q+aEX{NQ@WoZQD76?1EclUne>#vH>apv3?L53_&=f67p^|uYm zqRvAFKr!+d&M1}&?Yymv4K4X1f*sFdW}0ZCF8g6wdrLRDI8*zB)gqC+%*c6~z0JH9 z`$m~#?Mm71VJ)PQYATegTd-4%u@J%(BTUSyT(5Bil6r39?PfdRDvDOt!J{Rc;cl3u zTqVgX{+Byu%TstHYZauB7Og3-+!8W0SbKnvKii~3xE^8IX@`Xa7? zR-H=H%V)Alt}HV+f~hXSr;a(0Z?T{xdGqI^ERQ#(PTgzI1a?sCCm9s`$zm{gNo-t8 zbIDv3urTvVuSyA)6CThqpK)TC_Mtzv`eBqRf!lw2$&Tf&9Hu(bO!>GTgay_L@|yBQ z^(?n*%HWspS6xUJMAnAJ^{J+mIPxSP@_N!T$;2;HR4f;Ls7zjQuG)~KGbT1kCR%hW{{SJYiJmk3 z*^q5RZ0zWd9z1WVGp0yDF;_O`D8Z4lARC9B3Gt1`1hQ*uSP|IsVgWy$ zG%{SO)6T1ovi2vok_qm_%PNPF%6^;)0{!H%{m5TFdeM#uVoxd(LW<`e;^&A?_g5qh zM`6maveCns<7S5JjU;#GG9YHLGe%g#U$g-fE_I?ezxL^)I}jr}RRG3wzTBy3;8_Dk zHgZqOh~+sFq!WmLSNeYSvHDolEjj0sZ)z`g7|Dt-;CM}MLw}yA(d#1Qsuu&EkaP9t zOe^2CZ49p7P+R~B>FrHpmC0nXkwon9$hMSDfC$nR?d>WU9jbPJ^!#;MCXwXz3_m@q z!r25szWDF|05xsB9`(By!f@1av+-5GVhoMi^mD;6b!@;c&ja>Jd8)npXpPO+s@ zs^;rvc+Cp9%vN|v&yI)n)-yJ#*pU)@GR)xBb@!Xf06gzU&scx`E5)NJ9i_~%%*&@q z!B7WWAK|X){{X1I1++I$d1W2cD)~}I`}EE#zh$|%9Lr|%m#gtc#p5y-l3Fw>+Y(uz zgeSV|BYn~tB`pOLYen6Ik19ieJ?kwkWDprf&QC9D4zD4{ zm0LEU7NQwRDNjN^_~)0~?qyh2wYHKqEPo{Ye02*WN>AC&tn~J)HLzf&W+4U%->nTk zQxej>zB@g$E4)9b$6iw%rE?)?h8WmtNaInh@9k00V`*bk9m?br&;Dtfi+JvkO9Oh- zw_Hmrcc|~e?D$N4N+~w`ggh^-EZs2G3k8aNNKOHKGD3|XDAZ?Dr+MYs; zpJ+_$Ex*sT5t0OKV5gDO!E(W43&>3M5_K&ch(hin{@B^`v9H^&10GpG%vt7p)%3eb z<}L)7sE;EZtZn$!=6i5g4Kx-zpW3rp1q4Fx;s+i*%R;Fr6PD$I_C)_rC_)*pPLA(1GmvjbGmIpD*bBbTaPrzlr7hE4Y z*quO(0nd=_PIvk`UX#UEqOs+P-Z_bHHVJwj1Aq=lEX&N_nLlG*fv8%H( zfJyR6=w*{m*%{~atNM%J^Pt_CN~v`M0#Yv5*dVugG6JMO`S3cgr%A^;oE+n93MC`$ z+sD{gooEBH58O}f=b;?(6ifl}XBDY;Rc$a;J%xsjm@k37pXu}GXRi{b6mi+ArWZ1Muqr6r&l;*u)*rZgD#t~2uAG8Usv1L#iX4fk5Ocp;Ib$A{rMGV! z(oZ!?p8W7sSDq+qsfxSKB#A$f0v7?8>(7Jds>-mCUNg#t5t1N51cE+)jVC3DD1%EQ zjG3g30+Oc2baAGGYE=o8)j+xPzf*P)O>)J{R? zM?Rl}tqmu(=&{ELSt18ghGs0KzR-Mvz!oHY?0MwyShF0H!9FG>g5~KUjp4y1Ste!nWt~Vf0(8G4taZEM(Ou|~6YR&9IsJD2 zbFSq$CxoQ%_uEqG*NdtGRO1AmgRMa8V!SlwQ(mn1?7N9xLEf0i5P}7C8dZ{V$7Jta zqpgOKRC;l!^Zx)9!A>y1J+Zjy7R8Gfk_Dr7w0S&DIGJ3*94x^gh%*qDOhA&P9`E~f z-Lx#k=VV?~jns~cJe(H?bGACu!xGkDp?VB;&l$&xy;#ct)oQXz`G09-`|8Tk1&x#P zByAqFp!0O;*zb+|VzchddXh%Zq-6FcjO1SxiS`n3UP(1xw6U1lC6S|aPVExD(POXd zcl&jFfgElKBsMqgOIsw9NX+BzccyJS(n&JKFf3XNuv&?obWBmnEKG!J!EnFd$4M(J zt_zZTcC9HB$X_xs=*Z8?k+U{lmNr`TrFfxA^e8k+0n<*2X3MA>(;1gX^W&v%5TcW) zGivG6+MBn~>>4dWagntfr;f6?NmlCEjWQQ0e-4d1o>?@m*4bc>`MQw+1OdLTkvwq* zY~=DEIs~%`CmvZW2F8`~I83c)w{Iz0Y69dE40LvsE82!V7#1}nz96xo?j!;?dJ87hcGKLelKUNIjrv{z*UMpm|<% z>X7fwv>tyhEM;-?rTTn+D~~_vC25v=^FeVJXSS`A*ko1N(1CPG*U$O!HrA^XoPD{m7UX!8*!^u&-&Qf@UO4SW1AlNwUX~=0 zNkTIa!QZYqRX*_X7{i?~%^5izcl~J87{;+44b?rfWpYwnEJ+c$eVvy+SO)TZe*XZD zoEFUS#8p6qf=+&YDStX4CBc~MXow4q&n~pDN08^RxVf=>UptY*VXRmWWpOqQHM?O7 zMAjv<0ts34n(*UWBz&HfyWxB`=z{1lSio8w2U}&svWVeo8m@WoE?s@Jh zp_##BCWOrvIc#N1$!YSF&1Th$4pch+qQvq`ES<0YBhI{hcJy1Dc%z62BLL&4>qy(& z8Rzq6k6O&=yASpJXrW+gAvGRywF0*B$FF{u?;kF7uit9%771s z&>lL*Xd{B~`78GhIdlfKq_~>vf(Lg$Xo%qZf&0?-XE7AH7UK@jP9j?6)rwUWWW+%X zRwAPc23XOoRUq>twfl5R8HmO|TE-`mVJp}+EIG4b zSVU%mGd+6ZBn$0EzElI;Hb#inkM=Bgup<_j7GC`-lW^ zHCHz;9JcXHT3kh@DSnvSE}ZtQsC$X_=8S#YU2k4`=fv|{G4*}R9mlb*l3QfBK*xE< zBSv$|kW-#fAb5?6ld(cOELSZo&`O$86w52AZ^v-z_W>Fb$KYO6J52qpb&c`9cI8YP zx8$CRMk9(N8ZeqD)r^xdBfq?}G7uStf`B$UNK38-Mfj^p;{xQ(?N6D^uw$_}nUTLi zhQ!60x{olE-@7V_RyphVEggB~NWeNh&!O?IvUg@U3D`f`A zxGdDvvth5@nW_uDlJP5>Comi5nigs$kz5xA)Ns^LJ_KUofJq0+rE#nYcgRE>% zzr8DAQvU!W$kV{%Qao)n5=muifL=PQ*krS2!5#C-ARs943m+Xj1h0D{+S{(1aB@uo z`O}43wcX2VnEWI5s~rvOKMBUbX0a72w7?NOt+}RPq`ODt%Qccb>3otuJ#{co4U8cr zI92>kJ*(%kM?V<3*2vDy$aEiCjPu^D<1&&m(8c6#ScTZ|nzPX_G?MN0(W#D94}Bdw;`()cyD(uCjgm zGR`IUO0R$wKnLJ!tk;%nq8>=ekC7w(Yh%l{vUG>c`@k|eZ&EflgkY2!xm%9~T?G9r zUy3G``#=HYEF@5+188V{+xg#G)|UJ>R4Z`gN1j>Uv9E6Un#txt9;VI*%9reVZNp@$ z;=L8LkK7i=>t&W<@Wn7)i%L4gi>V)pQ?gejI@d)mh z?JA-xm0_rKZswcWTz#vQc$OU_D|SV*jqLHIcyg|x*@4lG71_3YcAk-)OtD34wz6^b zttcHLyPoGib)+AyKC&51Cj_Y$7L-pt^o0!3Dym4*I`8!v8UEq}k{M$J{kq>;&vO?D zfnm49OV_<)rIptexM`)xfStQkTao2!k(H#mS*$tkZrLVL6l2EyxVuGa;^BSB8hY+#BE5jU8OdfDxSgD^(~Ua_9w8yNMH{9d~}hR%ep2MbfXJqUT)u7 zTBpY@=Zqs2jI@l18e2|{9e-#k@37OzR0UNepOK-{KV!MPz>Nq4uR%-6dv9cQ%;26_ z>S_5?4Ud^1tu^oCSsWc}DK18C_^`CG@$Kh8z!@%;?<%BtZl74Abkgss$K`r?$K_46l-tZ~?`KZC7~jHj~o>rV})NP?(!Wtc*yPTlRJVmdrNCAfk= zv#e+%2c0DT3bCF1`~(CrVe3vP+YDSsLNoe^^)}t11uM zO4=X4T5x!p-el1@(lhT_v-m#dC)H%&pxh2$jW1m|oELZ`0UWr|xZ& z?p1KMYxZl#$5wlrc~?XamFK_w)or4l!cA9EVqkY8IQ(i9mU&^ZC2K?P1(8{YYLWN= z_+Wp}S^AS-mj0c);<_MObq%pfM#|%A?~w@M8&-jlPuF2P`mtlA`hVmf2ahB2g!nt=qV;8Du#KroAy-vvox=^T5 zB~UwpsRzhE2dc;n3xuLZ1iq)|Npduk$t2|WyJ@BX1^tO&pb&o~jdZIkI|k15U_^eL zZ9tLtc>e(KhQ2(4f4AWES%WKKc~ypza(U8)d0sI9a6?Djs87pRkP+Gd1HzU%KaKR( z3m`Nm13QzN(qS1jb_iu(Ze6ehBDe_5e3Rq%>&HRdfvUU<4TmaS#@CYFzkA(`K~-EL zjsE~KjBY;N00A2R0Mnwvvdt%}kQ}K?w~Z4CvlB>7YQ=f0%Uz5U)qips(|+_bBliYL z3GVp=f9Ii08ZD#QvrYgS6%V=)GgFp7Jp8kd+G$xu_N-{+p*rr4z)~Z&Rf*DABYr*) zTh=!wduEBxsMj}n0k^(M5FaZ)N@U=fX6vQ z{id{U7tWm@14|Bc(TS(a@+hU0yL+}{lVAW+y<(%Zk0ZjTk=A>&&41>BWRCX)M(30-#01RU@m-iSQ5C|X<;B-{9xqx89%iPii<_T~X z;O===C#6Fv8`ilOxh!O>M|PrCup4^@3$=ppv=0G>x-wfqIu&uGk7}`&OLo+ck)HJ( z=eXU>BQ3a#OO9t<$Ow{Z%+1^uWAG3!!+ajG?s#;z%q2srBl&+bT9((#ay+O10CODr z8Z85(#gLuP{N*~D#+C~j)Q`fHwE zQHqlnEQ*=TO?5FlXeHVR07cO%9peME{^QSGCkwrsiLcp?1EBms>s-GU;c-QBE=JK4 z)1@_eKO3oHdwGmkZA^tXCo_(ZL0m#Z3pLYV761Zu^U}Ar@R{_&e)l`mJU-@lU(1|k z-y1hS#+%aImgZ89JwRg>^C@qZVI_EHE$7Kt(oZMydRkTuI;(E0&wkaQBhZr0r*;?> ztX3>$vXNN#3^+Uiyafwg?qriGPQcI9{WE}?J#K@OY`H&>Qd6?=g|NF4y4peEscOJJL+T1 z3URj$j(Mc3l$I%Cu~w>;$C)cxX`w%IyR>YgDGs;rb>pT-4oM_2UK67m736VBo-`E% zmXYtPsOjFDS=+ZTebgz}mI|nD@C|jxYilZdX}CM4m|sBp>h9r3N3o1a=+jrBdQN5Eetu zo$-5Bp~p#+V>7%8YGT$CW$0a@lpm6;+3Qf-sV zYF}*Sn$>?xS=O*Kdo@yoQM3^|X_=jr_b^5Lka{STBIYL2rj`34k+oeGZTsz8V|ivm z#rZMW47_NvAX+7%kJ-pKxJ=MWt)-2Xd=DQTS_|1_l-tc9G2=KrtJhXgBz|S05ZLA^ zJ3E7y3w3k&B=!;qR=bXexJD7k7A(Y!2oaFqaQOW6E88Wvm2WLh6yPxF@3mUe@9l7X zlI#7|K9kqlh+*=PN66_wHX&=$-J>8YsM6It0$70~N3vt!em@;j)=2)^kpBQ-1dLM` zh|j|%UBs9G?b3#eH5c)0rRGMJD`aduQ7H_!FUu!w6?7nim0=tGfp6{9LooHSjN$tC zsG8BFw}LF`^o_?pRN;!RB)lTIb2}wMwo2N@V*RLWR);`@d7WI$!A|@gjsF1IgB9w! z$Q$mv4|+&TX$+>?D9cQ{7HsZmWw7<0#MNPun*0x5);A=!W|HG;Ovz=MLld6xKml|Q zMglMyPsQG>CY&E8P`*=bwJbf`(NmUsb?jcba^_M?&_un{B?<@((@S6wAdlQo0X|Q_ z>3=dvKxnsm^1}na+2%TJ^O4hfM6Z7)^5SxtcQGZ46dfu^gH-GOq%7#fE}5r>Lf&*p z9TGX6}Gg;BO?laP}n-=nL5$HNsiL;B3NSpmP9GDyoi zP9JX+YLB1?etJ}-f)+^*!zXTZ+;c^8%OqI?9KX#qanM=qyLY{!NL6V= zIRZxgZbn4zNPzBke{2$1Z?}CR5=!Ga^p0Q8)}5J=5mS@n@TW7}wUbHlb{Qf^o;+1) zy|S^%kf{K8RlxyJSzU&I{Mz3|0C|Qm2hOF~G(txtmEp2ZZ5@__w)T@~efGq5L2^#=?&OG(! zk8e&io^(rcA6}!lH1Pib;twU_aEx9PJ#GkWQ>cz0bE+|;q_$$Dnm)yrnkDXa(*V9Y z8qV;7i;}13`BV0sQ;1qXVYYTuJwYQMofM~QE6lPnRJ(rM`8#!N!%D_V%(A@x%nv#& z<%nr#u<6^F>8!-Cv#Ez8Enc?R!5nCmdeGXaqRu9 z8)IlA1daIvTIdd2QAqMYG$wB?yvh0e=xOV$6NHV-iQ5|_!aaFmq5vnf1t&!R0KZo9 zoG!IHSEMLg%pU<2W`33Z8T9kkuT}Wp2lY?Y2z?~t`T1wY!O3Kzw^?(NRCdC^@Gx~+ zH!=#5yXb@jo%Nl?Z>Hf~J?~(M%ODxR&nnmO-Hopu;;F-DltQddOKcBe`&Ih&5B(@P zG#Cs!dOhk!6Q6UL4$F_G8 z>b*l6zi@UvxmVLTkMxI#+rV1JNThJN!m!+8xifPgbB|^77%c5+vDu6r+Zf!93k(F6 za+vv=y_g-)x)TiO!^Xk+-n#k(6UQuKcw6qzFO__TTWM`3dwZ}1sKg$$H|w6;NMxF4 zw_;+($($rT#IIm=K&s0VFWc3`p`X?1%?ZNe&jJYNl-SB*lpPFeF^1E+#L=@yBy%Goo#`1ndqb;! zV`1cLU1NE(nMMg9eigYKNLN&6n4=aZF@<3wnLnyt#vl@Vi)blOyC+}%POmPA8L`kZ^0fqEHg5%q>!}|K$0)2oSd&A4o4l0 z%E*Mv3bZyD5J?OJ#$5RCCtWjhDl!QeWP#3+v=T=tk8lqmOiGfA^Q16EJntb9sDT<* z4o{D{G!BQ6@zPK$Tu2fu>OS15`9MY0CISxToz?ipQizV;N?UMNw9-@vVsm0ypYQB+ zMrK4IMvjObZ^y!AixOOtW9&DqcjB-!c~E>~nLMd#J_`-Qs`wnmr*|EW?>5>k& z+%9W%GbT`U8L0bgQhFK7H$QL6p1fm)a!ibq|FDPIv8Rwhdmn}$B-3RJ|* zz?EV62jly7+1YTI%LNb!&NH5UtApYkaiBte($Z&;H2BE+L6a=7VzAtID_sHalEYc2 zDjkK|!3v~)7h5|geK~c)FIx!$ua}?a>q%PiyCw}a+@)n4g-bje(&{*fyDZ?wK5~)F z^DXJt7N2VDv4>*)s8k=F4?SsF@R*=YL=}(?`+L?c_a3;ol>M2*LyTbVe+rteW9s2> zmveb2tTrbd2kNd`uMoVjKp7HS5+Vj~W+nTW>%xuosw0Ul-dS*CKuIFG#k!hnk!cXc z!!`gTnW%$`eMkC7n!jeHRLx^3M`~(y^LdK2>f>@VBeH>4*q%#5DU>s*by8RHzPWo} z9B@G~iIjk+1pKqTb(~N7d*U>*Td>OIfI51N(-WC}WMc6dX=-pTAzzeIq|!%YFP6yK z$HOGb3}!lYD@7#Iq;d}6#DIQv(!4|ZfgD#E;Yegay$(00ICu22OUp4Y6_~*za|CqF zP@h(L^!}#v9H%h3i=l+dWdt$bixVx@uV!cFQ-=QO+0X)<={@SvbZ^8JNqwYA#@JFYwzvzToak zR%AZh&)M8d4He$^)}4<6`(3g>50lN4n;|&IwP$fZ6G34+jv|^PBWEj<`3jcj;QWJx zSeGl5^tMS7NL3_|WZ}6CMkNAPY2h-oCENEJ*UtLXJTmWtV(RM0)6XHl#`TH*tHSvD zEw#Pwr6=Lj`sSOT@rMtgkfD{6)9Y|q$7JEi*S8ldwIx-^K_GMbnh8yyu%Lsu_wo4Y zQ^q)aZ}zsdB3`*9kIYuN;9N#X8pvI$#Nz`10zWamDs%q;Re0Nad4B`MA{gq~-a!_7 zk$br&7MmqeYzQK$SSm2bH@-t7p3*?j@zWeb#9TN19!Pk4gql(b8}t7Fy3gVM4ZZ&W zSdQb0N+f~-l;n3Dv+q?dTdyj##z5>*v_k{m-gb7OwXxmvzP@)PaxvD#X1;+Qg(QsX z!?M>^IvOC*yv z+1@oLy0|U9Ba#otRAkQmPtK(JfMM|BicgV}NFt*vvPS@LsLsU+VZD^l8aw#c&-v@p zPpw2Ey&MW0%hIJjIWgk67LiN~YcPp%8{3S_QXskr8T9C~Ao%|P9cf!C+rz1ibGNM} z?a!2h?t#TJE@$jnY2Dgc7C|7ik~(`!BC+?5b0i^pV0M_pHq_ncX9RtA8n&X?y6pgAuyjIzhh+TkSo)}}pYv7~!lRiEz&jp#@@jk%dWtNu#D@X2tv_RsaeI$rQOkN-?6Dh9Tse}1I^ zld7!%+X##UxQAu-BLu#)dYywRUxvN`Dg>{}AOJ)^l5_=M}5YWXNliGLj&<~m$ zLZ460n-RcaGrN7@1miT|pBs~>a>5DVveL%QR@x|*F)XlzRhfp_y|O&^{B-gLjdE~g zKc!_!axSA#Hq!g|BAe2~$nkV14Pq;n;H@iASUiaGLJI~E8GwJj$G5KS=~t@HJi!cL zBKmpb@uwhY+UZpos3RQl@}`duTiJPSW%5E;;GP%vqLx6h$&q=B&hn!Gr9+|r039Q3 z8_HH^BS_n|Kg1>$;b2B>M;YJWok%t;)mqb1g@L<8mSD(J-S(oJg` zm584-^WXWJ%`n`^-eU~Io_(njy~yl6Sm6l(u`5`LMUA6zWm!GwPQeN`en|bgSrAU+ z3+cw>(c|>$khh==3R{iaX?r08a*n;C1D}h^rq4VxR6~Y zz-&i)Cq8bE@and#GM{FpLHbwGlGr`)h@f;*SgHG)$nrJRTuz}w1F#>7t6D*>;gk#v zll7*Qvs$YT>{q!xLtst%X{-gvn$nQah@&Kyi`xF8d?+KN)d!)%9|M*up3gnZqpki zafKwhXd@mDkD3MmiX6D3IVOol*VX}Z!EV73`Z{H0iIj;sq3Hm8vdGKB)^g79DggF zq}Aieaq98dib~R3?e^fQYE8v*#dRdQ=|~=R(h%Oob1O#>!VX03pOtq0An;7S9^vs_ z&2$F~L-1Mg}GtF^~g_Ou~3?$erbU&x3XBBq(-!>!> zyrH8C5cbESPoI)Lda@EN!pe0xIXhyl#;4);JVWgqE&~Ow!x_)>J5+dMTJIdzVGOoo zl4z=|jTdsr7((y;U6G>?8b6MOj4&gSrK4q5F8hPZlUTcLm~4C#d)OZyJb2h&C%_|JS2zG3S)#g~AcLIK--K{Hw>-*Ej>a?< zAfrvPIc%NZaM8uk5~OWr$%SQ+fM))cQg!}19mM9{a;jJ!!}FtATD9E%MsNVhrtH;>ug$|ogN-*fB*AQkHCYO;KE0YmFcD-p^ zjb+yk_KlBx!98AM;3>#F}Y@X&Ko%C!@g=Bmo<8H>Ky8Jf>JikZ8U_ zX(fmCqRgc(SexF2mIAm7?$=n_Qel%(cPvvX z?AMr|3>&~B#`+s62h*I>j4gfvASNnPB z!_r2uO)g4}SU;ZGqqS?TId11BhAC^e>SltuDr25o7v@@!JY;By+fV)ztE4&?&i=x2 zj11kGXA&*`!-MDp8?e`Ly#%NfwKT1f|JPx=FFb>gct z@0HIiY%}}S*}lxVL%ui3r%jF_nYnQkMJ%-ypsX@s3{m}&Ma2N^lY9nbGh>bzFH`oc?XlA$^}*P6UjG_e_@*SPBgY{mMWh9j*g ztm0vVu><&PGF~AhED&Llsdg?Sjjs`og}W_~g@o~&Uu=$ib~2`b5uqB`@vokWu_7jw zKC);{b1a!+p<^fIT3w+f8S9wB!*zp9Q@wR8@TfLpU4~EQM0P$mzKJS`6?C1AQ6%$$ z2au-rM-;ZFtzMyKdtU9jz05ljs+O8%AbA~n-C5*8FcR0zXV7OI@fcQYIvG{uN z*GQe@8A}tJG-ZFe8VqbwPdd9F{{XF#RyEl9Jy8=wu67{Rnr4zL$GmypG*d5GXRU89 zC3y&+lTv6Zvg`pojVnVU4FyzU6J2`l6fO3GFq+20ug1TOoaYP*{w;eP9H~l@^>A+TL$Z}K#7~2Ar4AH})ECZ;5bG<%s z8Ctl^Wx9E)&}3=F`nh1SXJ*ZTBvOehS-U5Ekg0b%C`QQhusYA0!s#UQVsni36zWM>x2zDPW;C`t1i;eM|#oSmLRzx9$3vYJ=rZs1&lqF#AP9<)#b8O{+i8J2l*Cb z*+RhnLD)WeX52s77HcvE&tH7=qTI+5_DgHEGVt;S2WoCET3VMZQoT)TS+bb?m74o^ zj?Jt&l~(*G%AeL#pY8+Tr&zKx$0U(T3cI#XPPMNbQArFpksV1Mz-N%{OBo(YAf%9) zQYx*C%GrB|e(Z3|YP@lX6o5>UmnypQN$Wt}c^07)1vHVrJ@K%mzRTubH~=fZt0VWN zUMEi@ZiPD;YQr0gF~)INj{%({Ug37!hwpZf9fdjruUSKgHC&Fl8ST=R;+G5O2yKXI z0)S7-j%0IIE;WjnO6g_@bqT&Df(kz5ZT#x-#QTjW=HlqWyYBmH7xA*Bgh~>3WEQbfKD@Fy5pn}Cl{H6z1v_VGwz9pf z<1x7W>7$dnzpSGq(a&@`vsuw5OEXHq#TU4Ty<_hCv!58rT$*k3;Q;{ZvovI`?*!4F~ne3n*i;=Ur$B+GO+~1CbS%admHM+S*-h zo>`-ViRba&-KcXIu^eF|n!U%8oqLB$%w+cfUvg~`uivOVKH)%QDih!Ks7@toK2~%g zyW*N0s|}E$5xDgvdJ+Yd+z{&lCsHE1_X%tH`xt&dZFGgq5&%qq;MR?si67Gc04&pf zg~z*vyN>Snq@)3@_L9#8q`XmrPT0pPl^Z@c(AP3sv?;zQ^QM{BW;=eSiQqA_<+Aa> z@i(;%uG;~1+ud94mNFZ_3U$Bp(5)@x`$)#iN!+7g9dGvOi>cBn43Ux_`HiUyYj>6r8Vo5pC+AI7vz_F( z<$9Ga<}x-UX#zoBNu!LhUc!4+B82!G01y3o0BgJH0aeu4`2qV=t{||FGCHbb1e3ik zFQ@$aB$C9uc2H(e%=1{LW?d8EV>SaQU)T>F5%^yd3UeOcFli__-vurs6AgO01E0>E z+)n*I8H&s1vS{g$o^eX0Z4o^3R#HuP`-ag(k{y_grz zupiFVyK8F=rqO^D01tYRc&DcFT)Xe*B$lo|9}PtbOBWe-&m8gs1XZ>+-;@2i*x`H@ zIftEe+EY2lD$4N>ACfft7Y+fg3E1=$H!}4ComQKUaXPR;i>^cyT&eo?Yk@(iYkhm=l$fq0h{&b_53)id3QE6mt(v-Z@ zOl#P9)Ik_&oD|<-ETi@(p+_WxXk4lK(Iko&Pcq?k4Z2d@Pf_gLLravvFV~eye!jR7 z+KpR!E{UXVyhcHY?y|s+b)7#5jhszu>BcfWsbclU#m7o%gmz}f3lZby z@9b=aTVg3{*bakeiifnYJKFr7m|`P@PQYsi)B5JUXnEII%+pCBcioL@gi(cy(xYl~;JrP|<=(uPNygTlwiKtm zc0?dH)(xnPX@{31U$;+N@X4Lj&Im(UcYJ2f)r?AYPCp8qu%4OVF(xq$XUb%qm1Cao zZG#(wuu>IOuN$5C&|~ASmfwOx3bX{0Ip(>0-aB(M<=8BJ0HzN+=CI>(GI88vi-}fI z9&Xex+ZD;s_P#b3MjE9C$dg_j$Lu=Fzv80Wb8T;rENs{u>tBX&p(+d8kaBlWNj&*e zZ#|V&Q%Wlz9z>2VtsRwZL?U|0q{{ZesNZ(2Yut@eYZfjcJFEZ%KDDiK-c5;KOgVVh}&#tt_I}M zynWoJCdyvLT(L&vK$V)av~R6>R?+QrZD@nAN8t4+@<)P)3=Kr0H(fF)Wj*Qe-14Sw zBu+&Esg{d>{dc6D(b*~hQ)a4-B%VguK6ZR{thdWLgE7Q|^Q^6SFeqhMK1kZ5J9!H?WMWIh58MFJ_cgK<4Jp!j z_yB12koOD;QSerTNg4Gw#Y9+vJ^2D9*eJpgGlbq3uZKY^4y^?j@Jc51( zybrke-=Fc&_RdE-vMD<^DpQ7cw@~Bd^CfcA(U9oqC@no+@4jr9Rk~0a zbUxHYCMe$ITU}_V*1=Pv$)oeI5BTV8LN+=IE(;9pLrW5wVljdeeL5&Q?Tx-cKLfOa zHNJ|-9YYRSuL8v6a;%BSk>%gmpvMql@Q6oh>4_h4Uq8Q4A5PT_GwK+{8LZ=GW{n$9 z?!2=w2oHc0OuF&Ejd|*M;~Vc)JB)ItmOXA;Uhzu61P<0$jyRouNmT4YK^y-7Bdr*c zQlm)cNXjkH<;c(0jY+^R-R-jku;XK5+m6#275@E}LV##~;Q8^_pM~8y^9DY&5A|5k z6a!Elx1eiryq6xhnFum;>;&KH>&DL*wE!n$P2xhIBlg!+hT7uNK_-1YXp46Z`UqHI zt7@=5QY`fJN(lvstZDRspc`PF!F!lD?bYNN4I*bWXyJ0}8=N5JO>ASu`pMc^1PsC9 zcOApngfVHDXdmVf2m|sDNWmakHB0d3k91qh^5PjHq$+&ljo@me80xal3nH!db=(?Z48oC*TuaxSsZAMChZXDZ}ebNg#^v6NlPzJIptv z5|ktl9nf}yKfi<5zxs~rP!gci^QD`@cFc9bJ7+YooZpG&87yO@INRz1%ZN+EkD%ia}mSdWG{?)W_P2csrJ?f#Q}j7&U2j z+KNHH8&Wmn@vgA1Efr>2RGc1}uAhh9d9jsXe9olyr2ajev&Zvv@>QIqw5$eTH@AjH z?3yB{!v>80d~_>IhqSvAAvh-oZ8v2lKA8#nsOfLLeFf1D*NSfw#BeQX^fE#kK>nqP$0twdyu-*;NMqF(g>T$=@Z4 z%u39Y88*!%2f;o+ewTt{Iy6!o$i3;CyK;;!A~=Zj2AkP7$3b@8T0!8736eOVU_zdsh4wiG1wT|{z#tp0lW^%s6vu< z0bZDEaHZ5ZQiBtui+Vz5I@C95I~X8uNq_}$ylcwSDtGzqFK3BzSn}>E2Z}kFzs!5*PgZ4 z%vAE*O8_uFUgon4W>2&tg*vlP%yF6*dx<7Y4qDidl18c;)7gxj?T7Ntfzb2T5=A6( znScS~&){=g^URFOSOWyg0Y3i#=9MwoYy}#%spjh0lFPJ`+p`ik{H>d3-)QCR(UIX+ z1HstVyme^iwTS?^f)jLlN?Dcu@| zEwLn!gT0?09Usq{+3=)2vkrZ|>3AYr$V^T&T`A7}Y0+CGE-h*6l!jR*sT(jLb_&u4 zKL=yp3+H6{Jvki0;edz@Jdfo-Xk{J^t(&s%jM(|p8TpLwKprB@&?o==k&3 ziWt3o$N8!}ziGq+Y<25PA~(uYY>j#~;r&{?ajm%!oP2S({X@KJ549|y5QFor^>r&K z$R{F|kTtOwI*2`KH!sI=cr`U?78I@9s~obP>1B#bN>H-QKIT$oKOP5En$jph2a(@2 z)wTTBGiv_;*FP#`a*jWi$ybVOO@w)?S4?b}XPv8_{BsC}nyOn;G@t{c@_Ns^f=3JX zaye%eqiuH(a+eA;lg^WP{ejBykz=m+#gfH-uBDWjSWjxOhCuFv*gqg7ZP3~#Kr@VUH4|Mik<1?# zEK_jbYgcoZ3%qiz=$I1wg^;KvKZjw5rzS{Vh@p z$uw$NAYC%{lq30*O5LDFKH&kWO1*kah{zPO9iatN}yJPy>@Mn{SnI$c*f2ZOZ*adYcA5wl=3b<~Xc2 zI>hqRqm9ehhF{`WAy{XTZNuAbJCSv+{?$?d`16@B1>kJ@&ARK(cExnC_`Afhz#&VS z0)c{a&Y+ylTA3gD<+9(!rDHj3R%Ee0LP?ms->s3!XPu;fF2s_5$?Hk&EuIh{+Z=^p zNj=Q7w6c;}n@#@QiXbP!Zy%1W8q8NpsW_>5Bfw@OG-gPyqLS4u#>&AAPgY>W z4at&z)L=eSEjFL-4`a(N4fI1STPX7S*>XLbix@5>S zFhMy@#W?Y~)vYWBtj`>=1p{asWr++6x7t(>$4tXGj06PlL6$h~GpHRxi)J|HO2zpn z#6=^UXf5t(p@tXtn0J^vhy2f8Fb zs>@idQm{~`O$_m_Y3m zv19Knl1O1-8Q^WI`%ZKL9trsAh+y#z->wzH1Im-Rx$ynJqE}WP`_zYp^z$U=mcP2~GQe0fcX{x7-+|%b)}BmmW|;LqD(3iz zn~mKhkisFiQZi3Hs#(Ip&aG0am$JBOww5Mmd|nG}GRff{L27m}x)#v`_TO3-^Uo!? zc?eegs}}8SqPb;MGLD`904g<;#B&zJTcW&|9p7%W&_`zOEx|HAz63HJ%?}y`dGpju z6tf{gAPhzZ!||%xU0OkKB@i%G>AvUu(-#fH{aM3fITji|Z%t`(O7Yim9CFUF)G@R+ z>9twWo7=MskVrdFddj!q96|`56`#>2bCKS)uDHJjxw?Y&Ewo(3-1%myj#KHA*4{rY z>G4%D*-U)4CA^ZydZdL3ELGyGXgk2sA^a8j_}^bXxbQC+S(%~;(CRzwzu{k0v;LxR zY2#5aF}i>`ZIMu=+T6E`D_>->0br9w6(Lpk@$0@aTg_WNWiAA zCgFS<-75<(r1|J(Xr>D#TH_vCPTiL#C(%XFEC>YjIc+Q=vGSz0XKl~LkJ^tF-Sm>j zs`FN-8}kGHYM}C7H5NWOmO2=?V=}UT4w|fzSOlLYX=F_XjQsi5$6Tb*+kKgj+4RSp zV{galUr3Jalmtn&$$_~&e;S4_aH(v&S;yrm){)tJ>ngIwyVxo)<#4NNChtz|>+V$91Hn7jSyI{D zz{DslJBrbc?%r>Rm z^q@f#Q(k1GkO9&vM8wRPjyz=dA+L8Zi8I)386c_fA_=c0yzAq~O%@@0QqD~BXQI+Z zGHEW_6zhS0dyo1k436pTM200ca0e?#5BOfFp z%1+%%Kau!9sn8uDB;sqK=7>_-pz$fR= zOT48{Gq9$<&LqhIV-%n-Sjk_S31nDmw24^~IZt=#lS;xq@<$W_0Y5Ng+n>j;D6ZL}ME6_$Q_!U>D48WBL5+S+`L)+B}%grn6g; z$8jtzBO*No{X}sx6;_XA`IBUOgL(jY*81~Iv;0}Y?X^snPi$3&GIlks%hUVNDgIkY z6gRBNBZc;)lx#e$+|-DbftydL91W=gSm9V_qZZR1U@~^BgfNmv zW}UsoC6Kvp^PrD;Ak9IVmFPV-W-VUD$XWx43 zqo2~AhSO9U)psNDre<83-d1UZanGwNXD>W;96X#Gmogw1Nai(J!^w6&x%dln?HCXAt~%c3Q&NOS#{$468iUY6V%9IWNk$B@c- zjQ6E{=2t0}mK=sc?c9xvvP~`KnrZAcjGQvGb4=?d(TgsK@wFWRLL(0>0n&|iHO1pA z+_O8V#^m&*t5qe5a7 z%5A{*)GNxs=~uN_q7u6Q0LAb%=g(6VCDoaFQD-_3M#JJbrFbh=u>>an0J~CKov`BU zmjXC@TsOC6ThFs1w`f0tKfhO2j5rVRP>RT6!eE8`@lPDzHm5ffhlS$!zAlUqBm*Ik zc%EE@xjK?Jxpo=RgGOCgsRzc7$4MJme$r*v=DmxLz{TXZs{OOy< zcyB4@d5ZWRF^998%UTE7sZ1kE=%HDiyR-t2$oc5$Z#2^RXfk=yRu)%R^NV{GGEIzy z9QO7VaJ?Y*v7x<=p|$;> z+ZDmV->1*zn);uC{{W`5_@nI)cPm<5LAH~SNavmC->5%J{5y)x>jWz# zn97z-nzb?UrH7|4>SJ3MCCJ9XE05`m$vC`+HGjeW$ zP5JXAbgFYUKB`o+92D|aF14=OjrJl(MlcC`Rgf`j5{4cW_Kp7iF36fO9&iscUm|sO zV;CwBa-}v%D&wG7R#wPnYZ#@bV{q8okxHmYa!+*Eq(A`Igz@?6$4f2~V@sIjS+y}9 zNExE=#|3nTt=0D;v5l71)UMsuYkM=w(4YWlxIeWFg>Kze49EMFVIbh+BEwe*o@wqyZJ&akJ z3pHaCSf@UuH)@q8NbJ>MQgfmBJ79J^j;G5%7chV0l^REl+sio4HZ;Ftgm%*f$f?)u ztdZHCXp_5DX0Ipmlm=1T6JC`N1N|k!^)D2d)Vz%&42zeBPl;C{Wdfplel`z#W z*p8eOSYB!hbI2D&fuJ-7_0#a1c@q()mmL29%^`T#2_menzF%zb zN~(_oNNZk-S7_uxJ(|-zZMP*xySo+9kH{PIZmL-L@sM#Y1WN)f;BP6`ewSjkke6h_PxEM%xi zO1HmC#9j8{fwVS?e(eF{&qsnVu2M`E#SV8YqF6SMtp%Hl8;Y8PSco~JMi|&6x^}ma zl0R(_k0ikfFR#x>#k_5)z9lp;ZYGJMkFQ?Tfo4RSOHnyjhRk&`QAeVl?6~RHR+yiF z^Fw=ffD$5&rn5)&MYkB+9B`h>AEqrtx z;=0e+{{YUjF1x+PBz>c2?&-@pNFH>am;u-i%CqibIt8Ryd$YS9n5NGo$kNFeGkBkM zso^B;WRk*4?Ml-Kt}$>#lEI+N$lx?bH(^N8EwQJQ}F*52Kv00{`dr|x$> zl(SYEy|y4N9Cd5fh-zCX2W0YsgmxtFy$`}`U1-mBBR7;j9-oKv`L$zNTE?Wi?oRnN zttz~RjCJf=w~nJ6NTR(-CRl{Ngtmb0!}3pgB8kxIcGFD_+8A5C$(mb4Z!75 zUO_R?T!m;&8NudKNM!6P!j!T`@oHV4v$=f#0KU4&yH$z6gpVn)x4my#O7g**j2ood zjqsdg)8yf?Zo=2EAGtJEAF#W{REo>n6Krm0PwM&5>6;68d4n+lMK8pzS#233+D073 zOqo2g(yOS8!X7q;DH%kzEJ?Deh_A4$eG=HgD4rN+)e*=%!K=v}ve=lU16bsF(uN+% z!@n)K$Qi0V`0Akq7PEfAjy}<&=do8>{kkMGtR7=7?s5mU0_GRGNNv|-0BS!je~l~g zE*nb9MUl+|l*mYeRmaHvwCcom6kGUbkZ*mz(m%QBi%uPEP+Z+*`oO7R=}i2nfb zt#Gjoi+)urGoww9bGX7Z&^ zDpSbRF`Uaymul^1G)W*?Ll{d4-`+jMG#l-Xa2HH>w*35c&s|OSh78{j=UpAWvf1ZB z$TTMptrd<-hlW_ghO~4J&fYj=mK_c6O6k3L>b3zI^-+hUnw~0TUK;X4w3z&=Q}q{< zraHLec*&3JY(`%#X-jv*8+^d74!zkyJ=*dY$DQ@D#qXnyzRdyoky!j1#6SK_>z3)7 zfz~xDPOi1l0Dh*HqMP+Nw20`wF42)#`D0qq`^Ly8wmCfb zAN6%*eR1e-k3Bn3q)`{CbGhYPV*ba-G1wJx_E>p4Q#5hI^W*w^k5bxFJT5g+2|kSS zCwfh%MiPs6CES#dkrX3oE$1H*M#K6e-}N``@#ORt1g^H_Qqqwiiac*ixg4Ci3`8@| z>Sm#A*AYgdG_8+#&{wL20jT5CdHMPI>G^I9)@z<>X*Gz!cu{}2Z}X>b1i(plZ{ns8 zdTC3hIUnwh2-8Aa&cqO^t>AxfdT)nW`N}68`%)Zk_sdVS#&CM?Q@%0>{Xwlwi%STF zuG%0LQ0HFMUPuN-@^$?qU3GTM@{cV*Ax8Db&7n}sCdBg3T1xdRmL3^|TDZx3bNPtn zj+>2p)M~ixvvc~C$0-e{$oONw`g&K3aX<9vJiCAX;fLW(@J<=_`@cHmW(E8+RU9Vc zEW|W~lFZ-M%t_mpJ3Y+AADK^nn)(WiI8m~4#V=T&^>i`J7fn{Js0NAn z{Y-7+F zPccMbbDgn8>kjsGBg~=LYryVM7yOXxd9A!8)B3! zGlF-a(hPI>YLytiz}hk%$qo1;@$u3!Ms3)rJ5ynj zQIJMxqq;VwLOW({_Ur(X?02ErU=$toOcUxgVYbvHaw5Xr&c0(YruDF0yStQcU;upo z0N<|$AY-L_8|^_US!7s)0;)&Gj>3(%Z=Wywbt3@ zpp>LUfG4nom01S71K@o0^h$~9IL0Y<*DHBya68nw!qu~IS-&-5Pa6aKP%}99E=)k} zPqYMB)kov4+cqs9oBg7>-Y^VODk&wiTXGzklEcu##B56!Y!k{D3Y6G_7#RW3bk_VH ztvu2&D;-UpzLYN@+Zm|t)6vIG*^;|HQy{*7O;%IwD-sDno`t684mTY z4YSCh&;?QW(pk(+FSv^m`v|0^CzVpqcG-rdh#-)G`;BxG;R7=6m1jmPjYlB!q?-^+ zXqT?vrj+l#xl}Sque-IRQahbIgR#;s3k_o$KbfW@3I?)wq-ovZm)nsUBt{+T+69X5 zYCj+_AHKiu((8!MrN=s8lPJOES(ZqrVpBu7XvW1!S2_%Sf1sZq?*hcvOvW;Mdry-d1nH9aUp{x19pQchya!V1eJD;N1w?Dr`3{3H3RU{p+s_* zUR24q7=~oA3wyQ81#(C&6ph?F9PEzJJa5m(NwNS8hn8u`z$aHCX~_#4RdMd^xpCG7 zS=f-L+h#LWkdfqejg9_D>6BqK9(g0t%9c2{m6!V%P|I`LrC=6jX(X{XwHuUaCM)fM zDFm4y>|((WgY&N)30N|jL9YRl3HW+ahZD$Et&HT9=9EI2I@Rl`?(ifFC+XP`5C>$M z^RchbOxif|-%%O}r7xm$brj>^k9<<4%(>%OxUrc30J|@05j#k}MI@O@y@yhCBp(Vv z*XN^&)(b+hpHmKW5o!LTer~78fk!U%#>OqFrS5+DT`UPLJhFoz_cq1d+ikYdBS+(` zxS}CUy1g~D*9s#M#4tHjv*~5IQZ48w%S8V4siAN4SC*iI@N+CbR4=E(kXA z?;bX&-QN<~yoKu$ujuivvleL)gpvTgu_}^Po!tOa{=?_2pcl?FXDBzV=MuG=!xnWD zL1i+cj`D#xbIFU+)SX`=C6(E%zemh;8UBqk406gsUFZD?vIJdx#gm@$~9aluOpOrkJ z^+OGw$F>`qVq!?-LaTbJ_rV;jKFK0LrfQO@Bkw{$A0wv|i(B00E5#T+Xfkk%%V!c< zx7~xvqKmLcj=3Y^?*5{h%EpzrCOyuF?m}F46?7xdfz~9k=#aI$A1|eB!6A|$w+rjW z`KQks=9WZ>QzvKK_e#ZID9@`fOgmN>r}6~w$l&aqp0zA5TpumiTU#v=trvv-2PSQOi5%~Maw54 z?@tM);C6kiMEO;(Bya6UBH1C9_#J3SmQx_Lk-~wtarxFvLy1P_ z>M(KG^2H`xz(<2wUY1s-TNZ%WHIcV9TQPm&$^gx^&?>|78=dw@&Y+iiV>kl&4tl(e{L);)aU=qEAO$;) zwVij1c99fn4r$v6hbv)nP{{YPu8IYT-D}pQKqSq{9w;+Wgg z0?QmUrIA$s0MlkENks)8$%Ft7fj&3Yo^Q3pbe+XsIJ}k*7TFt$Tz)T@Tx53+DscBB zhU|Cca2;KHCyk*Y`=fH;H<9Ik9ZK6v9DZRN@~S&(a+_#0g^Z|6e)mSZF*vd2v zV@dBv8DV9CbK4^z?P0IEy#D-;NKfgh8vnH|d<9fQH!y%L$gGm)}9AhmVY(-a+$)PM$%CYLC z6H#c7>+W5dfzgQg0`V{_WUA&0e@%7NsO`RbCz6HBj98+5BdEzq|rS0$)?y;{|B?OI2M&1mgQ zex4?b{{X9=$Lge3-1jy7hVR)Se2>S^LU_WkB($wM5E+WeXV{w64*W7 z+_1lLcR5W4FWs#%@_Y`ZIFsj7f%O!`mZ-`WI!U=EA1g7he;g}Xl=dyiWK?9{!j?cZ zz@IzUpFIM5i+h&QB7l3*Ac|;421ubP;Bj;qOM2AL1bZIiI-;p7sK`#%C76wmjgFbN zw6Ik#DHI8=XAOj1sZuMLbaJ(BiRE$I5oci0o&NwtJ;3&Yb-jPvqsI2JlcBfe)`IJB z<1Rfs>sG7EB(f)`la?wnE~Zww?1fc3a~lOd6a%t<`SlBHeDZn8C*9PG>qSu+k1};M zavHyKE@Hl0EUzlE7&1;GjnDw^+@JWtiTUx@g}u7D`%YYZ$f`BO?7#TRsy{jyQK>6P zC`mOp?<_sXX?6J#=&;*%gZF$No`@~1-2KTbw|b}AZh!_|*XSwf$LjJTq8gdXQN)iN zZ5+$(`{9rDkg7r3AI`>yp0#YP?}wr(KttSSvae>d9=T)n4fZuHNr8$nPPS*8VkotT ztzF=rv==PHVVJOvG_O?`vVOuw{uB+LJ#=G-LV*m|9I>6b*Erl>W2vp}wH#x;NSD1% zrNzYb@pUOpa}`>fMGk8j9xok~gZdigI!fUzT5+=BNY`%3{B+I7h*O5dV6tFX1HJ_# z4+58oef{)$Vq?jFdTio8ple~Q-m{*|N0Sd;#Fc4Lr{4@&7mWUnqPbNu#}vBmZ#oC( ztg8AY9RC*lV9Tro{q1ExD-zMF61ZV72(woPuj$^cugak(eG zbi>h4SU6XxJX)_It8WoQD!%4FH&K!b*PzQ*LGO^^Llm=*11N(Hh39)Me(918HN!XFg+PAp(6J9Wpy{t)Y z6lCfC?W%FC1i%!LB-rp|EBE+1Uf@rW=ivAqZ>c2UV_{sbWd7ncQCaBYtXL5?8!07$ z7!qHBnK3ROdr4+94YswR+wyt=cv*-!)tWh5m5)&)L8kUYg5f8PjO^874F2NnBvp1G zk~`IaPaiwiLTejKgQ`#o$5B-rR`L}dTa_cR{{Wh&KCyaf!DG6KW0)-|ED7ZU{upVY zpw;8pj_yv7?5r8I2Y4P&T#p>!oFwG|3n@6|Uq|rgiFo5%5)irr&5usC8RC5i^*Jd) zV~pgm*V5RE-D*)<5k1O|7u%9K5ksLW-_x*1{rbqZ@Shxn1H;`6MD-j006Et|FO0Y| zF^h@D!PIYpG5-K{YB+S#dQ-({)%6a}PX$~S66G>?CACgF^GVp4`oVjo_TFRiPKRBE zg|*KMgZ}`|P6ULGn63|uJ-;5jGVqx55FCmAZ&A0dUb$g#a^tGdGvU4b3IojdgFBRZl)ys>5sQ zX6*9Rt&-yva=0opEqAwRDA&m3D!caCw>NEBdu8k;!tCpRK6>G8{7}rvZ6=nDj{9Ss zch}w%ws7}x>W)?10o-z?;<+{{V?YAC&OwAoDd5-lZNq7sTJOaxfJm89GLwmB`sj;mfp| ze;f0LH0SuHFNzajYZ1+g1nM2f6gA zvVJ;d#@-9q%Ul7dQn&XP7IFh>JT*=|w zKzg+Le`@Fl5U`ivAR0%Wu2QKhcAU_tCv3_J{D1&{?DTuEv*>I;K zm$Dg|Ekj(|Sk_2lSVJiROi_Tbsf;lE?D+k^w^R=x1T|g=QJAphnm5LBP(xztq{tpP ztAr#eWqBmu+QgpLAbfc70%wFX30#S^S z1YpYw=#SWR%(DlNIi+o_)&?ynDr=j<6^;pC-6|wyVo`j!8~cIS01wZPKaQ7KLZ+^i z+>wb}TAxK2_oAA-4zmY~+MnM-R5HmKR*jW`C41BlB!WolnG!FUJ{t02d2*~llY(1x zmNp_!dLgpoxPZX6$YMtIy&pOrdX!=grN%QJA^CrX3@ zK_mP0!%w4PMxW;Nm9?u9(nvj7=lttU_Rg)FYfb{N)w3!yD)?z6?*W1K$YlzfZG3nh zdJeOz5gm;gV^0W1b@<2TFnb zG^t{;2ashCdTl07y(=|t+wHLo5lRIml)({~v?;OOvK{M>AZy7#_L$s6pprA~Rk@B! zh_pkplbUkma?)I<40!8BqLv9e8*ASEin4nJ_PZi7d$qCt{d!j}y5l0H5JUhY)yv+S z_~WPb^x>Cuv`HNDvk5KWHcWbhE~i12l)?F~HfW@x>)VV&UYwGu z7^BBbs3^RU4*MR|iy|~up-+Y%xZjS62?)kn22&MT<}8{UY;&ae>trRI+Mu%jp&AHn zRSmf+J%UQDh3-D&4ejWiXaV>hdIyy(tZrgBUf;D>?JALkVX+iLCs5q?Uae^($W-6! z@yVuUi4c;5?#_)R56W*Mp7g(B>e9Fg9I*SEc^yon8% zP*yAHbvAv@*fJr3m^+@+p`4}e^ zW+jLqkbf#puV%qYOB5_ERPXJMa0hz#57g2s%vs~?e&Ms=9iE6)NDOxr6xXb`UR2|o z9gL2w0;97-kiSCOGXy1Sq;iWD7`XyLQe)(SqCakr7*GZX3!Kod1nxmxWq(>kxRx^c zhkEM2rCSS17}fg`bUR?|0FW5IiC{DU=nfi5jHC@Ckg0=IQ2M?$JJZ^q9Q{$6IjvTv z)oTNt$V!579f*mJQCme>+D!%r{-Lr5Ohyu6mfLQf>XU{;=&LR0bl#Y8npHAwmy;dN zvH3Zk-ePk!a($mMLzcu?;V0k@)BdaN4p6F!ryK__M^fzA0^23{gXJ2XDfg zRjy%H&6{;_b|9-HaICfR)|nq4W<~~6Qbwm0=oXk;an8UE*io`|(~OHC2OrLVtz%py-e#~# zVcN9Jt~MO^>n^%oGs@6lu|TT}2~@`n**koR+^AcgI3lq%-Lpf);F_giLD#8?%boK_lsB0g$$Dbb~eI1;^6F30>06%J0 zD^qtbmgJ2*&vVw8GGsDW#8xsqib_T2Rw9s!8u1dWOmW8_^>&oj&--0y3#xf-+=kEnn9|L1wKRsP%Xog>LReL4kBs%6d z9I^P;mR7!9YUFVeEjtjoltC1WskNbOK&o{UN1{w#RmaDj^&~9k&V=B0sRixV?Dq?; zPg6^4otnH@>22uP!pg}Uk&VZ18Ei7o8nd@^#K|jtr^@TkMR~LpbJO#sR?Wd>QTIou zYH9PFzD8IoWoeIoF~~nu?6N8DR6>2w6bURl5bosE4YQ?eeLAh|?63 zC}~iP( zd~{g`?S{p$Dda--1zRz&-m{#oqu{%_e6{_*$KjIqzPm;3f z4Omo;*kvle%j_h^(Lxli$I1TyJ!l3G3Az5^SrVkiQEWaA^&V$qe1+(jBC?6>LRl<5 zhAOH*=@2Uz<3-=+@HN(S>@FYFPMp@Iyph_*RYqExIU77<92N@R&FkCtWK$%n2?PS( z;-N(#jSYF}Dbm!9AcEA*yWYs!`9wZcJXPt~Sps{JG^~u&?oVFDYb6t5VYd*DM1R7D zBroUj)In`5iL5qIRLLw#;h1iE)4vlMwWO~csFog_Q6(?5{**^EJ1e+wh4wZy-bbFb z5r#u4!VZ*_fu#%?at=tO?Ee5OYWL=n9{aOJ-Bz4175yNu2~?WH$O{j0Sx1lt_4(;* ziJC_zR#UYW3n*Ryq-k#c^wfr`1z06(LrU%i;h!B2B}QPQZW zRh~nL$Ky?z&A1T8rQVYw$uZ+e zuS;zteGGRPI`^)2u_=E=Tnh&_5@p;fP8M z^63f%FD1iUVi_n($Vpc?q4IhPW}cjT)z40;cG9Tjm@;}zFCSjQ}Vh_Y9b1O)w|c4G><-{k!DsGL`a zN<=}Fq1*ASc{m3f59czSX}){nqx_UfN`Kbk?niQ+f=o>mf=Jbg@}vgs@K;BEKOG}; zs@3(v!^&e@)_S;v0y)=B%60j5sMDA6$ZW=P_@oPCw99_1KoKW^&pb-3dtDSB;ragn zW7aM8tY#y19?i_+wXANWM=)J(2Ef#36^E}Rb|96Znj#q`iTfl*Ao*5P+!zM_-C_dE ziIm~1ZCA`18+0^n+{qcSVg2c+J=> zmV1DeKqs|3j)`INr|>*4&p`uis(I0>hFB%=p`n^LhEf?>StJ@HZ{VTP-ufaCrze$E zrg_qqHKB$q+?G#rC1|2#8Xo569jW>7=)QWkL1N$ALv?uo$4W-KZSG7Ybx73!ju>JA zVTv^awPR3E^!`uhsCfeoBao{{9z9#?p)t|DnWV>TsK_iaqZ76Fy~O~4ce0_p^h;0) z@|fWXs@yP*OxVi-Ry8uTF*U1kJ|e4T@}+8*@9SzL3)^5IJMJ4LkB-rdMH6k6J2 zQR`fURsy}Pxu3h=YSGIrnY@tR9r=^o)+Ob%{7&apu=D}|6;QcfA+yS}w{v!xg4vPMltq`ay%ajFTn*@$ z<0H8E+idm0UEDRr(@su7J6F+ct&-NMTe}KE%+#igbJB2(rJD zMwJQJ1M|MU69NewsE@wFgQ$CFMh)aR0F$%i>t7&zdGqnqE(7P@yi||{AFOJ{EP~)I zUO6Q1EAo3{kOBtvpv8aBLAdy`Xt0cfibSvNHOQE-_SR1G+(7$K6Rr6Hf!6vwV+*UT r0lp*JwUR}Zq>+zvNL{tD^3CJt$=Z*P+p3Y1k>3?y2pB)l&cFZJF97uG literal 0 HcmV?d00001 diff --git a/crates/nockup/install-replit.sh b/crates/nockup/install-replit.sh new file mode 100644 index 000000000..9e7944995 --- /dev/null +++ b/crates/nockup/install-replit.sh @@ -0,0 +1,444 @@ +#!/bin/bash + +set -euo pipefail + +: "${HOME:=$(getent passwd "$(whoami)" | cut -d: -f6)}" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Configuration +GITHUB_REPO="nockchain/nockchain" +RELEASE_TAG="stable-build-50c9ed33893494fdc13557fa78a9aa3e4e8b0a1f" +VERSION="0.4.0" +CHANNEL="stable" +CONFIG_URL="https://raw.githubusercontent.com/nockchain/nockup/refs/heads/master/default-config.toml" +# Function to print colored output +print_info() { + echo -e "${BLUE}ℹ️ $1${NC}" +} + +print_success() { + echo -e "${GREEN}✅ $1${NC}" +} + +print_warning() { + echo -e "${YELLOW}⚠️ $1${NC}" +} + +print_error() { + echo -e "${RED}❌ $1${NC}" +} + +print_step() { + echo -e "${CYAN}🚀 $1${NC}" +} + +# Function to detect platform and architecture (Replit-specific) +detect_platform() { + local arch + local os + local target + + # Replit typically runs on x86_64 Linux + case "$(uname -m)" in + x86_64|amd64) + arch="x86_64" + ;; + arm64|aarch64) + arch="aarch64" + ;; + *) + print_warning "Unsupported architecture: $(uname -m), defaulting to x86_64" + arch="x86_64" + ;; + esac + + # Replit is Linux-based + case "$(uname -s)" in + Linux) + os="unknown-linux-gnu" + ;; + *) + print_warning "Non-Linux OS detected: $(uname -s), defaulting to Linux" + os="unknown-linux-gnu" + ;; + esac + + target="${arch}-${os}" + echo "$target" +} + +# Function to check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Function to download file +download_file() { + local url="$1" + local output="$2" + + if command_exists curl; then + curl -fsSL "$url" -o "$output" + elif command_exists wget; then + wget -q "$url" -O "$output" + else + print_error "Neither curl nor wget found. Please install curl." + exit 1 + fi +} + +# Function to create temporary directory +create_temp_dir() { + local temp_dir + if command_exists mktemp; then + temp_dir=$(mktemp -d) + else + temp_dir="/tmp/nockup-install-$$" + mkdir -p "$temp_dir" + fi + echo "$temp_dir" +} + +# Function to setup toolchain directory with channel manifests +# Function to setup toolchain directory with channel manifests +setup_toolchain() { + local toolchain_dir="$HOME/.nockup/toolchains" + + # Create toolchain directory if it doesn't exist + mkdir -p "$toolchain_dir" + + print_step "Setting up toolchain directory" + print_info "Fetching latest channel manifests from GitHub releases" + + # Function to get latest manifest for a channel + get_latest_manifest() { + local channel="$1" + local manifest_file="${channel}-manifest.toml" + local output_file="${toolchain_dir}/channel-nockup-${channel}.toml" + + print_info "Fetching latest ${channel} manifest..." + + # Get latest release for this channel + local api_url="https://api.github.com/repos/nockchain/nockchain/releases" + local temp_releases="/tmp/releases_${channel}.json" + + if ! download_file "$api_url" "$temp_releases" 2>/dev/null; then + print_warning "Failed to fetch releases from GitHub API for ${channel}" + return 1 + fi + + # Extract latest tag for this channel + local latest_tag="" + if command_exists grep && command_exists sed; then + latest_tag=$(grep -o "\"tag_name\":\"${channel}-build-[^\"]*\"" "$temp_releases" | \ + sed 's/"tag_name":"\([^"]*\)"/\1/' | head -1) + fi + + rm -f "$temp_releases" + + if [[ -z "$latest_tag" ]]; then + print_warning "No ${channel} releases found" + return 1 + fi + + local manifest_url="https://github.com/nockchain/nockchain/releases/download/${latest_tag}/${manifest_file}" + + print_info "Downloading from: $manifest_url" + if download_file "$manifest_url" "$output_file" 2>/dev/null; then + print_success "Downloaded: channel-nockup-${channel}.toml" + return 0 + else + print_warning "Failed to download ${channel} manifest" + return 1 + fi + } + + # Download stable and nightly manifests + local channels=("stable" "nightly") + local success_count=0 + + for channel in "${channels[@]}"; do + local output_file="${toolchain_dir}/channel-nockup-${channel}.toml" + + if [[ -f "$output_file" ]]; then + print_info "Toolchain file already exists: channel-nockup-${channel}.toml" + ((success_count++)) + continue + fi + + if get_latest_manifest "$channel"; then + ((success_count++)) + else + # Create minimal fallback for stable channel only + if [[ "$channel" == "stable" ]]; then + print_info "Creating minimal channel-nockup-stable.toml fallback" + cat > "$output_file" << EOF +# Stable channel configuration for nockup +[channel] +name = "stable" +version = "$VERSION" + +[binaries] +nockup = "$VERSION" +hoon = "0.1.0" +hoonc = "0.2.0" +EOF + fi + fi + done + + print_success "Toolchain directory setup complete" + print_info "Toolchain files location: $toolchain_dir" +} + +# Function to setup config file +setup_config() { + local config_dir="$HOME/.nockup" + local config_file="$config_dir/config.toml" + + # Create config directory if it doesn't exist + mkdir -p "$config_dir" + + # Check if config file already exists + if [[ -f "$config_file" ]]; then + print_info "Config file already exists at: $config_file" + return 0 + fi + + print_step "Downloading default config file" + print_info "Downloading from: $CONFIG_URL" + + # Download the default config + if download_file "$CONFIG_URL" "$config_file"; then + print_success "Downloaded default config to: $config_file" + else + print_error "Failed to download default config file" + + # Create a minimal config file as fallback + print_warning "Creating minimal fallback config file" + cat > "$config_file" << EOF +# Nockup configuration file +[default] +channel = "$CHANNEL" +version = "$VERSION" +EOF + print_info "Created minimal config at: $config_file" + fi +} + +# Function to setup PATH for Replit (session-only) +setup_replit_path() { + local bin_dir="$1" + + # Set PATH for current session + export PATH="$bin_dir:$PATH" + print_success "Added $bin_dir to PATH for current session" + + # Create activation script for manual use + local activate_script="$HOME/.nockup/activate" + cat > "$activate_script" << EOF +#!/bin/bash +# Nockup environment activation script for Replit +export PATH="$bin_dir:\$PATH" +echo "✅ Nockup environment activated!" +echo "📦 nockup is now available in PATH" +EOF + chmod +x "$activate_script" + + print_info "Created activation script: $activate_script" + + # Show Replit-specific instructions + echo "" + print_info "📋 For persistent PATH in Replit, add this to your .replit file:" + echo "" + echo "[env]" + echo "PATH = \"$bin_dir:\$PATH\"" + echo "" + print_info "Or run this command to update your .replit file automatically:" + echo "echo -e '\\n[env]\\nPATH = \"$bin_dir:\$PATH\"' >> .replit" +} + +# Function to verify binary works +verify_binary() { + local binary_path="$1" + + if [[ ! -x "$binary_path" ]]; then + print_error "Binary is not executable: $binary_path" + return 1 + fi + + # Try to run nockup --help to verify it works + if "$binary_path" --help >/dev/null 2>&1; then + print_success "Binary verification successful" + return 0 + else + print_warning "Binary verification failed, but continuing anyway" + return 0 + fi +} + +# Function to update .replit file automatically +update_replit_config() { + local bin_dir="$1" + local replit_file=".replit" + + # Check if .replit exists + if [[ ! -f "$replit_file" ]]; then + print_warning ".replit file not found in current directory" + print_info "This might not be the root of your Replit project" + return 1 + fi + + # Check if PATH is already configured + if grep -q "PATH.*nockup" "$replit_file" 2>/dev/null; then + print_info "PATH already configured in .replit file" + return 0 + fi + + # Add [env] section if it doesn't exist + if ! grep -q "^\[env\]" "$replit_file" 2>/dev/null; then + echo "" >> "$replit_file" + echo "[env]" >> "$replit_file" + fi + + # Add PATH configuration + echo "PATH = \"$bin_dir:\$PATH\"" >> "$replit_file" + + print_success "Updated .replit file with PATH configuration" + print_info "nockup will be available in PATH for all future runs" +} + +# Main installation function +main() { + print_step "Starting Nockup installation for Replit" + print_info "This installer is optimized for the Replit environment" + echo "" + + # Setup config file and toolchain first + setup_config + setup_toolchain + + # Detect platform (with Replit defaults) + local target + target=$(detect_platform) + print_info "Target platform: $target" + + # Create temporary directory + local temp_dir + temp_dir=$(create_temp_dir) + print_info "Using temporary directory: $temp_dir" + + # Cleanup function + cleanup() { + if [[ -n "${temp_dir:-}" ]]; then + rm -rf "$temp_dir" + fi + } + trap cleanup EXIT + + # Construct download URL + local archive_name="nockup-${CHANNEL}-${VERSION}-${target}.tar.gz" + local download_url="https://github.com/${GITHUB_REPO}/releases/download/${RELEASE_TAG}/${archive_name}" + local archive_path="${temp_dir}/${archive_name}" + + print_step "Downloading Nockup binary" + print_info "URL: $download_url" + + # Download the archive + if ! download_file "$download_url" "$archive_path"; then + print_error "Failed to download Nockup binary" + print_info "Please check your internet connection" + exit 1 + fi + + print_success "Downloaded Nockup archive" + + # Extract the archive + print_step "Extracting Nockup binary" + + if ! tar -xzf "$archive_path" -C "$temp_dir"; then + print_error "Failed to extract archive" + exit 1 + fi + + # Find the nockup binary in the extracted files + local nockup_binary + nockup_binary=$(find "$temp_dir" -name "nockup" -type f | head -1) + + if [[ -z "$nockup_binary" ]]; then + print_error "Could not find nockup binary in extracted archive" + print_info "Archive contents:" + ls -la "$temp_dir" + exit 1 + fi + + print_success "Extracted Nockup binary: $nockup_binary" + + # Create installation directory + local install_dir="$HOME/.nockup/bin" + local nockup_path="$install_dir/nockup" + + print_step "Installing Nockup binary" + mkdir -p "$install_dir" + + # Copy binary to installation directory + cp "$nockup_binary" "$nockup_path" + chmod +x "$nockup_path" + + print_success "Installed Nockup to: $nockup_path" + + # Verify the binary works + verify_binary "$nockup_path" + + # Setup PATH for Replit + setup_replit_path "$install_dir" + + # Try to update .replit file automatically + if update_replit_config "$install_dir"; then + print_info "Automatic .replit configuration successful" + else + print_warning "Could not automatically update .replit file" + print_info "You may need to add PATH configuration manually" + fi + + # Set channel and run install + print_step "Setting channel to $CHANNEL and running installation" + if "$nockup_path" channel set "$CHANNEL" && "$nockup_path" install; then + print_success "Nockup installation completed successfully!" + else + print_error "Installation failed" + print_info "You can try running manually:" + print_info " $nockup_path channel set $CHANNEL" + print_info " $nockup_path install" + exit 1 + fi + + # Final instructions + echo "" + print_success "🎉 Nockup has been successfully installed for Replit!" + echo "" + print_info "✅ nockup is available in your current session" + print_info "✅ PATH has been configured for future Replit runs" + echo "" + print_info "🚀 Next steps:" + print_info " 1. Verify installation: nockup --help" + print_info " 2. Create a project: cp example-manifest.toml my-project.toml" + print_info " 3. Initialize project: nockup start my-project.toml" + print_info " 4. Build and run: nockup build my-project && nockup run my-project" + echo "" + print_info "📁 Installation directory: $install_dir" + print_info "📄 Configuration file: $HOME/.nockup/config.toml" + print_info "🔧 Activation script: $HOME/.nockup/activate" +} + +# Check if we're being sourced or executed +if [[ "${BASH_SOURCE[0]:-$0}" == "${0}" ]]; then + main "$@" +fi \ No newline at end of file diff --git a/crates/nockup/install.sh b/crates/nockup/install.sh new file mode 100644 index 000000000..fb28e9f62 --- /dev/null +++ b/crates/nockup/install.sh @@ -0,0 +1,516 @@ +#!/bin/bash + +set -euo pipefail + +: "${HOME:=$(getent passwd "$(whoami)" | cut -d: -f6)}" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Configuration +GITHUB_REPO="nockchain/nockchain" +VERSION="unknown" +RELEASE_TAG="unknown" +CHANNEL="stable" +CONFIG_URL_MACOS="https://raw.githubusercontent.com/nockchain/nockup/refs/heads/master/default-config-aarch64-apple-darwin.toml" +CONFIG_URL_LINUX="https://raw.githubusercontent.com/nockchain/nockup/refs/heads/master/default-config-x86_64-unknown-linux-gnu.toml" +# Determine config URL based on OS +if [[ "$(uname -s)" == "Darwin" ]]; then + CONFIG_URL="$CONFIG_URL_MACOS" +else + CONFIG_URL="$CONFIG_URL_LINUX" +fi + +# Function to print colored output (to stderr so it doesn't interfere with function returns) +print_info() { + echo -e "${BLUE}ℹ️ $1${NC}" >&2 +} + +print_success() { + echo -e "${GREEN}✅ $1${NC}" >&2 +} + +print_warning() { + echo -e "${YELLOW}⚠️ $1${NC}" >&2 +} + +print_error() { + echo -e "${RED}❌ $1${NC}" >&2 +} + +print_step() { + echo -e "${CYAN}🚀 $1${NC}" >&2 +} + +# Function to check if command exists +command_exists() { + command -v "$1" >/dev/null 2>&1 +} + +# Function to download file +download_file() { + local url="$1" + local output="$2" + + if command_exists curl; then + curl -fsSL "$url" -o "$output" + elif command_exists wget; then + wget -q "$url" -O "$output" + else + print_error "Neither curl nor wget found. Please install one of them." + return 1 + fi +} + +# Function to create temporary directory +create_temp_dir() { + local temp_dir + if command_exists mktemp; then + temp_dir=$(mktemp -d) + else + temp_dir="/tmp/nockup-install-$$" + mkdir -p "$temp_dir" + fi + echo "$temp_dir" +} + +# Function to get latest release for channel +get_latest_release() { + local channel="$1" + + print_step "Fetching latest ${channel} commit from branch..." + + # Determine branch name based on channel + local branch="master" # or "nightly" for nightly channel + if [[ "$channel" == "nightly" ]]; then + branch="nightly" + fi + + # Get latest commit SHA from the branch + local commits_url="https://api.github.com/repos/${GITHUB_REPO}/commits/${branch}" + local temp_commit="/tmp/nockup-commit-$$.json" + + if ! download_file "$commits_url" "$temp_commit"; then + print_error "Failed to fetch latest commit from branch ${branch}" + return 1 + fi + + local latest_commit_sha="" + latest_commit_sha=$(grep -o "\"sha\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$temp_commit" | \ + sed 's/"sha"[[:space:]]*:[[:space:]]*"\([^"]*\)"/\1/' | head -1) || true + + rm -f "$temp_commit" + + if [[ -z "$latest_commit_sha" ]]; then + print_error "Could not determine latest commit SHA" + return 1 + fi + + print_info "Latest commit: ${latest_commit_sha:0:7}" + + # Now construct the expected tag name + local expected_tag="${channel}-build-${latest_commit_sha}" + + print_info "Looking for release: $expected_tag" + + # Verify this release exists + local releases_url="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${expected_tag}" + local temp_release="/tmp/nockup-release-$$.json" + + if ! download_file "$releases_url" "$temp_release"; then + print_error "Release not found for tag: $expected_tag" + print_info "The build may still be in progress" + rm -f "$temp_release" + return 1 + fi + + # Extract version from release name + local version="" + version=$(grep -o "\"name\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$temp_release" | \ + sed 's/"name"[[:space:]]*:[[:space:]]*"\([^"]*\)"/\1/' | head -1) || true + + if [[ -z "$version" ]]; then + version=$(echo "$expected_tag" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') || version="latest" + fi + + rm -f "$temp_release" + + echo "$expected_tag|$version" +} + +# Function to detect platform and architecture +detect_platform() { + local arch + local os + local target + + case "$(uname -m)" in + x86_64|amd64) + arch="x86_64" + ;; + arm64|aarch64) + arch="aarch64" + ;; + *) + print_error "Unsupported architecture: $(uname -m)" + print_info "Supported architectures: x86_64, aarch64" + exit 1 + ;; + esac + + case "$(uname -s)" in + Linux) + os="unknown-linux-gnu" + ;; + Darwin) + os="apple-darwin" + ;; + *) + print_error "Unsupported operating system: $(uname -s)" + print_info "Supported operating systems: Linux, macOS" + exit 1 + ;; + esac + + target="${arch}-${os}" + echo "$target" +} + +# Function to setup toolchain directory with channel manifests +setup_toolchain() { + local toolchain_dir="$HOME/.nockup/toolchains" + + mkdir -p "$toolchain_dir" + + print_step "Setting up toolchain directory" + print_info "Fetching latest channel manifests from GitHub releases" + + get_latest_manifest() { + local channel="$1" + local manifest_file="${channel}-manifest.toml" + local output_file="${toolchain_dir}/channel-nockup-${channel}.toml" + + print_info "Fetching latest ${channel} manifest..." + + local api_url="https://api.github.com/repos/nockchain/nockchain/releases" + local temp_releases="/tmp/releases_${channel}.json" + + if ! download_file "$api_url" "$temp_releases"; then + print_warning "Failed to fetch releases from GitHub API for ${channel}" + return 1 + fi + + local latest_tag="" + # ✅ Fixed: Added [[:space:]]* to handle spaces in JSON + latest_tag=$(grep -o "\"tag_name\"[[:space:]]*:[[:space:]]*\"${channel}-build-[^\"]*\"" "$temp_releases" 2>/dev/null | \ + sed 's/"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)"/\1/' | head -1) || true + + rm -f "$temp_releases" + + if [[ -z "$latest_tag" ]]; then + print_warning "No ${channel} releases found" + return 1 + fi + + local manifest_url="https://github.com/nockchain/nockchain/releases/download/${latest_tag}/${manifest_file}" + + print_info "Downloading from: $manifest_url" + if download_file "$manifest_url" "$output_file"; then + print_success "Downloaded: channel-nockup-${channel}.toml" + return 0 + else + print_warning "Failed to download ${channel} manifest" + return 1 + fi + } + + local channels=("stable" "nightly") + local success_count=0 + + for channel in "${channels[@]}"; do + print_info "Processing channel: $channel" + local output_file="${toolchain_dir}/channel-nockup-${channel}.toml" + + if [[ -f "$output_file" ]]; then + print_info "Toolchain file already exists: channel-nockup-${channel}.toml" + ((success_count++)) || true + continue + fi + + if get_latest_manifest "$channel"; then + ((success_count++)) || true + else + if [[ "$channel" == "stable" ]]; then + print_info "Creating minimal channel-nockup-stable.toml fallback" + cat > "$output_file" << EOF +# Stable channel configuration for nockup +[channel] +name = "stable" +version = "$VERSION" + +[binaries] +nockup = "$VERSION" +hoon = "0.1.0" +hoonc = "0.2.0" +EOF + ((success_count++)) || true + fi + fi + done + + print_success "Toolchain directory setup complete" + print_info "Toolchain files location: $toolchain_dir" +} + +# Function to setup config file +setup_config() { + local config_dir="$HOME/.nockup" + local config_file="$config_dir/config.toml" + + mkdir -p "$config_dir" + + if [[ -f "$config_file" ]]; then + print_info "Config file already exists at: $config_file" + return 0 + fi + + print_step "Downloading default config file" + print_info "Downloading from: $CONFIG_URL" + + if download_file "$CONFIG_URL" "$config_file"; then + print_success "Downloaded default config to: $config_file" + else + print_warning "Failed to download default config file, creating fallback" + cat > "$config_file" << EOF +# Nockup configuration file +channel = "$CHANNEL" +architecture = "$(uname -m)" +EOF + print_info "Created minimal config at: $config_file" + fi +} + +# Function to add to PATH +add_to_path() { + local bin_dir="$1" + local shell_rc="" + + if [[ -n "${BASH_VERSION:-}" ]]; then + shell_rc="$HOME/.bashrc" + elif [[ -n "${ZSH_VERSION:-}" ]]; then + shell_rc="$HOME/.zshrc" + elif [[ "$SHELL" == *"zsh"* ]]; then + shell_rc="$HOME/.zshrc" + elif [[ "$SHELL" == *"bash"* ]]; then + shell_rc="$HOME/.bashrc" + else + shell_rc="$HOME/.profile" + fi + + local path_entry="export PATH=\"$bin_dir:\$PATH\"" + + if [[ -f "$shell_rc" ]] && grep -q "$path_entry" "$shell_rc" 2>/dev/null; then + print_info "PATH already configured in $shell_rc" + else + if [[ -w "$(dirname "$shell_rc")" ]]; then + touch "$shell_rc" + + echo "" >> "$shell_rc" + echo "# Added by nockup installer" >> "$shell_rc" + echo "$path_entry" >> "$shell_rc" + + print_success "Added $bin_dir to PATH in $shell_rc" + else + print_warning "Could not modify $shell_rc (permission denied)" + print_info "Please manually add this line to your shell configuration:" + print_info " $path_entry" + fi + fi + + local activate_script="$HOME/.nockup/activate.sh" + cat > "$activate_script" << 'EOF' +#!/bin/bash +# Nockup environment activation script +# Usage: source ~/.nockup/activate.sh +export PATH="$HOME/.nockup/bin:$PATH" +echo "✅ Nockup environment activated!" +echo "📦 nockup is now available in PATH" +EOF + chmod +x "$activate_script" + + print_success "Created activation script: $activate_script" +} + +# Function to verify binary works +verify_binary() { + local binary_path="$1" + + if [[ ! -x "$binary_path" ]]; then + print_error "Binary is not executable: $binary_path" + return 1 + fi + + if "$binary_path" --help >/dev/null 2>&1; then + print_success "Binary verification successful" + return 0 + else + print_warning "Binary verification failed, but continuing anyway" + return 0 + fi +} + +# Function to setup GPG key (Linux only) +setup_gpg_key() { + if [[ "$(uname -s)" != "Linux" ]]; then + return 0 + fi + + if ! command_exists gpg; then + return 0 + fi + + local gpg_key="A6FFD2DB7D4C9710" + + if gpg --list-keys "$gpg_key" >/dev/null 2>&1; then + return 0 + fi + + gpg --keyserver keyserver.ubuntu.com --recv-keys "$gpg_key" >/dev/null 2>&1 || true +} + +# Main installation function +main() { + print_step "Starting Nockup installation" + print_info "This installer works on Linux and macOS systems" + echo "" >&2 + + # Try to get latest release + local release_info + set +e # Temporarily disable exit on error + release_info=$(get_latest_release "$CHANNEL") + local get_release_status=$? + set -e # Re-enable exit on error + + if [[ $get_release_status -ne 0 ]] || [[ -z "$release_info" ]]; then + print_error "Failed to fetch latest release information" + print_info "Please check your internet connection and try again" + exit 1 + fi + + RELEASE_TAG=$(echo "$release_info" | cut -d'|' -f1) + VERSION=$(echo "$release_info" | cut -d'|' -f2) + + print_success "Latest ${CHANNEL} release: ${RELEASE_TAG}" + print_success "Version: ${VERSION}" + echo "" >&2 + + setup_config + setup_toolchain + print_info "DEBUG: After setup_toolchain" + setup_gpg_key + print_info "DEBUG: After setup_gpg_key" + + local target + target=$(detect_platform) + print_info "Target platform: $target" + + local temp_dir + temp_dir=$(create_temp_dir) + print_info "Using temporary directory: $temp_dir" + + cleanup() { + if [[ -n "${temp_dir:-}" ]]; then + rm -rf "$temp_dir" + fi + } + trap cleanup EXIT + + local archive_name="nockup-${CHANNEL}-latest-${target}.tar.gz" + local download_url="https://github.com/${GITHUB_REPO}/releases/download/${RELEASE_TAG}/${archive_name}" + local archive_path="${temp_dir}/${archive_name}" + + print_step "Downloading Nockup binary" + print_info "URL: $download_url" + + if ! download_file "$download_url" "$archive_path"; then + print_error "Failed to download Nockup binary" + print_info "Please check:" + print_info " - Your internet connection" + print_info " - The release exists at: $download_url" + exit 1 + fi + + print_success "Downloaded Nockup archive" + + print_step "Extracting Nockup binary" + + if ! tar -xzf "$archive_path" -C "$temp_dir"; then + print_error "Failed to extract archive" + exit 1 + fi + + local nockup_binary + nockup_binary=$(find "$temp_dir" -name "nockup" -type f | head -1) + + if [[ -z "$nockup_binary" ]]; then + print_error "Could not find nockup binary in extracted archive" + print_info "Archive contents:" + ls -la "$temp_dir" >&2 + exit 1 + fi + + print_success "Extracted Nockup binary" + + local install_dir="$HOME/.nockup/bin" + local nockup_path="$install_dir/nockup" + + print_step "Installing Nockup binary" + mkdir -p "$install_dir" + + cp "$nockup_binary" "$nockup_path" + chmod +x "$nockup_path" + + print_success "Installed Nockup to: $nockup_path" + + verify_binary "$nockup_path" + + add_to_path "$install_dir" + + print_step "Setting channel to $CHANNEL and running installation" + if "$nockup_path" channel set "$CHANNEL" && "$nockup_path" install; then + print_success "Nockup installation completed successfully!" + else + print_error "Installation failed" + print_info "You can try running manually:" + print_info " $nockup_path channel set $CHANNEL" + print_info " $nockup_path install" + exit 1 + fi + + echo "" >&2 + print_success "🎉 Nockup has been successfully installed!" + echo "" >&2 + + echo -e "${YELLOW}═══════════════════════════════════════════════════════════════${NC}" >&2 + echo -e "${GREEN}To use nockup immediately in this terminal, run:${NC}" >&2 + echo "" >&2 + echo -e " ${CYAN}export PATH=\"$install_dir:\$PATH\"${NC}" >&2 + echo "" >&2 + echo -e "${YELLOW}═══════════════════════════════════════════════════════════════${NC}" >&2 + echo "" >&2 + + print_info "Next steps:" + print_info " 1. Run the export command above, OR restart your shell" + print_info " 2. Verify installation: nockup --help" + print_info " 3. Create a project: nockup start " + echo "" >&2 +} + +if [[ "${BASH_SOURCE[0]:-$0}" == "${0}" ]]; then + main "$@" +fi \ No newline at end of file diff --git a/crates/nockup/manifests/example-manifest-with-libraries.toml b/crates/nockup/manifests/example-manifest-with-libraries.toml new file mode 100644 index 000000000..1c756ce5e --- /dev/null +++ b/crates/nockup/manifests/example-manifest-with-libraries.toml @@ -0,0 +1,31 @@ +[project] +name = "Et In Arcadia Ego" +project_name = "arcadia" +version = "1.0.0" +description = "I too was in Arcadia." +author_name = "Nicolas Poussin" +author_email = "nicolas@poussin.edu" +github_username = "arcadia" +license = "MIT" +keywords = ["nockapp", "nockchain", "hoon"] +nockapp_commit_hash = "336f744b6b83448ec2b86473a3dec29b15858999" +template = "basic" + +[libraries.sequent] +url = "https://github.com/jackfoxy/sequent" +commit = "0f6e6777482447d4464948896b763c080dc9e559" + +[libraries.math] +url = "https://github.com/urbit/numerics" +branch = "main" +directory = "libmath" + +[libraries.lagoon] +url = "https://github.com/urbit/numerics" +directory = "lagoon" +commit = "7c11c48ab3f21135caa5a4e8744a9c3f828f2607" + +[libraries.bits] +url = "https://github.com/urbit/urbit" +branch = "develop" +file = "pkg/arvo/lib/bits.hoon" diff --git a/crates/nockup/manifests/example-manifest.toml b/crates/nockup/manifests/example-manifest.toml new file mode 100644 index 000000000..2e3be94a3 --- /dev/null +++ b/crates/nockup/manifests/example-manifest.toml @@ -0,0 +1,12 @@ +[project] +name = "Et In Arcadia Ego" +project_name = "arcadia" +version = "1.0.0" +description = "I too was in Arcadia." +author_name = "Nicolas Poussin" +author_email = "nicolas@poussin.edu" +github_username = "arcadia" +license = "MIT" +keywords = ["nockapp", "nockchain", "hoon"] +nockapp_commit_hash = "336f744b6b83448ec2b86473a3dec29b15858999" +template = "basic" diff --git a/crates/nockup/quick_start.sh b/crates/nockup/quick_start.sh new file mode 100644 index 000000000..ff03f39d1 --- /dev/null +++ b/crates/nockup/quick_start.sh @@ -0,0 +1,527 @@ +#!/bin/bash + +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# Global variables for project configuration +PROJECT_NAME="" +PROJECT_TEMPLATE="" +PROJECT_AUTHOR_NAME="" +PROJECT_AUTHOR_EMAIL="" +PROJECT_GITHUB_USERNAME="" +PROJECT_DESCRIPTION="" +DRY_RUN=false +NON_INTERACTIVE=false + +# Function to print colored output +print_info() { + echo -e "${BLUE}ℹ️ $1${NC}" +} + +print_success() { + echo -e "${GREEN}✅ $1${NC}" +} + +print_warning() { + echo -e "${YELLOW}⚠️ $1${NC}" +} + +print_error() { + echo -e "${RED}❌ $1${NC}" +} + +print_step() { + echo -e "${CYAN}🚀 $1${NC}" +} + +# Function to show usage +show_usage() { + cat << EOF +Usage: $(basename "$0") [OPTIONS] + +NockApp Quick Start Helper - Create a new NockApp project interactively + +Options: + -h, --help Show this help message + -n, --name NAME Project name (skips interactive prompt) + -t, --template TEMPLATE Template to use (basic|grpc|http-static|http-server|repl|chain|rollup) + -a, --author NAME Author name + -e, --email EMAIL Author email + -g, --github USERNAME GitHub username + -d, --description DESC Project description + --non-interactive Run without prompts (requires all options) + --dry-run Show what would be created without actually creating it + +Examples: + $(basename "$0") # Interactive mode + $(basename "$0") --name my-app --template basic # Partial interactive + $(basename "$0") -n my-app -t basic -a "John Doe" -e john@example.com -g johndoe -d "My app" --non-interactive + +Templates: + basic - Simple NockApp (recommended for beginners) + grpc - gRPC server and client + http-static - Static file server + http-server - Dynamic web application + repl - Interactive command line + chain - Nockchain integration (light client) + rollup - Rollup bundler + +EOF +} + +# Function to check if nockup is available +check_nockup() { + # Add nockup to PATH if not already there + export PATH="$HOME/.nockup/bin:$PATH" + + if ! command -v nockup >/dev/null 2>&1; then + print_error "Nockup not found. Please run the installer first:" + print_info " curl -fsSL https://raw.githubusercontent.com/sigilante/nockup/master/install.sh | bash" + exit 1 + fi + + print_success "Nockup found: $(which nockup)" +} + +# Function to get user input with default +get_input() { + local prompt="$1" + local default="$2" + local result + + if [[ -n "$default" ]]; then + read -p "$prompt (default: $default): " result + echo "${result:-$default}" + else + read -p "$prompt: " result + echo "$result" + fi +} + +# Function to sanitize input (remove potentially dangerous characters) +sanitize_input() { + local input="$1" + # Remove newlines, carriage returns, and other control characters + echo "$input" | tr -d '\n\r\t' | sed 's/[`$]//g' +} + +# Function to validate project name +validate_project_name() { + local name="$1" + + # Check if name is empty + if [[ -z "$name" ]]; then + echo "Project name cannot be empty" + return 1 + fi + + # Check if name contains invalid characters + if [[ ! "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then + echo "Project name can only contain letters, numbers, hyphens, and underscores" + return 1 + fi + + # Check if directory already exists + if [[ -d "$name" ]]; then + echo "Directory '$name' already exists" + return 1 + fi + + return 0 +} + +# Function to validate template +validate_template() { + local template="$1" + local valid_templates="basic grpc http-static http-server repl chain rollup" + + for valid in $valid_templates; do + if [[ "$template" == "$valid" ]]; then + return 0 + fi + done + + echo "Invalid template: $template" + echo "Valid templates: $valid_templates" + return 1 +} + +# Function to show template information +show_template_info() { + echo "" + print_info "Available NockApp templates:" + echo "" + echo " 1. basic - Simple NockApp (recommended for beginners)" + echo " Single process, minimal setup" + echo "" + echo " 2. grpc - gRPC server and client" + echo " May require multiple processes" + echo "" + echo " 3. http-static - Static file server" + echo " Serve HTML, CSS, JS files" + echo "" + echo " 4. http-server - Dynamic web application" + echo " Full web server with routing" + echo "" + echo " 5. repl - Interactive command line" + echo " Read-eval-print loop interface" + echo "" + echo " 6. chain - Nockchain integration" + echo " Connect to Nockchain network (light client)" + echo "" + echo " 7. rollup - Rollup bundler" + echo " Package NockApps for deployment" + echo "" +} + +# Function to get template choice +get_template() { + local choice + local template + + show_template_info + + while true; do + choice=$(get_input "Choose template (1-7)" "1") + + case $choice in + 1|basic) template="basic"; break ;; + 2|grpc) template="grpc"; break ;; + 3|http-static) template="http-static"; break ;; + 4|http-server) template="http-server"; break ;; + 5|repl) template="repl"; break ;; + 6|chain) template="chain"; break ;; + 7|rollup) template="rollup"; break ;; + *) + print_warning "Invalid choice. Please enter 1-7 or template name." + continue + ;; + esac + done + + echo "$template" +} + +# Function to get project details interactively +get_project_details_interactive() { + print_step "Let's create your NockApp project!" + echo "" + + # Get project name (if not already set) + if [[ -z "$PROJECT_NAME" ]]; then + while true; do + PROJECT_NAME=$(get_input "Enter your project name" "my-nockapp") + PROJECT_NAME=$(sanitize_input "$PROJECT_NAME") + + if validate_project_name "$PROJECT_NAME"; then + break + else + print_warning "$(validate_project_name "$PROJECT_NAME" 2>&1)" + fi + done + fi + + # Get template (if not already set) + if [[ -z "$PROJECT_TEMPLATE" ]]; then + PROJECT_TEMPLATE=$(get_template) + fi + + # Get author details (if not already set) + echo "" + print_info "Author information (for project metadata):" + + if [[ -z "$PROJECT_AUTHOR_NAME" ]]; then + PROJECT_AUTHOR_NAME=$(sanitize_input "$(get_input "Your name" "Your Name")") + fi + + if [[ -z "$PROJECT_AUTHOR_EMAIL" ]]; then + PROJECT_AUTHOR_EMAIL=$(sanitize_input "$(get_input "Your email" "you@example.com")") + fi + + if [[ -z "$PROJECT_GITHUB_USERNAME" ]]; then + PROJECT_GITHUB_USERNAME=$(sanitize_input "$(get_input "GitHub username" "yourusername")") + fi + + # Get description (if not already set) + if [[ -z "$PROJECT_DESCRIPTION" ]]; then + PROJECT_DESCRIPTION=$(sanitize_input "$(get_input "Project description" "A NockApp built with Nockup")") + fi +} + +# Function to validate all required fields +validate_config() { + local missing_fields=() + + [[ -z "$PROJECT_NAME" ]] && missing_fields+=("name") + [[ -z "$PROJECT_TEMPLATE" ]] && missing_fields+=("template") + [[ -z "$PROJECT_AUTHOR_NAME" ]] && missing_fields+=("author_name") + [[ -z "$PROJECT_AUTHOR_EMAIL" ]] && missing_fields+=("author_email") + [[ -z "$PROJECT_GITHUB_USERNAME" ]] && missing_fields+=("github_username") + [[ -z "$PROJECT_DESCRIPTION" ]] && missing_fields+=("description") + + if [[ ${#missing_fields[@]} -gt 0 ]]; then + print_error "Missing required fields: ${missing_fields[*]}" + return 1 + fi + + # Validate template + if ! validate_template "$PROJECT_TEMPLATE"; then + return 1 + fi + + # Validate project name + if ! validate_project_name "$PROJECT_NAME"; then + return 1 + fi + + return 0 +} + +# Function to create manifest file +create_manifest() { + local manifest_file="${PROJECT_NAME}.toml" + + print_step "Creating project manifest: $manifest_file" + + cat > "$manifest_file" << EOF +# NockApp Project Configuration +# Generated by Nockup Quick Start + +[project] +name = "$PROJECT_DESCRIPTION" +project_name = "$PROJECT_NAME" +version = "0.1.0" +description = "$PROJECT_DESCRIPTION" +author_name = "$PROJECT_AUTHOR_NAME" +author_email = "$PROJECT_AUTHOR_EMAIL" +github_username = "$PROJECT_GITHUB_USERNAME" +license = "MIT" +keywords = ["nockapp", "nockchain", "hoon"] +nockapp_commit_hash = "336f744b6b83448ec2b86473a3dec29b15858999" +template = "$PROJECT_TEMPLATE" + +# Libraries: Uncomment and modify as needed +# [libraries.sequent] +# url = "https://github.com/jackfoxy/sequent" +# commit = "0f6e6777482447d4464948896b763c080dc9e559" + +# [libraries.bits] +# url = "https://github.com/urbit/urbit" +# branch = "develop" +# file = "pkg/arvo/lib/bits.hoon" + +# [libraries.math] +# url = "https://github.com/urbit/numerics" +# branch = "main" +# directory = "libmath" +# commit = "7c11c48ab3f21135caa5a4e8744a9c3f828f2607" +EOF + + print_success "Created manifest: $manifest_file" +} + +# Function to initialize project +initialize_project() { + local manifest_file="${PROJECT_NAME}.toml" + + print_step "Initializing NockApp project with Nockup..." + + if nockup start "$manifest_file"; then + print_success "Project initialized successfully!" + return 0 + else + print_error "Project initialization failed" + print_info "You can try manually:" + print_info " nockup start $manifest_file" + return 1 + fi +} + +# Function to show next steps +show_next_steps() { + echo "" + print_success "🎉 Your NockApp project '$PROJECT_NAME' is ready!" + echo "" + print_info "📁 Project structure created in: ./$PROJECT_NAME/" + print_info "🌙 Hoon code: ./$PROJECT_NAME/hoon/app/app.hoon" + print_info "🦀 Rust code: ./$PROJECT_NAME/src/main.rs" + print_info "📋 Configuration: ./$PROJECT_NAME/manifest.toml" + echo "" + print_info "🚀 Next steps:" + echo " cd $PROJECT_NAME" + echo " nockup build ." + echo " nockup run ." + echo "" + + # Template-specific instructions + case "$PROJECT_TEMPLATE" in + "grpc") + print_info "💡 gRPC template may need multiple processes" + echo "" + ;; + "chain") + print_info "💡 Chain template connects to Nockchain network" + print_info " Uses light client - no additional setup needed" + echo "" + ;; + "http-static"|"http-server") + print_info "💡 Web server template:" + print_info " Your app will be available at a local URL after running" + echo "" + ;; + esac + + print_info "📚 To learn more:" + print_info " • Edit the Hoon kernel: $PROJECT_NAME/hoon/app/app.hoon" + print_info " • Customize the Rust code: $PROJECT_NAME/src/main.rs" + print_info " • Add libraries by editing: $PROJECT_NAME/manifest.toml" + print_info " • View documentation: nockup --help" +} + +# Function to show dry run summary +show_dry_run_summary() { + echo "" + print_info "=== DRY RUN - No files will be created ===" + echo "" + print_info "Project Configuration:" + print_info " Name: $PROJECT_NAME" + print_info " Template: $PROJECT_TEMPLATE" + print_info " Author: $PROJECT_AUTHOR_NAME" + print_info " Email: $PROJECT_AUTHOR_EMAIL" + print_info " GitHub: $PROJECT_GITHUB_USERNAME" + print_info " Description: $PROJECT_DESCRIPTION" + echo "" + print_info "Would create:" + print_info " • Manifest file: ${PROJECT_NAME}.toml" + print_info " • Project directory: ./${PROJECT_NAME}/" + print_info " • Hoon code: ./${PROJECT_NAME}/hoon/app/app.hoon" + print_info " • Rust code: ./${PROJECT_NAME}/src/main.rs" + echo "" +} + +# Parse command line arguments +parse_args() { + while [[ $# -gt 0 ]]; do + case $1 in + -h|--help) + show_usage + exit 0 + ;; + -n|--name) + PROJECT_NAME=$(sanitize_input "$2") + shift 2 + ;; + -t|--template) + PROJECT_TEMPLATE="$2" + shift 2 + ;; + -a|--author) + PROJECT_AUTHOR_NAME=$(sanitize_input "$2") + shift 2 + ;; + -e|--email) + PROJECT_AUTHOR_EMAIL=$(sanitize_input "$2") + shift 2 + ;; + -g|--github) + PROJECT_GITHUB_USERNAME=$(sanitize_input "$2") + shift 2 + ;; + -d|--description) + PROJECT_DESCRIPTION=$(sanitize_input "$2") + shift 2 + ;; + --non-interactive) + NON_INTERACTIVE=true + shift + ;; + --dry-run) + DRY_RUN=true + shift + ;; + *) + print_error "Unknown option: $1" + show_usage + exit 1 + ;; + esac + done +} + +# Cleanup function for error handling +cleanup_on_error() { + local manifest_file="${PROJECT_NAME}.toml" + + if [[ -f "$manifest_file" ]]; then + print_warning "Cleaning up manifest file: $manifest_file" + rm -f "$manifest_file" + fi +} + +# Main function +main() { + # Set up error handling + trap cleanup_on_error ERR + + print_step "NockApp Quick Start Helper" + print_info "This tool helps you create your first NockApp project" + echo "" + + # Parse command line arguments + parse_args "$@" + + # Check if nockup is available + check_nockup + + # Get project details (interactive or from args) + if [[ "$NON_INTERACTIVE" != "true" ]]; then + get_project_details_interactive + fi + + # Validate configuration + if ! validate_config; then + print_error "Configuration validation failed" + if [[ "$NON_INTERACTIVE" == "true" ]]; then + print_info "In non-interactive mode, all required fields must be provided" + show_usage + fi + exit 1 + fi + + echo "" + print_step "Creating your NockApp project..." + print_info "Project: $PROJECT_NAME" + print_info "Template: $PROJECT_TEMPLATE" + print_info "Author: $PROJECT_AUTHOR_NAME" + echo "" + + # Dry run mode + if [[ "$DRY_RUN" == "true" ]]; then + show_dry_run_summary + exit 0 + fi + + # Create manifest file + create_manifest + + # Initialize project with nockup + if initialize_project; then + show_next_steps + else + print_warning "Project creation completed with errors" + print_info "Check the manifest file and try running nockup start manually" + exit 1 + fi +} + +# Check if we're being sourced or executed +if [[ "${BASH_SOURCE[0]:-$0}" == "${0}" ]]; then + main "$@" +fi diff --git a/crates/nockup/replit/.replit b/crates/nockup/replit/.replit new file mode 100644 index 000000000..e1f779e30 --- /dev/null +++ b/crates/nockup/replit/.replit @@ -0,0 +1,11 @@ +run = "bash ./setup-nockup.sh && chmod 644 ~/.bashrc" + +[env] +PATH = "$HOME/.nockup/bin:$HOME/.cargo/bin:$PATH" + +[nix] +channel = "stable-23_11" + +deps = ["curl", "git", "screen", "python3", "rustup"] + +packages = ["gnupg"] diff --git a/crates/nockup/replit/setup-nockup.sh b/crates/nockup/replit/setup-nockup.sh new file mode 100644 index 000000000..e00e6f994 --- /dev/null +++ b/crates/nockup/replit/setup-nockup.sh @@ -0,0 +1,29 @@ +#!/bin/bash +set -e + +echo "🚀 Setting up Nockup development environment..." + +# Increase stack size to work around TLS issues +ulimit -s unlimited 2>/dev/null || ulimit -s 65536 + +# Install Rust nightly if not already installed +if ! command -v rustc >/dev/null 2>&1 || ! rustup toolchain list | grep -q nightly; then + echo "📦 Installing Rust stable toolchain..." + rustup toolchain install stable + rustup default stable + echo "✅ Rust stable installed" +else + echo "✅ Rust stable already installed" +fi + +# Install nockup +echo "📦 Installing nockup..." +curl -H "Cache-Control: no-cache" -H "Pragma: no-cache" -fsSL https://raw.githubusercontent.com/sigilante/nockup/master/install.sh | bash + +# Source the activation script to make nockup available immediately +if [ -f "$HOME/.nockup/activate.sh" ]; then + source "$HOME/.nockup/activate.sh" +fi + +echo "✅ Setup complete!" +echo "📦 nockup is now available in PATH" \ No newline at end of file diff --git a/crates/nockup/rust-toolchain.toml b/crates/nockup/rust-toolchain.toml new file mode 100644 index 000000000..aa37dcd1c --- /dev/null +++ b/crates/nockup/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "nightly-2025-02-14" +components = ["miri"] diff --git a/crates/nockup/scripts/collate-manifests.sh b/crates/nockup/scripts/collate-manifests.sh new file mode 100644 index 000000000..45e00495e --- /dev/null +++ b/crates/nockup/scripts/collate-manifests.sh @@ -0,0 +1,40 @@ +#!/bin/bash + +CHANNEL=$1 +MANIFEST_DIR=$2 +OUTPUT_FILE="$CHANNEL-manifest.toml" + +# Get nockup version dynamically +NOCKUP_VERSION=$(cargo metadata --format-version 1 --no-deps | jq -r '.packages[] | select(.name == "nockup") | .version') + +# Start with global metadata +cat << EOF > "$OUTPUT_FILE" +manifest-version = "1" +date = "$(date +%Y-%m-%d)" + +# Global package info +[pkg.nockup] +version = "$NOCKUP_VERSION" +components = ["core"] +extensions = [] + +# Profiles +[profiles.default] +components = ["core"] +[profiles.minimal] +components = ["core"] + +EOF + +# Merge all individual manifests, skipping headers and pkg definitions +find "$MANIFEST_DIR" -name "*-manifest.toml" | sort | while read manifest; do + echo "# From $(basename "$manifest")" + # Skip header lines and [pkg.xxx] sections, only take target entries + awk ' + /^\[pkg\.[^.]*\.target\./ { printing = 1 } + /^\[pkg\.[^.]*\]$/ && !/target/ { printing = 0; next } + /^manifest-version|^date|^\s*$/ && !printing { next } + printing || /^\[pkg\.[^.]*\.target\./ + ' "$manifest" + echo "" +done >> "$OUTPUT_FILE" \ No newline at end of file diff --git a/crates/nockup/scripts/generate-manifest.sh b/crates/nockup/scripts/generate-manifest.sh new file mode 100644 index 000000000..6bfdd3e99 --- /dev/null +++ b/crates/nockup/scripts/generate-manifest.sh @@ -0,0 +1,179 @@ +#!/bin/bash + +set -e # Exit on error + +BINARY=$1 +PLATFORM=$2 # linux64 or darwin64 +CHANNEL=${3:-stable} + +if [ -z "$BINARY" ] || [ -z "$PLATFORM" ]; then + echo "Usage: $0 [channel]" >&2 + echo "Example: $0 hoonc linux64 stable" >&2 + exit 1 +fi + +# Get nockchain info from environment or git +NOCKCHAIN_OWNER=${NOCKCHAIN_OWNER:-sigilante} +NOCKCHAIN_REPO=${NOCKCHAIN_REPO:-nockchain} +NOCKCHAIN_COMMIT=${NOCKCHAIN_COMMIT:-$(git rev-parse HEAD)} +NOCKCHAIN_SHORT=$(echo $NOCKCHAIN_COMMIT | cut -c1-7) +DATE=$(date +%Y-%m-%d) + +echo "Building manifest for:" >&2 +echo " Binary: $BINARY" >&2 +echo " Platform: $PLATFORM" >&2 +echo " Channel: $CHANNEL" >&2 +echo " Commit: $NOCKCHAIN_COMMIT" >&2 + +# Map platform to Rust target triple +case "$PLATFORM" in + linux64) + TARGET="x86_64-unknown-linux-gnu" + ;; + darwin64) + # Apple Silicon (M1/M2/M3/M4/M5) + TARGET="aarch64-apple-darwin" + ;; + darwinx86) + # Intel Macs (legacy, if needed) + TARGET="x86_64-apple-darwin" + ;; + *) + echo "Unknown platform: $PLATFORM" >&2 + exit 1 + ;; +esac + +# Generate the release tag for THIS specific commit +RELEASE_TAG="${CHANNEL}-build-${NOCKCHAIN_COMMIT}" + +# Default version +VERSION="0.1.0" + +# Try to get version from the specific release for this commit +RELEASE_API="https://api.github.com/repos/$NOCKCHAIN_OWNER/$NOCKCHAIN_REPO/releases/tags/${RELEASE_TAG}" +echo "Fetching release info for tag: $RELEASE_TAG" >&2 + +RELEASE_JSON=$(curl -s "$RELEASE_API") + +# Check if release exists +if echo "$RELEASE_JSON" | grep -q '"message".*"Not Found"'; then + echo "Warning: Release not found for tag $RELEASE_TAG" >&2 +else + # Extract version from asset names in this specific release + VERSION_FROM_ASSETS=$(echo "$RELEASE_JSON" | \ + grep -o '"name"[[:space:]]*:[[:space:]]*"[^"]*"' | \ + grep -o "${BINARY}-${CHANNEL}-[0-9]\+\.[0-9]\+\.[0-9]\+-" | \ + head -1 | \ + sed "s/${BINARY}-${CHANNEL}-//" | \ + sed 's/-$//') + + if [ -n "$VERSION_FROM_ASSETS" ]; then + VERSION="$VERSION_FROM_ASSETS" + echo "Found version from release assets: $VERSION" >&2 + fi +fi + +# If version extraction failed, try to fetch from Cargo.toml at this commit +if [ "$VERSION" = "0.1.0" ]; then + echo "Attempting to fetch version from Cargo.toml at commit $NOCKCHAIN_COMMIT" >&2 + + # Try local file first (since we're in the nockchain repo) + if [ -f "crates/${BINARY}/Cargo.toml" ]; then + VERSION_FROM_CARGO=$(grep '^version[[:space:]]*=' "crates/${BINARY}/Cargo.toml" | head -1 | sed 's/.*"\([0-9.]*\)".*/\1/') + if [ -n "$VERSION_FROM_CARGO" ]; then + VERSION="$VERSION_FROM_CARGO" + echo "Found version from local Cargo.toml: $VERSION" >&2 + fi + else + # Fall back to fetching from GitHub + CARGO_URL="https://raw.githubusercontent.com/$NOCKCHAIN_OWNER/$NOCKCHAIN_REPO/$NOCKCHAIN_COMMIT/crates/${BINARY}/Cargo.toml" + CARGO_CONTENT=$(curl -s "$CARGO_URL") + + if [ -n "$CARGO_CONTENT" ] && ! echo "$CARGO_CONTENT" | grep -q "404: Not Found"; then + VERSION_FROM_CARGO=$(echo "$CARGO_CONTENT" | grep '^version[[:space:]]*=' | head -1 | sed 's/.*"\([0-9.]*\)".*/\1/') + if [ -n "$VERSION_FROM_CARGO" ]; then + VERSION="$VERSION_FROM_CARGO" + echo "Found version from remote Cargo.toml: $VERSION" >&2 + fi + fi + fi +fi + +# Generate the URL following the exact pattern +ARTIFACT_NAME="${BINARY}-${CHANNEL}-${VERSION}-${TARGET}.tar.gz" +URL="https://github.com/$NOCKCHAIN_OWNER/$NOCKCHAIN_REPO/releases/download/${RELEASE_TAG}/${ARTIFACT_NAME}" + +echo "Download URL: $URL" >&2 + +# Create temporary directory for download +TEMP_DIR=$(mktemp -d) +trap "rm -rf $TEMP_DIR" EXIT + +DOWNLOAD_PATH="$TEMP_DIR/$ARTIFACT_NAME" + +# Download the artifact +echo "Downloading artifact..." >&2 +if ! curl -L -f -o "$DOWNLOAD_PATH" "$URL"; then + echo "Error: Failed to download $URL" >&2 + echo "This could mean:" >&2 + echo " 1. The release doesn't exist yet" >&2 + echo " 2. The version number is incorrect" >&2 + echo " 3. The artifact wasn't uploaded" >&2 + exit 1 +fi + +echo "Download successful, computing hashes..." >&2 + +# Compute BLAKE3 hash +if command -v b3sum >/dev/null 2>&1; then + BLAKE3_HASH=$(b3sum "$DOWNLOAD_PATH" | awk '{print $1}') + echo "BLAKE3: $BLAKE3_HASH" >&2 +else + echo "Warning: b3sum not found, using placeholder for BLAKE3 hash" >&2 + echo "Install with: cargo install b3sum" >&2 + BLAKE3_HASH="0000000000000000000000000000000000000000000000000000000000000000" +fi + +# Compute SHA-1 hash +if command -v sha1sum >/dev/null 2>&1; then + SHA1_HASH=$(sha1sum "$DOWNLOAD_PATH" | awk '{print $1}') + echo "SHA-1: $SHA1_HASH" >&2 +elif command -v shasum >/dev/null 2>&1; then + SHA1_HASH=$(shasum -a 1 "$DOWNLOAD_PATH" | awk '{print $1}') + echo "SHA-1: $SHA1_HASH" >&2 +else + echo "Warning: sha1sum/shasum not found, using placeholder for SHA-1 hash" >&2 + SHA1_HASH="0000000000000000000000000000000000000000" +fi + +# Create manifest directory if it doesn't exist +MANIFEST_DIR="${MANIFEST_DIR:-crates/nockup/toolchains}" +mkdir -p "$MANIFEST_DIR" + +# Generate manifest file +MANIFEST_FILE="$MANIFEST_DIR/${BINARY}-${TARGET}-${CHANNEL}.toml" + +cat << EOF > "$MANIFEST_FILE" +manifest-version = "1" +date = "$DATE" +commit = "$NOCKCHAIN_COMMIT" +commit_short = "$NOCKCHAIN_SHORT" +release_tag = "$RELEASE_TAG" + +[pkg.$BINARY] +version = "$VERSION" +components = ["core"] + +[pkg.$BINARY.target.$TARGET] +available = true +url = "$URL" +hash_blake3 = "$BLAKE3_HASH" +hash_sha1 = "$SHA1_HASH" +EOF + +echo "" >&2 +echo "✓ Generated manifest: $MANIFEST_FILE" >&2 +echo "✓ Version: $VERSION" >&2 +echo "✓ BLAKE3: $BLAKE3_HASH" >&2 +echo "✓ SHA-1: $SHA1_HASH" >&2 \ No newline at end of file diff --git a/crates/nockup/src/cli.rs b/crates/nockup/src/cli.rs new file mode 100644 index 000000000..da961652c --- /dev/null +++ b/crates/nockup/src/cli.rs @@ -0,0 +1,51 @@ +use clap::{Parser, Subcommand}; + +#[derive(Parser)] +#[command(name = "nockup")] +#[command(about = "A developer support framework for NockApp development")] +#[command(version = env!("FULL_VERSION"))] +pub struct Cli { + #[command(subcommand)] + pub command: Option, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Initialize nockup cache and download templates + Install, + /// Initialize a new NockApp project from a .toml config file + Init { + /// Name of the project config file (looks for .toml) + name: String, + }, + /// Check for updates to nockup, hoon, and hoonc + Update, + /// Build a NockApp project + Build { + /// Path to the project directory + project: String, + }, + /// Run a NockApp project + Run { + /// Path to the project directory + project: String, + /// Additional arguments to pass to the running application + #[arg(last = true)] + args: Vec, + }, + /// Manage channels (e.g., set default) + Channel { + #[command(subcommand)] + action: ChannelAction, + }, +} + +#[derive(Subcommand)] +pub enum ChannelAction { + /// Set the default channel (e.g., stable, nightly) + Set { + channel: String, // e.g., "stable" or "nightly" + }, + /// Show the current channel + Show, +} diff --git a/crates/nockup/src/commands/build.rs b/crates/nockup/src/commands/build.rs new file mode 100644 index 000000000..f35360f7b --- /dev/null +++ b/crates/nockup/src/commands/build.rs @@ -0,0 +1,154 @@ +use std::path::Path; +use std::process::Stdio; + +use anyhow::{Context, Result}; +use colored::Colorize; +use tokio::process::Command; + +pub async fn run(project: String) -> Result<()> { + let project_dir = Path::new(&project); + + // Check if project directory exists + if !project_dir.exists() { + return Err(anyhow::anyhow!("Project directory '{}' not found", project)); + } + + // Check if it's a valid NockApp project (has manifest.toml) + let manifest_path = project_dir.join("manifest.toml"); + if !manifest_path.exists() { + return Err(anyhow::anyhow!( + "Not a NockApp project: '{}' missing manifest.toml", project + )); + } + + // Check if Cargo.toml exists + let cargo_toml = project_dir.join("Cargo.toml"); + if !cargo_toml.exists() { + return Err(anyhow::anyhow!("No Cargo.toml found in '{}'", project)); + } + + println!("{} Building project '{}'...", "🔨".green(), project.cyan()); + + // Extract expected binary names from Cargo.toml + let cargo_toml_content = tokio::fs::read_to_string(&cargo_toml) + .await + .context("Failed to read Cargo.toml")?; + + let cargo_toml_parsed: toml::Value = + toml::from_str(&cargo_toml_content).context("Failed to parse Cargo.toml")?; + + let expected_binaries = if let Some(bins) = cargo_toml_parsed.get("bin") { + bins.as_array() + .context("Invalid format for [[bin]] in Cargo.toml")? + .iter() + .filter_map(|bin| bin.get("name").and_then(|n| n.as_str())) + .map(String::from) + .collect::>() + } else { + Vec::new() + }; + + // Check number of expected binaries; if more than one, check primary source files. + let binaries: Vec = { + if expected_binaries.is_empty() { + vec![project_dir.join("src").join("main.rs")] + } else if expected_binaries.len() == 1 { + vec![project_dir.join("src").join("main.rs")] + } else { + let mut binaries = Vec::new(); + for bin_name in &expected_binaries { + let bin_path = project_dir.join("src").join(format!("{}.rs", bin_name)); + binaries.push(bin_path); + } + binaries + } + }; + + // Run cargo build in the project directory + let mut cargo_command = Command::new("cargo"); + cargo_command + .arg("build") + .arg("--release") // Build in release mode by default + .current_dir(project_dir) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + + let status = cargo_command + .status() + .await + .context("Failed to execute cargo build")?; + + if !status.success() { + return Err(anyhow::anyhow!( + "Cargo build failed with exit code: {}", + status.code().unwrap_or(-1) + )); + } + + println!("{} Cargo build completed successfully!", "✓".green()); + + // Check if hoon app file exists + // If there is only one binary, then check in the normal spot. + // If there are multiple binaries, then check at each location by name. + for bin_path in &binaries { + // if this is main.rs, then load app.hoon + let name = if bin_path.file_name().unwrap() == "main.rs" { + "app".to_string() + } else { + bin_path.file_stem().unwrap().to_string_lossy().to_string() + }; + let hoon_app_path = project_dir.join(format!("hoon/app/{}.hoon", name)); + println!("Compiling Hoon app file at: {}", hoon_app_path.display()); + + if !hoon_app_path.exists() { + return Err(anyhow::anyhow!( + "Hoon app file not found: '{}'", + hoon_app_path.display() + )); + } + + println!("{} Compiling Hoon app...", "📦".green()); + + // Run hoonc command from project directory + let mut hoonc_command = Command::new("hoonc"); + hoonc_command + .arg(hoon_app_path.strip_prefix(project_dir).unwrap()) + .current_dir(project_dir) // Run in project directory + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + + let hoonc_status = hoonc_command.status().await.context( + "Failed to execute hoonc command - make sure hoonc is installed and in PATH", + )?; + + if !hoonc_status.success() { + return Err(anyhow::anyhow!( + "hoonc compilation failed with exit code: {}", + hoonc_status.code().unwrap_or(-1) + )); + } + + // move out.jam to {bin_name}.jam if the program has multiple names + if binaries.len() > 1 { + let target_jam = project_dir.join(format!( + "{}.jam", + bin_path.file_stem().unwrap().to_string_lossy() + )); + tokio::fs::rename(project_dir.join("out.jam"), &target_jam) + .await + .context(format!( + "Failed to rename out.jam to {}", + target_jam.display() + ))?; + println!( + "{} Renamed out.jam to {}", + "🔀".green(), + target_jam.display().to_string().cyan() + ); + } + } + + println!("{} Hoon compilation completed successfully!", "✓".green()); + + Ok(()) +} diff --git a/crates/nockup/src/commands/channel.rs b/crates/nockup/src/commands/channel.rs new file mode 100644 index 000000000..f6365b63e --- /dev/null +++ b/crates/nockup/src/commands/channel.rs @@ -0,0 +1,48 @@ +use std::path::PathBuf; + +use anyhow::{Context, Result}; + +use crate::cli::ChannelAction; + +fn set_channel(channel: &str) -> Result<()> { + // validate that is 'nightly' or 'stable', change later when more are supported + if channel != "nightly" && channel != "stable" { + return Err(anyhow::anyhow!("Invalid channel: {}", channel)); + } + let mut config = get_config()?; + config["channel"] = toml::Value::String(channel.to_string()); + let cache_dir = get_cache_dir()?; + let config_path = cache_dir.join("config.toml"); + std::fs::write(config_path, toml::to_string(&config)?) + .context("Failed to write config file")?; + println!("Set default channel to '{}'.", channel); + Ok(()) +} + +fn show_channel() -> Result<()> { + let config = get_config()?; + println!("Default channel: {}", config["channel"]); + println!("Architecture: {}", config["architecture"]); + Ok(()) +} + +fn get_cache_dir() -> Result { + let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; + Ok(home.join(".nockup")) +} + +fn get_config() -> Result { + let cache_dir = get_cache_dir()?; + let config_path = cache_dir.join("config.toml"); + let config_str = std::fs::read_to_string(&config_path).context("Failed to read config file")?; + let config: toml::Value = + toml::de::from_str(&config_str).context("Failed to parse config file")?; + Ok(config) +} + +pub async fn run(command: ChannelAction) -> Result<()> { + match command { + ChannelAction::Set { channel } => set_channel(&channel), + ChannelAction::Show => show_channel(), + } +} diff --git a/crates/nockup/src/commands/common.rs b/crates/nockup/src/commands/common.rs new file mode 100644 index 000000000..3c76df083 --- /dev/null +++ b/crates/nockup/src/commands/common.rs @@ -0,0 +1,746 @@ +use std::fs; +use std::io::Read; +use std::path::PathBuf; +use std::process::Stdio; + +use anyhow::{anyhow, Context, Result}; +use blake3; +use colored::Colorize; +use flate2::read::GzDecoder; +use sha1::{Digest, Sha1}; +use tar::Archive; +use tokio::fs as tokio_fs; +use tokio::process::Command; + +const GITHUB_REPO: &str = "sigilante/nockup"; +const TEMPLATES_BRANCH: &str = "master"; + +pub fn get_cache_dir() -> Result { + let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; + Ok(home.join(".nockup")) +} + +pub fn get_target_identifier() -> String { + let arch = std::env::consts::ARCH; + let os = std::env::consts::OS; + + match (arch, os) { + ("x86_64", "linux") => "x86_64-unknown-linux-gnu".to_string(), + ("x86_64", "windows") => "x86_64-pc-windows-msvc".to_string(), + ("x86_64", "macos") => "x86_64-apple-darwin".to_string(), + ("aarch64", "linux") => "aarch64-unknown-linux-gnu".to_string(), + ("aarch64", "macos") => "aarch64-apple-darwin".to_string(), + ("aarch64", "windows") => "aarch64-pc-windows-msvc".to_string(), + _ => format!("{}-unknown-{}", arch, os), + } +} + +pub fn get_config() -> Result { + let cache_dir = get_cache_dir()?; + let config_path = cache_dir.join("config.toml"); + if !config_path.exists() { + return Err(anyhow::anyhow!( + "Config file not found. Please run 'nockup install' first." + )); + } + let config_str = std::fs::read_to_string(&config_path).context("Failed to read config file")?; + let config: toml::Value = + toml::de::from_str(&config_str).context("Failed to parse config file")?; + Ok(config) +} + +pub fn get_or_create_config() -> Result { + let cache_dir = get_cache_dir()?; + let config_path = cache_dir.join("config.toml"); + if !config_path.exists() { + write_default_config(&config_path)?; + } + let config_str = std::fs::read_to_string(&config_path).context("Failed to read config file")?; + let config: toml::Value = + toml::de::from_str(&config_str).context("Failed to parse config file")?; + Ok(config) +} + +fn write_default_config(config_path: &PathBuf) -> Result<()> { + let default_config = format!( + r#"channel = "stable" +architecture = "{}" +"#, + get_target_identifier() + ); + std::fs::write(config_path, default_config).context("Failed to create default config file")?; + Ok(()) +} + +pub async fn download_templates(cache_dir: &PathBuf) -> Result<()> { + let templates_dir = cache_dir.join("templates"); + + if has_existing_templates(&templates_dir).await? { + println!("{} Existing templates found, updating...", "🔄".yellow()); + update_templates(&templates_dir).await?; + } else { + println!("{} Downloading templates from GitHub...", "⬇️".green()); + clone_templates(&templates_dir).await?; + } + + Ok(()) +} + +async fn has_existing_templates(templates_dir: &PathBuf) -> Result { + if !templates_dir.exists() { + return Ok(false); + } + + let git_dir = templates_dir.join(".git"); + if !git_dir.exists() { + return Ok(false); + } + + let entries = fs::read_dir(templates_dir)?; + let mut count = 0; + for entry in entries { + let entry = entry?; + let file_name = entry.file_name(); + if file_name != ".git" { + count += 1; + if count > 0 { + return Ok(true); + } + } + } + + Ok(false) +} + +async fn clone_templates(templates_dir: &PathBuf) -> Result<()> { + let commit_id = get_git_commit_id().await?; + let commit_file = templates_dir.join("commit.toml"); + + match tokio_fs::read_to_string(&commit_file).await { + Ok(commit_content) => { + let commit: toml::Value = + toml::de::from_str(&commit_content).context("Failed to parse commit file")?; + let local_commit_id = commit["commit"]["id"].to_string().replace("\"", ""); + if local_commit_id == commit_id { + println!("{} Templates are up to date", "✅".green()); + return Ok(()); + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + println!("{} No local commit ID found", "🔍".yellow()); + } + Err(e) => { + return Err(anyhow::anyhow!("Failed to read commit file: {}", e)); + } + } + + if templates_dir.exists() { + fs::remove_dir_all(templates_dir) + .context("Failed to remove existing templates directory")?; + + if templates_dir.exists() { + return Err(anyhow::anyhow!( + "Failed to completely remove templates directory at {}", + templates_dir.display() + )); + } + } + + let temp_dir = templates_dir.parent().unwrap().join("temp_repo"); + if temp_dir.exists() { + fs::remove_dir_all(&temp_dir)?; + } + + let repo_url = format!("https://github.com/{}.git", GITHUB_REPO); + + let mut command = Command::new("git"); + command + .arg("clone") + .arg("--depth=1") + .arg("--branch") + .arg(TEMPLATES_BRANCH) + .arg(&repo_url) + .arg(&temp_dir); + + command.stdout(Stdio::null()); + command.stderr(Stdio::null()); + let status = command.status().await?; + + if !status.success() { + return Err(anyhow::anyhow!( + "Failed to clone templates from GitHub. Exit code: {}", + status.code().unwrap_or(-1) + )); + } + + let repo_templates_dir = temp_dir.join("templates"); + if !repo_templates_dir.exists() { + fs::remove_dir_all(&temp_dir).ok(); + return Err(anyhow::anyhow!( + "No 'templates' directory found in the repository" + )); + } + + match fs::rename(&repo_templates_dir, templates_dir) { + Ok(_) => {} + Err(e) if e.raw_os_error() == Some(66) => { + println!("{} Rename failed, copying instead...", "⚠️".yellow()); + copy_dir_recursive(&repo_templates_dir, templates_dir)?; + } + Err(e) => return Err(e.into()), + } + + let repo_manifests_dir = temp_dir.join("manifests"); + if !repo_manifests_dir.exists() { + fs::remove_dir_all(&temp_dir).ok(); + return Err(anyhow::anyhow!( + "No 'manifests' directory found in the repository" + )); + } + + let manifests_dir = templates_dir.parent().unwrap().join("manifests"); + if manifests_dir.exists() { + fs::remove_dir_all(&manifests_dir)?; + } + + match fs::rename(&repo_manifests_dir, &manifests_dir) { + Ok(_) => {} + Err(e) if e.raw_os_error() == Some(66) => { + println!( + "{} Rename failed for manifests, copying instead...", + "⚠️".yellow() + ); + copy_dir_recursive(&repo_manifests_dir, &manifests_dir)?; + } + Err(e) => return Err(e.into()), + } + + let commit_file = templates_dir.join("commit.toml"); + let commit_data = format!("[commit]\nid = \"{}\"\n", commit_id); + fs::write(&commit_file, commit_data)?; + + fs::remove_dir_all(&temp_dir)?; + println!( + "{} Templates and manifests downloaded successfully", + "✓".green() + ); + Ok(()) +} + +fn copy_dir_recursive(src: &PathBuf, dst: &PathBuf) -> Result<()> { + fs::create_dir_all(dst)?; + + for entry in fs::read_dir(src)? { + let entry = entry?; + let file_type = entry.file_type()?; + let src_path = entry.path(); + let dst_path = dst.join(entry.file_name()); + + if file_type.is_dir() { + copy_dir_recursive(&src_path, &dst_path)?; + } else { + fs::copy(&src_path, &dst_path)?; + } + } + + Ok(()) +} + +async fn update_templates(templates_dir: &PathBuf) -> Result<()> { + clone_templates(templates_dir).await +} + +pub async fn download_toolchain_files(cache_dir: &PathBuf) -> Result<()> { + let toolchain_dir = cache_dir.join("toolchains"); + + if has_existing_toolchain_files(&toolchain_dir).await? { + println!( + "{} Existing toolchain files found, updating...", + "🔄".yellow() + ); + update_toolchain_files(&toolchain_dir).await?; + } else { + println!( + "{} Downloading toolchain files from GitHub...", + "⬇️".green() + ); + clone_toolchain_files(&toolchain_dir).await?; + } + + Ok(()) +} + +async fn has_existing_toolchain_files(toolchain_dir: &PathBuf) -> Result { + if !toolchain_dir.exists() { + return Ok(false); + } + let entries = fs::read_dir(toolchain_dir)?; + for entry in entries { + let entry = entry?; + if entry.file_type()?.is_file() { + return Ok(true); + } + } + Ok(false) +} + +async fn update_toolchain_files(toolchain_dir: &PathBuf) -> Result<()> { + clone_toolchain_files(toolchain_dir).await +} + +async fn clone_toolchain_files(toolchain_dir: &PathBuf) -> Result<()> { + if toolchain_dir.exists() { + fs::remove_dir_all(toolchain_dir)?; + } + fs::create_dir_all(toolchain_dir)?; + + println!( + "{} Fetching latest channel manifests from GitHub releases...", + "⬇️".green() + ); + + async fn get_latest_manifest(channel: &str, toolchain_dir: &PathBuf) -> Result<()> { + let manifest_file = format!("{}-manifest.toml", channel); + let output_file = toolchain_dir.join(format!("channel-nockup-{}.toml", channel)); + + println!("{} Fetching latest {} manifest...", "🔍".yellow(), channel); + + let api_url = "https://api.github.com/repos/nockchain/nockchain/releases"; + let client = reqwest::Client::new(); + let response = client + .get(api_url) + .header("User-Agent", "nockup") + .send() + .await + .context("Failed to fetch releases from GitHub API")?; + + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "Failed to fetch releases: HTTP {}", + response.status() + )); + } + + let releases: serde_json::Value = response + .json() + .await + .context("Failed to parse releases JSON")?; + + let latest_tag = get_git_commit_id().await?; + + let manifest_url = format!( + "https://github.com/nockchain/nockchain/releases/download/{}-build-{}/{}", + channel, latest_tag, manifest_file + ); + + println!("{} Downloading from: {}", "⬇️".blue(), manifest_url); + + let response = client + .get(&manifest_url) + .header("User-Agent", "nockup") + .send() + .await + .context("Failed to download manifest")?; + + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "Failed to download manifest: HTTP {}", + response.status() + )); + } + + let content = response + .text() + .await + .context("Failed to read manifest content")?; + + tokio_fs::write(&output_file, content) + .await + .context("Failed to write manifest file")?; + + println!( + "{} Downloaded: channel-nockup-{}.toml", + "✅".green(), + channel + ); + + Ok(()) + } + + let channels = ["stable", "nightly"]; + let mut errors = Vec::new(); + + for channel in &channels { + if let Err(e) = get_latest_manifest(channel, toolchain_dir).await { + println!( + "{} Failed to download {} manifest: {}", + "⚠️".yellow(), + channel, + e + ); + errors.push(format!("{}: {}", channel, e)); + } + } + + if errors.len() == channels.len() { + return Err(anyhow::anyhow!( + "Failed to download any toolchain manifests: {}", + errors.join(", ") + )); + } + + if !errors.is_empty() { + println!( + "{} Some manifests failed to download: {}", + "⚠️".yellow(), + errors.join(", ") + ); + } + + println!("{} Toolchain files setup complete", "✅".green()); + Ok(()) +} + +pub async fn download_binaries(config: &toml::Value) -> Result<()> { + let channel = config["channel"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Invalid channel in config"))?; + let architecture = config["architecture"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Invalid architecture in config"))?; + + let cache_dir = get_cache_dir()?; + let channel_name = format!("channel-nockup-{}", channel); + let manifest_path = cache_dir + .join("toolchains") + .join(format!("{}.toml", channel_name)); + let manifest = std::fs::read_to_string(&manifest_path).context(format!( + "Failed to read channel manifest for '{}.toml' at path {}", + channel_name, + manifest_path.display() + ))?; + let manifest: toml::Value = toml::de::from_str(&manifest).context(format!( + "Failed to parse channel manifest for '{}'", + channel_name + ))?; + + println!( + "{} Downloading binaries for channel '{}' and architecture '{}'...", + "⬇️".green(), + channel_name.cyan(), + architecture.cyan() + ); + + for index in ["hoon", "hoonc", "nockup"] { + println!("{} Downloading {} binary...", "⬇️".green(), index.cyan()); + let archive_url = manifest["pkg"][index]["target"][architecture]["url"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("{} Invalid URL for {} binary", "❌".red(), index))?; + let archive_url = archive_url.replace("http://", "https://"); + let signature_url = format!("{}.asc", archive_url); + + let archive_blake3 = manifest["pkg"][index]["target"][architecture]["hash_blake3"] + .as_str() + .ok_or_else(|| { + anyhow::anyhow!("{} Invalid Blake3 hash for {} binary", "❌".red(), index) + })?; + let archive_sha1 = manifest["pkg"][index]["target"][architecture]["hash_sha1"] + .as_str() + .ok_or_else(|| { + anyhow::anyhow!("{} Invalid SHA1 hash for {} binary", "❌".red(), index) + })?; + + println!("{} Blake3 checksum passed.", "✅".green()); + println!("{} SHA1 checksum passed.", "✅".green()); + + let archive_path = download_file(&archive_url).await?; + + if std::env::consts::OS == "linux" { + let signature_path = download_file(&signature_url).await?; + verify_gpg_signature(&archive_path, &signature_path).await?; + fs::remove_file(&signature_path)?; + } else { + println!( + "{} Skipping signature verification on {} (not yet supported)", + "⚠️".yellow(), + std::env::consts::OS + ); + } + + verify_checksums(&archive_path, &archive_blake3, &archive_sha1).await?; + + let target_dir = get_cache_dir()?; + let binary_path = target_dir.join("bin"); + fs::create_dir_all(&binary_path)?; + + extract_binary_from_archive(&archive_path, &binary_path, index).await?; + + fs::remove_file(&archive_path)?; + } + + Ok(()) +} + +async fn verify_gpg_signature( + archive_path: &std::path::Path, + signature_path: &std::path::Path, +) -> Result<()> { + println!("{} Verifying GPG signature...", "🔐".yellow()); + + if !archive_path.exists() { + return Err(anyhow::anyhow!( + "Archive file does not exist: {}", + archive_path.display() + )); + } + if !signature_path.exists() { + return Err(anyhow::anyhow!( + "Signature file does not exist: {}", + signature_path.display() + )); + } + + let output = Command::new("gpg") + .args(["--verify", signature_path.to_str().unwrap(), archive_path.to_str().unwrap()]) + .output() + .await + .context("Failed to execute gpg command")?; + + if output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("Good signature") { + println!("{} GPG signature verified successfully", "✅".green()); + return Ok(()); + } + } + + let stderr = String::from_utf8_lossy(&output.stderr); + if stderr.contains("No public key") { + println!( + "{} Public key not found, importing from keyserver...", + "🔑".yellow() + ); + + let import_output = Command::new("gpg") + .args(["--keyserver", "keyserver.ubuntu.com", "--recv-keys", "A6FFD2DB7D4C9710"]) + .output() + .await + .context("Failed to import public key from keyserver")?; + + if !import_output.status.success() { + let alt_import = Command::new("gpg") + .args(["--keyserver", "keys.openpgp.org", "--recv-keys", "A6FFD2DB7D4C9710"]) + .output() + .await; + + if let Ok(alt_output) = alt_import { + if !alt_output.status.success() { + return Err(anyhow::anyhow!( + "Failed to import public key from keyservers. Please import manually:\n gpg --keyserver keyserver.ubuntu.com --recv-keys A6FFD2DB7D4C9710" + )); + } + } else { + return Err(anyhow::anyhow!( + "Failed to import public key. Please import manually:\n gpg --keyserver keyserver.ubuntu.com --recv-keys A6FFD2DB7D4C9710" + )); + } + } + + println!("{} Public key imported successfully", "✅".green()); + + let retry_output = Command::new("gpg") + .args([ + "--verify", + "--verbose", + signature_path.to_str().unwrap(), + archive_path.to_str().unwrap(), + ]) + .output() + .await + .context("Failed to execute gpg verification after key import")?; + + if !retry_output.status.success() { + let retry_stderr = String::from_utf8_lossy(&retry_output.stderr); + return Err(anyhow::anyhow!( + "GPG signature verification failed after key import: {}", retry_stderr + )); + } + + let retry_stderr = String::from_utf8_lossy(&retry_output.stderr); + if retry_stderr.contains("Good signature") { + println!("{} GPG signature verified successfully", "✅".green()); + } else { + return Err(anyhow::anyhow!( + "GPG signature verification failed: {}", retry_stderr + )); + } + } else { + return Err(anyhow::anyhow!( + "GPG signature verification failed: {}", stderr + )); + } + + Ok(()) +} + +async fn extract_binary_from_archive( + archive_path: &std::path::Path, + target_dir: &std::path::Path, + binary_name: &str, +) -> Result<()> { + println!( + "{} Extracting {} from archive...", + "📦".yellow(), + binary_name + ); + + let file = std::fs::File::open(archive_path).context("Failed to open archive file")?; + let decoder = GzDecoder::new(file); + let mut archive = Archive::new(decoder); + + let mut found_binary = false; + + for entry in archive + .entries() + .context("Failed to read archive entries")? + { + let mut entry = entry.context("Failed to read archive entry")?; + let entry_path = entry.path().context("Failed to get entry path")?; + + if entry_path.file_name() == Some(std::ffi::OsStr::new(binary_name)) { + let target_path = target_dir.join(binary_name); + + let mut buffer = Vec::new(); + entry + .read_to_end(&mut buffer) + .context("Failed to read binary from archive")?; + + let temp_path = target_path.with_extension("tmp"); + std::fs::write(&temp_path, buffer).context("Failed to write extracted binary")?; + + std::fs::rename(&temp_path, &target_path) + .context("Failed to move binary to final location")?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&target_path)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&target_path, perms)?; + } + + println!( + "{} Extracted {} to {}", + "✅".green(), + binary_name, + target_path.display() + ); + found_binary = true; + break; + } + } + + if !found_binary { + return Err(anyhow::anyhow!( + "Binary '{}' not found in archive", binary_name + )); + } + + Ok(()) +} + +async fn download_file(url: &str) -> Result { + let response = reqwest::get(url) + .await + .context(format!("Failed to download file from '{}'", url))?; + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "Failed to download file from '{}': HTTP {}", + url, + response.status() + )); + } + + let url_filename = url.split('/').last().unwrap_or("download"); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs(); + let filename = format!("nockup_{}_{}", timestamp, url_filename); + let temp_file = std::env::temp_dir().join(filename); + + let mut file = std::fs::File::create(&temp_file).context("Failed to create temporary file")?; + let content = response.bytes().await?; + std::io::copy(&mut content.as_ref(), &mut file).context("Failed to write to temporary file")?; + Ok(temp_file) +} + +async fn verify_checksums( + file_path: &PathBuf, + expected_blake3: &str, + expected_sha1: &str, +) -> Result<()> { + let bytes = + std::fs::read(file_path).context("Failed to read file for checksum verification")?; + + let computed_blake3 = blake3::hash(&bytes); + if computed_blake3.to_string() != expected_blake3 { + return Err(anyhow::anyhow!( + "Checksum verification failed: expected {}, got {}", expected_blake3, computed_blake3 + )); + } + + let mut hasher = Sha1::new(); + hasher.update(&bytes); + let computed_sha1 = hasher.finalize(); + let expected_sha1: [u8; 20] = hex::decode(expected_sha1) + .map_err(|e| anyhow::anyhow!("Invalid hex SHA-1: {}", e))? + .try_into() + .map_err(|_| anyhow!("Failed to convert to fixed array (length mismatch)"))?; + if computed_sha1.as_slice() != &expected_sha1 { + let expected_hex = hex::encode(&expected_sha1); + let computed_hex = hex::encode(computed_sha1.as_slice()); + return Err(anyhow::anyhow!( + "Checksum verification failed: expected {}, got {}", expected_hex, computed_hex + )); + } + Ok(()) +} + +pub async fn write_commit_details(cache_dir: &PathBuf) -> Result<()> { + let status_file = cache_dir.join("status.toml"); + let mut config = toml::map::Map::new(); + config.insert("commit".into(), toml::Value::Table(toml::map::Map::new())); + let commit_id = get_git_commit_id().await?; + let commit_table = config + .get_mut("commit") + .and_then(|commit| commit.as_table_mut()) + .ok_or_else(|| anyhow::anyhow!("Failed to insert commit ID into config"))?; + commit_table.insert("id".into(), toml::Value::String(commit_id)); + fs::write(status_file, toml::to_string(&config)?).context("Failed to write config file")?; + Ok(()) +} + +async fn get_git_commit_id() -> Result { + let repo_url = format!("https://api.github.com/repos/nockchain/nockchain/commits/master"); + let client = reqwest::Client::new(); + let response = client + .get(&repo_url) + .header("User-Agent", "nockup") + .send() + .await + .context("Failed to fetch commit ID from GitHub")?; + + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "Failed to fetch commit ID: HTTP {}", + response.status() + )); + } + + let json: serde_json::Value = response.json().await.context("Invalid JSON response")?; + let commit_id = json["sha"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Missing commit ID in response"))?; + Ok(commit_id.to_string()) +} diff --git a/crates/nockup/src/commands/init.rs b/crates/nockup/src/commands/init.rs new file mode 100644 index 000000000..5eb355408 --- /dev/null +++ b/crates/nockup/src/commands/init.rs @@ -0,0 +1,192 @@ +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result}; +use colored::Colorize; +use handlebars::Handlebars; + +use crate::lib_manager::{process_libraries, ProjectManifest}; + +pub async fn run(project_name: String) -> Result<()> { + // Load the project-specific manifest configuration + let manifest = load_project_config(&project_name)?; + let project_name = &manifest.project.project_name; + + println!( + "Initializing new NockApp project '{}'...", + project_name.green() + ); + + let target_dir = Path::new(project_name); + // Use cache dir ~/.nockup/templates/{{manifest.template}} + let template_dir = dirs::home_dir() + .ok_or_else(|| anyhow::anyhow!("Could not find home directory"))? + .join(format!(".nockup/templates/{}", manifest.project.template)); + + // Check if target directory already exists + if target_dir.exists() { + return Err(anyhow::anyhow!( + "Directory '{}' already exists. Please choose a different name or remove the existing directory.", project_name + )); + } + + // Check if template directory exists + if !template_dir.exists() { + return Err(anyhow::anyhow!( + "Template directory '{}' not found. Make sure nockup is run from the correct directory.", + template_dir.display() + )); + } + + // Create template context from the project config + let context = create_template_context(&manifest)?; + + // Copy template directory to new project location + copy_template_directory(template_dir.as_path(), target_dir, &context)?; + + // Process library dependencies from manifest + process_libraries(target_dir, &manifest) + .await + .context("Failed to process library dependencies")?; + + println!( + "{} New project created in {}/", + "✓".green(), + format!("./{}/", project_name).cyan() + ); + println!("To get started:"); + println!(" nockup build {}", project_name.cyan()); + println!(" nockup run {}", project_name.cyan()); + + Ok(()) +} + +fn load_project_config(project_name: &str) -> Result { + let config_filename = format!("{}.toml", project_name); + let config_path = Path::new(&config_filename); + + if !config_path.exists() { + return Err(anyhow::anyhow!( + "Project configuration file '{}.toml' not found", project_name + )); + } + + let config_content = fs::read_to_string(config_path) + .with_context(|| format!("Failed to read {}.toml", project_name))?; + + println!( + "{} Loaded project configuration from '{}'", + "✓".green(), + config_filename.cyan() + ); + println!("Config content:\n{}", config_content); + toml::from_str(&config_content) + .with_context(|| format!("Failed to parse {}.toml", project_name)) +} + +fn create_template_context(manifest: &ProjectManifest) -> Result> { + let mut context = HashMap::new(); + + // Add all values directly from manifest + context.insert("name".to_string(), manifest.project.name.clone()); + context.insert( + "project_name".to_string(), + manifest.project.project_name.clone(), + ); + context.insert("version".to_string(), manifest.project.version.clone()); + context.insert( + "project_description".to_string(), + manifest.project.description.clone(), + ); + context.insert( + "description".to_string(), + manifest.project.description.clone(), + ); + context.insert( + "author_name".to_string(), + manifest.project.author_name.clone(), + ); + context.insert( + "author_email".to_string(), + manifest.project.author_email.clone(), + ); + context.insert( + "github_username".to_string(), + manifest.project.github_username.clone(), + ); + context.insert("license".to_string(), manifest.project.license.clone()); + context.insert( + "keywords".to_string(), + manifest.project.keywords.join("\", \""), + ); + context.insert( + "nockapp_commit_hash".to_string(), + manifest.project.nockapp_commit_hash.clone(), + ); + context.insert("template".to_string(), manifest.project.template.clone()); + + Ok(context) +} + +fn copy_template_directory( + src_dir: &Path, + dest_dir: &Path, + context: &HashMap, +) -> Result<()> { + let handlebars = Handlebars::new(); + + // Create the destination directory + fs::create_dir_all(dest_dir) + .with_context(|| format!("Failed to create directory '{}'", dest_dir.display()))?; + + // Recursively copy and process template directory + copy_dir_recursive(src_dir, dest_dir, &handlebars, context, dest_dir)?; + + Ok(()) +} + +fn copy_dir_recursive( + src_dir: &Path, + dest_dir: &Path, + handlebars: &Handlebars, + context: &HashMap, + project_root: &Path, +) -> Result<()> { + for entry in fs::read_dir(src_dir) + .with_context(|| format!("Failed to read directory '{}'", src_dir.display()))? + { + let entry = entry?; + let src_path = entry.path(); + let file_name = entry.file_name(); + let dest_path = dest_dir.join(&file_name); + + if src_path.is_dir() { + // Create subdirectory and recurse + fs::create_dir_all(&dest_path) + .with_context(|| format!("Failed to create directory '{}'", dest_path.display()))?; + copy_dir_recursive(&src_path, &dest_path, handlebars, context, project_root)?; + } else { + // Copy and process file + let content = fs::read_to_string(&src_path) + .with_context(|| format!("Failed to read file '{}'", src_path.display()))?; + + // Process template variables in file content + let processed_content = + handlebars + .render_template(&content, context) + .with_context(|| { + format!("Failed to process template for '{}'", src_path.display()) + })?; + + fs::write(&dest_path, processed_content) + .with_context(|| format!("Failed to write file '{}'", dest_path.display()))?; + + // Show relative path from project root for cleaner output + let relative_path = dest_path.strip_prefix(project_root).unwrap_or(&dest_path); + println!(" {} {}", "create".green(), relative_path.display()); + } + } + + Ok(()) +} diff --git a/crates/nockup/src/commands/install.rs b/crates/nockup/src/commands/install.rs new file mode 100644 index 000000000..afa0fd892 --- /dev/null +++ b/crates/nockup/src/commands/install.rs @@ -0,0 +1,96 @@ +use std::fs; +use std::io::Read; +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use colored::Colorize; + +use super::common; + +pub async fn run() -> Result<()> { + let cache_dir = common::get_cache_dir()?; + + println!("{} Setting up nockup cache directory...", "🚀".green()); + println!( + "{} Cache location: {}", + "📁".blue(), + cache_dir.display().to_string().cyan() + ); + + // Create cache directory structure + create_cache_structure(&cache_dir).await?; + + // Download or update templates + common::download_templates(&cache_dir).await?; + + // Download toolchain files + common::download_toolchain_files(&cache_dir).await?; + + // Set default channel to stable and this architecture + let config_path = cache_dir.join("config.toml"); + let mut config = common::get_or_create_config()?; + println!("📝 Config installed at: {}", config_path.display()); + config["channel"] = toml::Value::String("stable".into()); + config["architecture"] = toml::Value::String(common::get_target_identifier()); + fs::write(config_path, toml::to_string(&config)?).context("Failed to write config file")?; + + // Write commit details to status file + common::write_commit_details(&cache_dir).await?; + + // Download binaries for current channel + common::download_binaries(&config).await?; + + // Prepend cache bin directory to PATH + prepend_path_to_shell_rc(&cache_dir.join("bin")).await?; + + println!("{} Setup complete!", "✅".green()); + println!( + "{} Templates are now available in: {}", + "📂".blue(), + cache_dir.join("templates").display().to_string().cyan() + ); + + Ok(()) +} + +async fn create_cache_structure(cache_dir: &PathBuf) -> Result<()> { + println!("{} Creating cache directory structure...", "📁".green()); + + fs::create_dir_all(cache_dir)?; + + let bin_dir = cache_dir.join("bin"); + fs::create_dir_all(&bin_dir)?; + + let templates_dir = cache_dir.join("templates"); + fs::create_dir_all(&templates_dir)?; + + println!("{} Created directory structure", "✓".green()); + Ok(()) +} + +async fn prepend_path_to_shell_rc(bin_dir: &PathBuf) -> Result<()> { + let shell = std::env::var("SHELL").unwrap_or_default(); + let rc_file = if shell.contains("zsh") { + dirs::home_dir().unwrap().join(".zshrc") + } else if shell.contains("bash") { + dirs::home_dir().unwrap().join(".bashrc") + } else { + return Ok(()); + }; + + let mut contents = String::new(); + if rc_file.exists() { + let mut file = fs::File::open(&rc_file)?; + file.read_to_string(&mut contents)?; + } + + let path_entry = format!("export PATH=\"{}:$PATH\"", bin_dir.display()); + println!("{}", path_entry); + if !contents.contains(&path_entry) { + let new_contents = format!("{}\n{}", contents, path_entry); + fs::write(&rc_file, new_contents)?; + println!("{} Updated {}", "📝".green(), rc_file.display()); + } + + Ok(()) +} diff --git a/crates/nockup/src/commands/mod.rs b/crates/nockup/src/commands/mod.rs new file mode 100644 index 000000000..43736c438 --- /dev/null +++ b/crates/nockup/src/commands/mod.rs @@ -0,0 +1,7 @@ +pub mod build; +pub mod channel; +pub mod common; +pub mod init; +pub mod install; +pub mod run; +pub mod update; diff --git a/crates/nockup/src/commands/run.rs b/crates/nockup/src/commands/run.rs new file mode 100644 index 000000000..b260f2d22 --- /dev/null +++ b/crates/nockup/src/commands/run.rs @@ -0,0 +1,61 @@ +use std::path::Path; +use std::process::Stdio; + +use anyhow::{Context, Result}; +use colored::Colorize; +use tokio::process::Command; + +pub async fn run(project: String, args: Vec) -> Result<()> { + let project_dir = Path::new(&project); + + // Check if project directory exists + if !project_dir.exists() { + return Err(anyhow::anyhow!("Project directory '{}' not found", project)); + } + + // Check if it's a valid NockApp project (has manifest.toml) + let manifest_path = project_dir.join("manifest.toml"); + if !manifest_path.exists() { + return Err(anyhow::anyhow!( + "Not a NockApp project: '{}' missing manifest.toml", project + )); + } + + // Check if Cargo.toml exists + let cargo_toml = project_dir.join("Cargo.toml"); + if !cargo_toml.exists() { + return Err(anyhow::anyhow!("No Cargo.toml found in '{}'", project)); + } + + println!("{} Running project '{}'...", "🔨".green(), project.cyan()); + + // Run cargo run in the project directory + let mut command = Command::new("cargo"); + command + .arg("run") + .arg("--release") // Run in release mode by default + .current_dir(project_dir) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + + // Add separator and pass through additional arguments to the program + if !args.is_empty() { + command.arg("--").args(&args); + } + + let status = command + .status() + .await + .context("Failed to execute cargo run")?; + + if status.success() { + println!("{} Run completed successfully!", "✓".green()); + } else { + return Err(anyhow::anyhow!( + "Run failed with exit code: {}", + status.code().unwrap_or(-1) + )); + } + + Ok(()) +} diff --git a/crates/nockup/src/commands/update.rs b/crates/nockup/src/commands/update.rs new file mode 100644 index 000000000..ed9698013 --- /dev/null +++ b/crates/nockup/src/commands/update.rs @@ -0,0 +1,34 @@ +use anyhow::Result; +use colored::Colorize; + +use super::common; + +pub async fn run() -> Result<()> { + let cache_dir = common::get_cache_dir()?; + + println!("{} Setting up nockup cache directory...", "🚀".green()); + println!( + "{} Cache location: {}", + "📁".blue(), + cache_dir.display().to_string().cyan() + ); + + // Download or update templates + common::download_templates(&cache_dir).await?; + + // Download toolchain files + common::download_toolchain_files(&cache_dir).await?; + + // Write commit details to status file + common::write_commit_details(&cache_dir).await?; + + // Get existing config + let config = common::get_config()?; + + // Download binaries for current channel + common::download_binaries(&config).await?; + + println!("{} Update complete!", "✅".green()); + + Ok(()) +} diff --git a/crates/nockup/src/lib_manager.rs b/crates/nockup/src/lib_manager.rs new file mode 100644 index 000000000..1795a6794 --- /dev/null +++ b/crates/nockup/src/lib_manager.rs @@ -0,0 +1,411 @@ +use std::collections::HashMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{Context, Result}; +use colored::Colorize; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct LibrarySpec { + pub url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub commit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub branch: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub directory: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub file: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ProjectManifest { + pub project: ProjectInfo, + #[serde(skip_serializing_if = "Option::is_none")] + pub libraries: Option>, +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct ProjectInfo { + pub name: String, + pub project_name: String, + pub version: String, + pub description: String, + pub author_name: String, + pub author_email: String, + pub github_username: String, + pub license: String, + pub keywords: Vec, + pub nockapp_commit_hash: String, + pub template: String, +} + +pub async fn process_libraries(project_dir: &Path, manifest: &ProjectManifest) -> Result<()> { + if let Some(libraries) = &manifest.libraries { + if libraries.is_empty() { + return Ok(()); + } + + println!("{} Processing library dependencies...", "📚".cyan()); + + let cache_dir = get_library_cache_dir()?; + let project_lib_dir = project_dir.join("hoon").join("lib"); + + // Ensure library directory exists + fs::create_dir_all(&project_lib_dir) + .context("Failed to create project library directory")?; + + for (lib_name, lib_spec) in libraries { + println!( + " {} Fetching library '{}'...", + "⬇️".green(), + lib_name.cyan() + ); + + // Validate library spec + if let Err(e) = validate_library_spec(lib_spec) { + println!(" ❌ Validation failed for '{}': {}", lib_name, e); + return Err(e); + } + + // Get or clone the repository + let repo_dir = match fetch_library_repo(&cache_dir, lib_name, lib_spec).await { + Ok(dir) => dir, + Err(e) => { + println!( + " ❌ Failed to fetch repository for '{}': {}", + lib_name, e + ); + return Err(e); + } + }; + + // Handle single file vs directory/full library + if let Some(file_path) = &lib_spec.file { + // Single file import + copy_single_file(&repo_dir, &project_lib_dir, file_path)?; + } else { + // Full library import - find the appropriate source directory (desk or hoon) + let source_dir = match find_library_source_dir(&repo_dir, lib_spec) { + Ok(dir) => dir, + Err(e) => { + println!( + " ❌ Failed to find source directory for '{}': {}", + lib_name, e + ); + return Err(e); + } + }; + + // Copy library files to project + if let Err(e) = + copy_library_files(&source_dir, &project_lib_dir, lib_name, lib_spec) + { + println!(" ❌ Failed to copy files for '{}': {}", lib_name, e); + return Err(e); + } + } + + println!(" ✓ Installed library '{}'", lib_name); + } + + println!("{} All libraries processed successfully!", "✓".green()); + } + + Ok(()) +} + +fn validate_library_spec(spec: &LibrarySpec) -> Result<()> { + // Ensure mutually exclusive branch/commit + match (&spec.branch, &spec.commit) { + (Some(_), Some(_)) => { + return Err(anyhow::anyhow!( + "Library spec cannot have both 'branch' and 'commit' specified. Please use only one." + )); + } + (None, None) => { + return Err(anyhow::anyhow!( + "Library spec must specify either 'branch' or 'commit'" + )); + } + _ => {} // Valid: exactly one of branch or commit is specified + } + + // Ensure mutually exclusive directory/file + match (&spec.directory, &spec.file) { + (Some(_), Some(_)) => { + return Err(anyhow::anyhow!( + "Library spec cannot have both 'directory' and 'file' specified. Please use only one." + )); + } + _ => {} // Valid: can have neither, or exactly one + } + + // Validate URL is GitHub (for now) + if !spec.url.starts_with("https://github.com/") { + return Err(anyhow::anyhow!( + "Only GitHub repositories are currently supported. URL must start with 'https://github.com/'" + )); + } + + Ok(()) +} + +fn get_library_cache_dir() -> Result { + let cache_dir = dirs::home_dir() + .ok_or_else(|| anyhow::anyhow!("Could not find home directory"))? + .join(".nockup") + .join("library_cache"); + + fs::create_dir_all(&cache_dir).context("Failed to create library cache directory")?; + + Ok(cache_dir) +} + +async fn fetch_library_repo( + cache_dir: &Path, + lib_name: &str, + spec: &LibrarySpec, +) -> Result { + // Create a unique directory name based on URL and commit/branch + let repo_name = extract_repo_name(&spec.url)?; + let unique_id = match (&spec.commit, &spec.branch) { + (Some(commit), None) => commit.clone(), + (None, Some(branch)) => branch.clone(), + _ => unreachable!(), // Already validated + }; + + let repo_cache_dir = cache_dir.join(format!("{}_{}", repo_name, unique_id)); + + // If already cached, return it + if repo_cache_dir.exists() { + return Ok(repo_cache_dir); + } + + // Clone the repository + println!(" ⬇️ Cloning repository..."); + + let mut git_cmd = Command::new("git"); + git_cmd.args(&["clone", &spec.url]); + + // If branch specified, clone that branch + if let Some(branch) = &spec.branch { + git_cmd.args(&["--branch", branch]); + } + + git_cmd.arg(&repo_cache_dir); + + let output = git_cmd.output().context("Failed to execute git clone")?; + + if !output.status.success() { + return Err(anyhow::anyhow!( + "Git clone failed: {}", + String::from_utf8_lossy(&output.stderr) + )); + } + + // If commit specified, checkout that commit + if let Some(commit) = &spec.commit { + let checkout_output = Command::new("git") + .args(&["checkout", commit]) + .current_dir(&repo_cache_dir) + .output() + .context("Failed to checkout commit")?; + + if !checkout_output.status.success() { + return Err(anyhow::anyhow!( + "Git checkout failed: {}", + String::from_utf8_lossy(&checkout_output.stderr) + )); + } + } + + Ok(repo_cache_dir) +} + +fn extract_repo_name(url: &str) -> Result { + // Extract repository name from GitHub URL + // https://github.com/user/repo -> repo + let parts: Vec<&str> = url.trim_end_matches('/').split('/').collect(); + if parts.len() < 2 { + return Err(anyhow::anyhow!("Invalid GitHub URL format")); + } + + let repo_name = parts[parts.len() - 1]; + let repo_name = repo_name.trim_end_matches(".git"); + + Ok(repo_name.to_string()) +} + +fn find_library_source_dir(repo_dir: &Path, spec: &LibrarySpec) -> Result { + let base_dir = if let Some(directory) = &spec.directory { + repo_dir.join(directory) + } else { + repo_dir.to_path_buf() + }; + + // Look for /desk or /hoon directory + let desk_dir = base_dir.join("desk"); + let hoon_dir = base_dir.join("hoon"); + let src_dir = base_dir.join("src"); + + if desk_dir.exists() { + Ok(desk_dir) + } else if hoon_dir.exists() { + Ok(hoon_dir) + } else if src_dir.exists() { + Ok(src_dir) + } else { + Err(anyhow::anyhow!( + "No '/desk' or '/hoon' or '/src' directory found in repository. Expected Hoon library structure not found." + )) + } +} + +fn copy_library_files( + source_dir: &Path, + dest_lib_dir: &Path, + lib_name: &str, + spec: &LibrarySpec, +) -> Result<()> { + // Always use flattened approach - copy contents directly to appropriate directories + let project_hoon_dir = dest_lib_dir.parent().unwrap(); // Get /hoon from /hoon/lib + + copy_top_level_library(source_dir, project_hoon_dir, source_dir)?; + + Ok(()) +} + +fn ensure_directory_exists(dir: &Path) -> Result<()> { + fs::create_dir_all(dir) + .with_context(|| format!("Failed to create directory '{}'", dir.display())) +} + +fn copy_single_file(repo_dir: &Path, project_lib_dir: &Path, file_path: &str) -> Result<()> { + let source_file = repo_dir.join(file_path); + + // Check if the source file exists + if !source_file.exists() { + return Err(anyhow::anyhow!( + "File '{}' not found in repository", file_path + )); + } + + // Determine destination based on file path structure + let file_name = source_file + .file_name() + .ok_or_else(|| anyhow::anyhow!("Invalid file path: {}", file_path))?; + + // Get the project's /hoon directory (parent of /hoon/lib) + let project_hoon_dir = project_lib_dir.parent().unwrap(); + + // Determine destination directory based on file path + let dest_dir = if file_path.contains("/lib/") { + project_hoon_dir.join("lib") + } else if file_path.contains("/sur/") { + project_hoon_dir.join("sur") + } else if file_path.contains("/app/") { + project_hoon_dir.join("app") + } else { + // Default to lib if no specific directory is found + project_hoon_dir.join("lib") + }; + + // Ensure destination directory exists + fs::create_dir_all(&dest_dir) + .with_context(|| format!("Failed to create directory '{}'", dest_dir.display()))?; + + // Copy the file + let dest_file = dest_dir.join(file_name); + fs::copy(&source_file, &dest_file).with_context(|| { + format!( + "Failed to copy file '{}' to '{}'", + source_file.display(), + dest_file.display() + ) + })?; + + println!(" copy {}", file_path); + + Ok(()) +} + +fn copy_top_level_library(src_dir: &Path, dest_dir: &Path, root_src: &Path) -> Result<()> { + for entry in fs::read_dir(src_dir) + .with_context(|| format!("Failed to read directory '{}'", src_dir.display()))? + { + let entry = entry?; + let src_path = entry.path(); + let file_name = entry.file_name(); + let file_name_str = file_name.to_string_lossy(); + + // Skip excluded directories + if src_path.is_dir() && (file_name_str == "mar" || file_name_str == "tests") { + continue; + } + + if src_path.is_dir() { + // Create the corresponding directory in destination and copy its contents + let dest_subdir = dest_dir.join(&file_name); + fs::create_dir_all(&dest_subdir).with_context(|| { + format!("Failed to create directory '{}'", dest_subdir.display()) + })?; + copy_directory_contents(&src_path, &dest_subdir, root_src)?; + } else { + if should_copy_file(&src_path) { + let dest_path = dest_dir.join(&file_name); + fs::copy(&src_path, &dest_path) + .with_context(|| format!("Failed to copy file '{}'", src_path.display()))?; + + let relative_src = src_path.strip_prefix(root_src).unwrap_or(&src_path); + println!(" copy {}", relative_src.display()); + } + } + } + + Ok(()) +} + +fn copy_directory_contents(src_dir: &Path, dest_dir: &Path, root_src: &Path) -> Result<()> { + for entry in fs::read_dir(src_dir) + .with_context(|| format!("Failed to read directory '{}'", src_dir.display()))? + { + let entry = entry?; + let src_path = entry.path(); + let file_name = entry.file_name(); + + let dest_path = dest_dir.join(&file_name); + + if src_path.is_dir() { + // Create subdirectory and recurse + fs::create_dir_all(&dest_path) + .with_context(|| format!("Failed to create directory '{}'", dest_path.display()))?; + copy_directory_contents(&src_path, &dest_path, root_src)?; + } else { + // Copy file (only .hoon files and other relevant extensions) + if should_copy_file(&src_path) { + fs::copy(&src_path, &dest_path) + .with_context(|| format!("Failed to copy file '{}'", src_path.display()))?; + + // Show relative path for cleaner output + let relative_src = src_path.strip_prefix(root_src).unwrap_or(&src_path); + println!(" copy {}", relative_src.display()); + } + } + } + + Ok(()) +} + +fn should_copy_file(path: &Path) -> bool { + if let Some(extension) = path.extension() { + let ext = extension.to_string_lossy().to_lowercase(); + // Copy .hoon files and other relevant Hoon ecosystem files + matches!(ext.as_str(), "hoon" | "hoon-mark" | "kelvin") + } else { + // Copy files without extensions that might be relevant + false + } +} diff --git a/crates/nockup/src/main.rs b/crates/nockup/src/main.rs new file mode 100644 index 000000000..ecb7bfed8 --- /dev/null +++ b/crates/nockup/src/main.rs @@ -0,0 +1,33 @@ +use std::process; + +use clap::Parser; + +mod cli; +mod commands; +mod lib_manager; +mod version; + +use cli::*; + +#[tokio::main] +async fn main() { + let cli = Cli::parse(); + + let result = match cli.command { + None => { + // No subcommand provided - show version info + version::show_version_info().await + } + Some(Commands::Install) => commands::install::run().await, + Some(Commands::Init { name }) => commands::init::run(name).await, + Some(Commands::Update) => commands::update::run().await, + Some(Commands::Build { project }) => commands::build::run(project).await, + Some(Commands::Run { project, args }) => commands::run::run(project, args).await, + Some(Commands::Channel { action }) => commands::channel::run(action).await, + }; + + if let Err(e) = result { + eprintln!("Error: {}", e); + process::exit(1); + } +} diff --git a/crates/nockup/src/validation.rs b/crates/nockup/src/validation.rs new file mode 100644 index 000000000..caa973ab9 --- /dev/null +++ b/crates/nockup/src/validation.rs @@ -0,0 +1,300 @@ +use std::path::Path; + +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum ValidationError { + #[error("Project name cannot be empty")] + EmptyProjectName, + #[error("Project name contains invalid characters: {0}")] + InvalidProjectNameChars(String), + #[error("Project name is too long (max 50 characters)")] + ProjectNameTooLong, + #[error("Invalid channel name: {0}. Must be 'stable' or 'nightly'")] + InvalidChannelName(String), + #[error("Directory already exists: {0}")] + DirectoryExists(String), + #[error("Path does not exist: {0}")] + PathNotFound(String), +} + +pub type ValidationResult = Result; + +pub fn validate_project_name(name: &str) -> ValidationResult<()> { + if name.is_empty() { + return Err(ValidationError::EmptyProjectName); + } + + if name.len() > 50 { + return Err(ValidationError::ProjectNameTooLong); + } + + // Only allow alphanumeric characters, hyphens, and underscores + let invalid_chars: Vec = name + .chars() + .filter(|&c| !c.is_alphanumeric() && c != '-' && c != '_') + .collect(); + + if !invalid_chars.is_empty() { + let invalid_str: String = invalid_chars.iter().collect(); + return Err(ValidationError::InvalidProjectNameChars(invalid_str)); + } + + Ok(()) +} + +pub fn validate_channel_name(channel: &str) -> ValidationResult<()> { + match channel { + "stable" | "nightly" => Ok(()), + _ => Err(ValidationError::InvalidChannelName(channel.to_string())), + } +} + +pub fn validate_project_path(path: &Path) -> ValidationResult<()> { + if path.exists() { + return Err(ValidationError::DirectoryExists( + path.display().to_string(), + )); + } + Ok(()) +} + +pub fn validate_existing_project(path: &Path) -> ValidationResult<()> { + if !path.exists() { + return Err(ValidationError::PathNotFound(path.display().to_string())); + } + + // Check for manifest.toml + let manifest_path = path.join("manifest.toml"); + if !manifest_path.exists() { + return Err(ValidationError::PathNotFound( + "manifest.toml not found in project directory".to_string(), + )); + } + + Ok(()) +} + +// src/cli.rs - CLI argument structure with validation +use clap::{Parser, Subcommand}; +use crate::validation::{validate_project_name, validate_channel_name, ValidationResult}; + +#[derive(Parser)] +#[command(name = "nockup")] +#[command(about = "NockApp template app installer and manager")] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, +} + +#[derive(Subcommand)] +pub enum Commands { + /// Initialize Nockup cache and download templates + Install, + /// Check for updates to nockup, hoon, and hoonc + Update, + /// Initialize a new NockApp project + Start { + /// Name of the project to create + #[arg(value_parser = validate_project_name_arg)] + project_name: String, + }, + /// Build a NockApp project + Build { + /// Path to the project to build + project_path: String, + }, + /// Run a NockApp project + Run { + /// Path to the project to run + project_path: String, + }, + /// Manage channels + #[command(subcommand)] + Channel(ChannelCommands), +} + +#[derive(Subcommand)] +pub enum ChannelCommands { + /// List available channels + List, + /// Set the active channel + Set { + /// Channel name ('stable' or 'nightly') + #[arg(value_parser = validate_channel_name_arg)] + channel: String, + }, +} + +// Custom validators for clap +fn validate_project_name_arg(s: &str) -> Result { + validate_project_name(s) + .map(|_| s.to_string()) + .map_err(|e| e.to_string()) +} + +fn validate_channel_name_arg(s: &str) -> Result { + validate_channel_name(s) + .map(|_| s.to_string()) + .map_err(|e| e.to_string()) +} + +// src/lib.rs - Expose modules for testing +pub mod validation; +pub mod cli; + +// Unit tests for validation functions +#[cfg(test)] +mod validation_tests { + use super::validation::*; + use std::path::PathBuf; + use tempfile::TempDir; + + #[test] + fn test_validate_project_name_valid() { + assert!(validate_project_name("valid-project").is_ok()); + assert!(validate_project_name("valid_project").is_ok()); + assert!(validate_project_name("project123").is_ok()); + assert!(validate_project_name("a").is_ok()); + assert!(validate_project_name("my-awesome-project_v2").is_ok()); + } + + #[test] + fn test_validate_project_name_empty() { + assert!(matches!( + validate_project_name(""), + Err(ValidationError::EmptyProjectName) + )); + } + + #[test] + fn test_validate_project_name_too_long() { + let long_name = "a".repeat(51); + assert!(matches!( + validate_project_name(&long_name), + Err(ValidationError::ProjectNameTooLong) + )); + } + + #[test] + fn test_validate_project_name_invalid_chars() { + let invalid_names = vec![ + "project with spaces", + "project/with/slashes", + "project@with@symbols", + "project!", + "project.dot", + ]; + + for name in invalid_names { + assert!(matches!( + validate_project_name(name), + Err(ValidationError::InvalidProjectNameChars(_)) + )); + } + } + + #[test] + fn test_validate_channel_name() { + assert!(validate_channel_name("stable").is_ok()); + assert!(validate_channel_name("nightly").is_ok()); + + assert!(matches!( + validate_channel_name("invalid"), + Err(ValidationError::InvalidChannelName(_)) + )); + assert!(matches!( + validate_channel_name(""), + Err(ValidationError::InvalidChannelName(_)) + )); + } + + #[test] + fn test_validate_project_path() { + let temp_dir = TempDir::new().unwrap(); + let non_existing = temp_dir.path().join("non-existing"); + let existing = temp_dir.path().join("existing"); + std::fs::create_dir(&existing).unwrap(); + + assert!(validate_project_path(&non_existing).is_ok()); + assert!(matches!( + validate_project_path(&existing), + Err(ValidationError::DirectoryExists(_)) + )); + } + + #[test] + fn test_validate_existing_project() { + let temp_dir = TempDir::new().unwrap(); + let project_dir = temp_dir.path().join("project"); + let non_existing = temp_dir.path().join("non-existing"); + + // Test non-existing project + assert!(matches!( + validate_existing_project(&non_existing), + Err(ValidationError::PathNotFound(_)) + )); + + // Test project without manifest + std::fs::create_dir(&project_dir).unwrap(); + assert!(matches!( + validate_existing_project(&project_dir), + Err(ValidationError::PathNotFound(_)) + )); + + // Test valid project + std::fs::write(project_dir.join("manifest.toml"), "").unwrap(); + assert!(validate_existing_project(&project_dir).is_ok()); + } +} + +// Property-based testing with proptest (optional) +#[cfg(feature = "proptest")] +mod proptest_validation { + use super::validation::*; + use proptest::prelude::*; + + proptest! { + #[test] + fn test_valid_project_names(s in "[a-zA-Z0-9_-]{1,50}") { + prop_assert!(validate_project_name(&s).is_ok()); + } + + #[test] + fn test_invalid_project_names_with_spaces(s in ".*[ ].*") { + if !s.is_empty() && s.len() <= 50 { + prop_assert!(validate_project_name(&s).is_err()); + } + } + + #[test] + fn test_project_names_too_long(s in "[a-zA-Z0-9_-]{51,100}") { + prop_assert!(matches!( + validate_project_name(&s), + Err(ValidationError::ProjectNameTooLong) + )); + } + } +} + +// Example of how to run validation in your main application +pub fn handle_start_command(project_name: &str) -> Result<(), Box> { + // Validation is already done by clap, but you can add additional checks + let project_path = std::path::Path::new(project_name); + + validate_project_path(project_path)?; + + // Proceed with project creation... + println!("Creating project: {}", project_name); + Ok(()) +} + +pub fn handle_build_command(project_path: &str) -> Result<(), Box> { + let path = std::path::Path::new(project_path); + + validate_existing_project(path)?; + + // Proceed with build... + println!("Building project at: {}", project_path); + Ok(()) +} \ No newline at end of file diff --git a/crates/nockup/src/version.rs b/crates/nockup/src/version.rs new file mode 100644 index 000000000..f91a170d2 --- /dev/null +++ b/crates/nockup/src/version.rs @@ -0,0 +1,115 @@ +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use colored::Colorize; +use tokio::process::Command as TokioCommand; + +pub async fn show_version_info() -> Result<()> { + // Show nockup version + println!("nockup version {}", env!("FULL_VERSION")); + + // Get hoon version + match get_binary_version("hoon").await { + Ok(version) => println!("hoon version {}", version), + Err(_) => println!("hoon {}", "not found".red()), + } + + // Get hoonc version + match get_binary_version("hoonc").await { + Ok(version) => println!("hoonc version {}", version), + Err(_) => println!("hoonc {}", "not found".red()), + } + + // Get current channel and architecture + // The channel is in the TOML file at ~/.nockup/config.toml + let config = get_config()?; + println!( + "current channel {}", + config["channel"].as_str().unwrap_or("stable") + ); + println!( + "current architecture {}", + config["architecture"].as_str().unwrap_or("unknown") + ); + + Ok(()) +} + +async fn get_binary_version(binary_name: &str) -> Result { + // First check if binary exists in PATH + let binary_path = + which::which(binary_name).context(format!("{} not found in PATH", binary_name))?; + + // Verify the binary is the correct architecture + let file_output = TokioCommand::new("file") + .arg(&binary_path) + .output() + .await + .context("Failed to check binary architecture")?; + + let file_info = String::from_utf8_lossy(&file_output.stdout); + let current_arch = std::env::consts::ARCH; + let expected_arch = match current_arch { + "x86_64" => "x86_64", + "aarch64" => "arm64", // macOS uses "arm64" in file output + _ => current_arch, + }; + + if !file_info.contains(expected_arch) { + return Err(anyhow::anyhow!( + "Binary architecture mismatch for {}: expected {}, found different architecture", + binary_name, expected_arch + )); + } + + // Try common version flags + let version_flags = ["--version", "-V", "-v", "version"]; + + for flag in &version_flags { + if let Ok(output) = TokioCommand::new(&binary_path).arg(flag).output().await { + if output.status.success() { + let version_output = String::from_utf8_lossy(&output.stdout); + let version_line = version_output.lines().next().unwrap_or("").trim(); + + if !version_line.is_empty() { + return Ok(extract_version_string(version_line)); + } + } + } + } + + Err(anyhow::anyhow!( + "Could not determine {} version - none of the common version flags worked", binary_name + )) +} + +fn extract_version_string(version_line: &str) -> String { + // Try to extract just the version part from output. + let words: Vec<&str> = version_line.split_whitespace().collect(); + + // Look for a word that looks like a version (starts with digit or 'v'). + for word in &words { + if word.chars().next().map_or(false, |c| c.is_ascii_digit()) { + return word.to_string(); + } + if word.starts_with('v') && word.len() > 1 { + return word[1..].to_string(); + } + } + + // Fallback: return the whole line. + version_line.to_string() +} + +fn get_cache_dir() -> Result { + let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; + Ok(home.join(".nockup")) +} + +fn get_config() -> Result { + let cache_dir = get_cache_dir()?; + let config_path = cache_dir.join("config.toml"); + let config_str = std::fs::read_to_string(&config_path)?; + let config: toml::Value = toml::de::from_str(&config_str)?; + Ok(config) +} diff --git a/crates/nockup/templates/basic/Cargo.toml b/crates/nockup/templates/basic/Cargo.toml new file mode 100644 index 000000000..ef3527e98 --- /dev/null +++ b/crates/nockup/templates/basic/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "{{project_name}}" +version = "0.1.0" +edition = "2021" +authors = ["{{author_name}} <{{author_email}}>"] +description = "A NockApp project" + +[[bin]] +name = "{{project_name}}" +path = "src/main.rs" + +[dependencies] +# NockVM dependency +nockapp = { git = "https://github.com/nockchain/nockchain.git", rev = "{{nockapp_commit_hash}}" } +nockvm = { git = "https://github.com/nockchain/nockchain.git", rev = "{{nockapp_commit_hash}}" } +nockvm_macros = { git = "https://github.com/nockchain/nockchain.git", rev = "{{nockapp_commit_hash}}" } + +# Common dependencies for NockApps +serde = { version = "1.0", features = ["derive"] } +toml = "0.8" +anyhow = "1.0" +bytes = "1.5.0" +tokio = { version = "1.32", features = [ + "fs", + "io-util", + "macros", + "net", + "rt-multi-thread", + "rt", + "signal", +] } +tokio-util = "0.7.11" + +[build-dependencies] +# Build script dependencies if needed \ No newline at end of file diff --git a/crates/nockup/templates/basic/README.md b/crates/nockup/templates/basic/README.md new file mode 100644 index 000000000..9960be7d1 --- /dev/null +++ b/crates/nockup/templates/basic/README.md @@ -0,0 +1,61 @@ +# {{project_name}} + +A NockApp project created with `nockup`. + +## Description + +{{project_description}} + +## Building + +To build this project: + +```bash +nockup build {{project_name}} +``` + +Or using cargo directly: + +```bash +cargo build --release +``` + +## Running + +To run this project: + +```bash +nockup run {{project_name}} +``` + +Or using cargo directly: + +```bash +cargo run +``` + +## Project Structure + +- `src/main.rs` - Main Rust entry point +- `src/lib.rs` - Core NockApp library code +- `src/app.hoon` - Hoon application logic +- `manifest.toml` - NockApp configuration +- `build.rs` - Build script for compiling Hoon code +- `Cargo.toml` - Rust dependencies and configuration + +## Development + +This project uses both Rust and Hoon: + +- **Rust** handles the runtime, VM integration, and system interfaces +- **Hoon** contains the core application logic that compiles to Nock +- The `build.rs` script automatically compiles Hoon to Nock during the build process + +## Dependencies + +- [NockApp](https://github.com/nockchain/nockchain) - Nock virtual machine +- Standard Rust crates for serialization and error handling + +## License + +This project is licensed under {{license}}. diff --git a/crates/nockup/templates/basic/build.rs b/crates/nockup/templates/basic/build.rs new file mode 100644 index 000000000..cc9ece4e0 --- /dev/null +++ b/crates/nockup/templates/basic/build.rs @@ -0,0 +1,42 @@ +use std::env; +use std::fs; +use std::path::Path; +use std::process::Command; + +fn main() { + println!("cargo:rerun-if-changed=hoon/app/app.hoon"); + println!("cargo:rerun-if-changed=hoon/lib/lib.hoon"); + + let out_dir = env::var("OUT_DIR").unwrap(); + let hoon_app_file = "hoon/app/app.hoon"; + + if Path::new(hoon_app_file).exists() { + // Compile Hoon to Nock (this is a placeholder - adjust based on actual hoonc usage) + let output = Command::new("hoonc") + .args(&[hoon_app_file, "--output", &format!("{}/app.nock", out_dir)]) + .output(); + + match output { + Ok(result) => { + if !result.status.success() { + panic!( + "Failed to compile Hoon: {}", + String::from_utf8_lossy(&result.stderr) + ); + } + println!("cargo:rustc-env=COMPILED_HOON_PATH={}/app.nock", out_dir); + } + Err(e) => { + println!( + "cargo:warning=Could not run hoonc: {}. Skipping Hoon compilation.", + e + ); + } + } + } else { + println!( + "cargo:warning=No Hoon file found at {}. Skipping Hoon compilation.", + hoon_app_file + ); + } +} diff --git a/crates/nockup/templates/basic/hoon/app/app.hoon b/crates/nockup/templates/basic/hoon/app/app.hoon new file mode 100644 index 000000000..9d421b883 --- /dev/null +++ b/crates/nockup/templates/basic/hoon/app/app.hoon @@ -0,0 +1,52 @@ +/+ lib +/= * /common/wrapper +:: +=> +|% ++$ versioned-state + $: %v1 + ~ + == +:: ++$ effect + $% [%effect @t] + == +:: ++$ cause + $% [%cause ~] + == +-- +|% +++ moat (keep versioned-state) +:: +++ inner + |_ state=versioned-state + :: + ++ load + |= old-state=versioned-state + ^- _state + ?: =(-.old-state %v1) + old-state + old-state + :: + ++ peek + |= =path + ^- (unit (unit *)) + ~> %slog.[0 'Peeks awaiting implementation'] + ~ + :: + ++ poke + |= =ovum:moat + ^- [(list effect) _state] + =/ cause ((soft cause) cause.input.ovum) + ?~ cause + ~> %slog.[3 (crip "invalid cause {}")] + :_ state + ^- (list effect) + ~[[%effect 'Invalid cause format']] + ~> %slog.[1 (cat 3 'poked: ' -.u.cause)] + ~> %slog.[0 'Pokes awaiting implementation'] + [~ state] + -- +-- +((moat |) inner) diff --git a/crates/nockup/templates/basic/hoon/common/wrapper.hoon b/crates/nockup/templates/basic/hoon/common/wrapper.hoon new file mode 100644 index 000000000..ba0b5cb97 --- /dev/null +++ b/crates/nockup/templates/basic/hoon/common/wrapper.hoon @@ -0,0 +1,103 @@ +~% %wrapper ..ut ~ +|% ++$ goof [mote=term =tang] ++$ wire path ++$ ovum [=wire =input] ++$ crud [=goof =input] ++$ input [eny=@ our=@ux now=@da cause=*] +:: +++ keep + |* inner=mold + => + |% + +$ inner-state inner + +$ outer-state + $% [%0 desk-hash=(unit @uvI) internal=inner] + == + +$ outer-fort + $_ ^| + |_ outer-state + ++ load + |~ arg=outer-state + ** + ++ peek + |~ arg=path + *(unit (unit *)) + ++ poke + |~ [num=@ ovum=*] + *[(list *) *] + ++ wish + |~ txt=@ + ** + -- + :: + +$ fort + $_ ^| + |_ state=inner-state + ++ load + |~ arg=inner-state + *inner-state + ++ peek + |~ arg=path + *(unit (unit *)) + ++ poke + |~ arg=ovum + [*(list *) *inner-state] + -- + -- + :: + |= crash=? + |= inner=fort + |= hash=@uvI + =< .(desk-hash.outer `hash) + |_ outer=outer-state + +* inner-fort ~(. inner internal.outer) + ++ load + |= old=outer-state + ~& build-hash+hash + ?+ -.old ~&("wrapper +load: invalid old state" !!) + %0 + =/ new-internal (load:inner-fort internal.old) + ..load(internal.outer new-internal) + == + :: + ++ peek + |= arg=path + ^- (unit (unit *)) + =/ pax ((soft path) arg) + ?~ pax + ~> %slog.[0 leaf+"wrapper +poke: arg is not a path"] + ~ + (peek:inner-fort u.pax) + :: + ++ wish + |= txt=@ + ^- * + q:(slap !>(~) (ream txt)) + :: + ++ poke + |= [num=@ ovum=*] + ^- [(list *) _..poke] + =/ effects=(list *) ?:(crash ~[exit/0] ~) + ?+ ovum ~&("wrapper +poke invalid arg: {}" effects^..poke) + [[%$ %arvo ~] *] + =/ g ((soft crud) +.ovum) + ?~ g ~&(%invalid-goof effects^..poke) + ?: ?=(%intr mote.goof.u.g) + [effects ..poke] + =- [effects ..poke] + (slog tang.goof.u.g) + :: + [[%poke *] *] + =/ ovum ((soft ^ovum) ovum) + ?~ ovum ~&("wrapper +poke invalid arg: {}" ~^..poke) + =/ o ((soft input) input.u.ovum) + ?~ o + ~& "wrapper: could not mold poke type: {}" + ~^..poke + =^ effects internal.outer + (poke:inner-fort u.ovum) + [effects ..poke(internal.outer internal.outer)] + == + -- +-- diff --git a/crates/nockup/templates/basic/hoon/lib/lib.hoon b/crates/nockup/templates/basic/hoon/lib/lib.hoon new file mode 100644 index 000000000..f0f441d6e --- /dev/null +++ b/crates/nockup/templates/basic/hoon/lib/lib.hoon @@ -0,0 +1,4 @@ +|% +++ arm !! +++ leg 0 +-- diff --git a/crates/nockup/templates/basic/manifest.toml b/crates/nockup/templates/basic/manifest.toml new file mode 100644 index 000000000..12f4953bc --- /dev/null +++ b/crates/nockup/templates/basic/manifest.toml @@ -0,0 +1,12 @@ +[project] +name = "{{name}}" +project_name = "{{project_name}}" +version = "{{version}}" +description = "{{description}}" +author_name = "{{author_name}}" +author_email = "{{author_email}}" +github_username = "{{github_username}}" +license = "{{license}}" +keywords = ["nockapp", "nockchain", "hoon"] +nockapp_commit_hash = "{{nockapp_commit_hash}}" +template = "basic" diff --git a/crates/nockup/templates/basic/src/main.rs b/crates/nockup/templates/basic/src/main.rs new file mode 100644 index 000000000..e920ebe36 --- /dev/null +++ b/crates/nockup/templates/basic/src/main.rs @@ -0,0 +1,50 @@ +use std::error::Error; +use std::fs; + +use nockapp::kernel::boot; +use nockapp::{exit_driver, http_driver, AtomExt, NockApp}; + +use nockapp::noun::slab::NounSlab; +use nockapp::wire::{SystemWire, Wire}; +use nockvm::noun::{Atom, D, T}; +use nockvm_macros::tas; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let cli = boot::default_boot_cli(false); + boot::init_default_tracing(&cli); + + let kernel = fs::read("out.jam") + .map_err(|e| format!("Failed to read out.jam: {}", e))?; + + let mut nockapp: NockApp = boot::setup(&kernel, Some(cli), &[], "{{project_name}}", None).await?; + + let mut poke_slab = NounSlab::new(); + let command_noun = T(&mut poke_slab, &[D(tas!(b"cause")), D(0x0)]); + poke_slab.set_root(command_noun); + + let result = match nockapp.poke(SystemWire.to_wire(), poke_slab).await { + Ok(effects) => { + let mut results = Vec::new(); + for (_i, effect) in effects.iter().enumerate() { + let effect_noun = unsafe { effect.root() }; + if let Ok(cell) = effect_noun.as_cell() { + let Ok(tail_atom) = cell.tail().as_atom() else { + continue; + }; + let Ok(tail_string) = std::str::from_utf8(tail_atom.as_ne_bytes()) else { + continue; + }; + results.push(tail_string.trim_end_matches('\0').to_string()); + } + } + results.last().unwrap_or(&String::new()).clone() + } + Err(_e) => { + "command failed".to_string() + } + }; + + println!("{}", result); + Ok(()) +} diff --git a/crates/nockup/templates/grpc/Cargo.toml b/crates/nockup/templates/grpc/Cargo.toml new file mode 100644 index 000000000..1d443cb92 --- /dev/null +++ b/crates/nockup/templates/grpc/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "grpc" +version = "0.1.0" +edition = "2021" +authors = ["N. E. Davis "] +description = "A NockApp project" + +[[bin]] +name = "grpc" +path = "src/main.rs" + +[dependencies] +# NockVM dependency +nockapp = { git = "https://github.com/nockchain/nockchain.git", rev = "485e914b389a1e518d4aaaa24f5f079d0ad894be" } +nockvm = { git = "https://github.com/nockchain/nockchain.git", rev = "485e914b389a1e518d4aaaa24f5f079d0ad894be" } +nockvm_macros = { git = "https://github.com/nockchain/nockchain.git", rev = "485e914b389a1e518d4aaaa24f5f079d0ad894be" } +noun-serde = { git = "https://github.com/nockchain/nockchain.git", rev = "485e914b389a1e518d4aaaa24f5f079d0ad894be" } +noun-serde-derive = { git = "https://github.com/nockchain/nockchain.git", rev = "485e914b389a1e518d4aaaa24f5f079d0ad894be" } +nockapp-grpc = { git = "https://github.com/nockchain/nockchain.git", rev = "485e914b389a1e518d4aaaa24f5f079d0ad894be" } + +# Common dependencies for NockApps +serde = { version = "1.0", features = ["derive"] } +toml = "0.8" +anyhow = "1.0" +bytes = "1.5.0" +clap = "4.4.4" +tokio = { version = "1.32", features = [ + "fs", + "io-util", + "macros", + "net", + "rt-multi-thread", + "rt", + "signal", +] } +tokio-util = "0.7.11" +tracing = "0.1.41" + +[build-dependencies] +# Build script dependencies if needed \ No newline at end of file diff --git a/crates/nockup/templates/grpc/README.md b/crates/nockup/templates/grpc/README.md new file mode 100644 index 000000000..9960be7d1 --- /dev/null +++ b/crates/nockup/templates/grpc/README.md @@ -0,0 +1,61 @@ +# {{project_name}} + +A NockApp project created with `nockup`. + +## Description + +{{project_description}} + +## Building + +To build this project: + +```bash +nockup build {{project_name}} +``` + +Or using cargo directly: + +```bash +cargo build --release +``` + +## Running + +To run this project: + +```bash +nockup run {{project_name}} +``` + +Or using cargo directly: + +```bash +cargo run +``` + +## Project Structure + +- `src/main.rs` - Main Rust entry point +- `src/lib.rs` - Core NockApp library code +- `src/app.hoon` - Hoon application logic +- `manifest.toml` - NockApp configuration +- `build.rs` - Build script for compiling Hoon code +- `Cargo.toml` - Rust dependencies and configuration + +## Development + +This project uses both Rust and Hoon: + +- **Rust** handles the runtime, VM integration, and system interfaces +- **Hoon** contains the core application logic that compiles to Nock +- The `build.rs` script automatically compiles Hoon to Nock during the build process + +## Dependencies + +- [NockApp](https://github.com/nockchain/nockchain) - Nock virtual machine +- Standard Rust crates for serialization and error handling + +## License + +This project is licensed under {{license}}. diff --git a/crates/nockup/templates/grpc/build.rs b/crates/nockup/templates/grpc/build.rs new file mode 100644 index 000000000..cc9ece4e0 --- /dev/null +++ b/crates/nockup/templates/grpc/build.rs @@ -0,0 +1,42 @@ +use std::env; +use std::fs; +use std::path::Path; +use std::process::Command; + +fn main() { + println!("cargo:rerun-if-changed=hoon/app/app.hoon"); + println!("cargo:rerun-if-changed=hoon/lib/lib.hoon"); + + let out_dir = env::var("OUT_DIR").unwrap(); + let hoon_app_file = "hoon/app/app.hoon"; + + if Path::new(hoon_app_file).exists() { + // Compile Hoon to Nock (this is a placeholder - adjust based on actual hoonc usage) + let output = Command::new("hoonc") + .args(&[hoon_app_file, "--output", &format!("{}/app.nock", out_dir)]) + .output(); + + match output { + Ok(result) => { + if !result.status.success() { + panic!( + "Failed to compile Hoon: {}", + String::from_utf8_lossy(&result.stderr) + ); + } + println!("cargo:rustc-env=COMPILED_HOON_PATH={}/app.nock", out_dir); + } + Err(e) => { + println!( + "cargo:warning=Could not run hoonc: {}. Skipping Hoon compilation.", + e + ); + } + } + } else { + println!( + "cargo:warning=No Hoon file found at {}. Skipping Hoon compilation.", + hoon_app_file + ); + } +} diff --git a/crates/nockup/templates/grpc/hoon/app/listen.hoon b/crates/nockup/templates/grpc/hoon/app/listen.hoon new file mode 100644 index 000000000..c44b5d972 --- /dev/null +++ b/crates/nockup/templates/grpc/hoon/app/listen.hoon @@ -0,0 +1,54 @@ +/+ lib +/= * /common/wrapper +:: +=> +|% ++$ versioned-state + $: %v1 + ~ + == +:: ++$ effect + $% [%effect @t] + == +:: ++$ cause + $% [%cause ~] + [%command val=@t] + == +-- +|% +++ moat (keep versioned-state) +:: +++ inner + |_ state=versioned-state + :: + ++ load + |= old-state=versioned-state + ^- _state + ?: =(-.old-state %v1) + old-state + old-state + :: + ++ peek + |= =path + ^- (unit (unit *)) + ~> %slog.[0 'Received peek'] + ~ + :: + ++ poke + |= =ovum:moat + ^- [(list effect) _state] + ~> %slog.[0 'Received poke'] + ~& cause.input.ovum + =/ cause ((soft cause) cause.input.ovum) + ?~ cause + ~> %slog.[3 (crip "invalid cause {}")] + :_ state + ^- (list effect) + ~[[%effect 'Invalid cause format']] + ~> %slog.[0 'Received poke'] + `state + -- +-- +((moat |) inner) diff --git a/crates/nockup/templates/grpc/hoon/app/talk.hoon b/crates/nockup/templates/grpc/hoon/app/talk.hoon new file mode 100644 index 000000000..a5f815649 --- /dev/null +++ b/crates/nockup/templates/grpc/hoon/app/talk.hoon @@ -0,0 +1,60 @@ +/+ *lib +/= * /common/wrapper +:: +=> +|% ++$ versioned-state + $: %v1 + ~ + == +:: ++$ cause + $% [%cause ~] + [%command val=@t] + ^cause + == +:: ++$ effect + $% [%effect msg=@] + ^effect + == +-- +|% +++ moat (keep versioned-state) +:: +++ inner + |_ state=versioned-state + :: + ++ load + |= old-state=versioned-state + ^- _state + ?: =(-.old-state %v1) + old-state + old-state + :: + ++ peek + |= =path + ^- (unit (unit *)) + ~> %slog.[0 'Peeks awaiting implementation'] + ~ + :: + ++ poke + |= =ovum:moat + ^- [(list effect) _state] + =/ cause ((soft cause) cause.input.ovum) + ?~ cause + ~> %slog.[3 (crip "invalid cause {}")] + :_ state + ^- (list effect) + ~[[%effect 'Invalid cause format']] + :_ state + ^- (list effect) + =/ pid 42 :: implementation-specific meaning + =/ val -.u.cause + :~ [%grpc %peek pid %talk /path] + [%grpc %poke pid val] + [%exit ~] + == + -- +-- +((moat |) inner) diff --git a/crates/nockup/templates/grpc/hoon/common/wrapper.hoon b/crates/nockup/templates/grpc/hoon/common/wrapper.hoon new file mode 100644 index 000000000..ba0b5cb97 --- /dev/null +++ b/crates/nockup/templates/grpc/hoon/common/wrapper.hoon @@ -0,0 +1,103 @@ +~% %wrapper ..ut ~ +|% ++$ goof [mote=term =tang] ++$ wire path ++$ ovum [=wire =input] ++$ crud [=goof =input] ++$ input [eny=@ our=@ux now=@da cause=*] +:: +++ keep + |* inner=mold + => + |% + +$ inner-state inner + +$ outer-state + $% [%0 desk-hash=(unit @uvI) internal=inner] + == + +$ outer-fort + $_ ^| + |_ outer-state + ++ load + |~ arg=outer-state + ** + ++ peek + |~ arg=path + *(unit (unit *)) + ++ poke + |~ [num=@ ovum=*] + *[(list *) *] + ++ wish + |~ txt=@ + ** + -- + :: + +$ fort + $_ ^| + |_ state=inner-state + ++ load + |~ arg=inner-state + *inner-state + ++ peek + |~ arg=path + *(unit (unit *)) + ++ poke + |~ arg=ovum + [*(list *) *inner-state] + -- + -- + :: + |= crash=? + |= inner=fort + |= hash=@uvI + =< .(desk-hash.outer `hash) + |_ outer=outer-state + +* inner-fort ~(. inner internal.outer) + ++ load + |= old=outer-state + ~& build-hash+hash + ?+ -.old ~&("wrapper +load: invalid old state" !!) + %0 + =/ new-internal (load:inner-fort internal.old) + ..load(internal.outer new-internal) + == + :: + ++ peek + |= arg=path + ^- (unit (unit *)) + =/ pax ((soft path) arg) + ?~ pax + ~> %slog.[0 leaf+"wrapper +poke: arg is not a path"] + ~ + (peek:inner-fort u.pax) + :: + ++ wish + |= txt=@ + ^- * + q:(slap !>(~) (ream txt)) + :: + ++ poke + |= [num=@ ovum=*] + ^- [(list *) _..poke] + =/ effects=(list *) ?:(crash ~[exit/0] ~) + ?+ ovum ~&("wrapper +poke invalid arg: {}" effects^..poke) + [[%$ %arvo ~] *] + =/ g ((soft crud) +.ovum) + ?~ g ~&(%invalid-goof effects^..poke) + ?: ?=(%intr mote.goof.u.g) + [effects ..poke] + =- [effects ..poke] + (slog tang.goof.u.g) + :: + [[%poke *] *] + =/ ovum ((soft ^ovum) ovum) + ?~ ovum ~&("wrapper +poke invalid arg: {}" ~^..poke) + =/ o ((soft input) input.u.ovum) + ?~ o + ~& "wrapper: could not mold poke type: {}" + ~^..poke + =^ effects internal.outer + (poke:inner-fort u.ovum) + [effects ..poke(internal.outer internal.outer)] + == + -- +-- diff --git a/crates/nockup/templates/grpc/hoon/lib/lib.hoon b/crates/nockup/templates/grpc/hoon/lib/lib.hoon new file mode 100644 index 000000000..a1a4e3558 --- /dev/null +++ b/crates/nockup/templates/grpc/hoon/lib/lib.hoon @@ -0,0 +1,35 @@ +|% +:: Commands ++$ peek-command + $% [%path =path] + == +:: ++$ poke-command + $% [%poke-simple ~] + [%poke-value val=@] + == +:: Causes ++$ cause + $% other-cause + grpc-bind-cause + poke-command + == +:: ++$ other-cause + $% [%born command=peek-command] + == +:: ++$ grpc-bind-cause + $% [%grpc-bind result=(unit (unit *))] + == +:: Effects ++$ effect + $% [%exit code=@] + [%grpc grpc-effect] + == +:: ++$ grpc-effect + $% [%peek pid=@ typ=@tas =path] + [%poke pid=@ val=@] + == +-- diff --git a/crates/nockup/templates/grpc/manifest.toml b/crates/nockup/templates/grpc/manifest.toml new file mode 100644 index 000000000..b954d476a --- /dev/null +++ b/crates/nockup/templates/grpc/manifest.toml @@ -0,0 +1,12 @@ +[project] +name = "{{name}}" +project_name = "{{project_name}}" +version = "{{version}}" +description = "{{description}}" +author_name = "{{author_name}}" +author_email = "{{author_email}}" +github_username = "{{github_username}}" +license = "{{license}}" +keywords = ["nockapp", "nockchain", "hoon"] +nockapp_commit_hash = "{{nockapp_commit_hash}}" +template = "grpc" diff --git a/crates/nockup/templates/grpc/src/lib.rs b/crates/nockup/templates/grpc/src/lib.rs new file mode 100644 index 000000000..6b8e65529 --- /dev/null +++ b/crates/nockup/templates/grpc/src/lib.rs @@ -0,0 +1,12 @@ +use std::error::Error; + +use nockapp::{AtomExt, Bytes, NockApp, NockAppError, Noun}; +use nockapp::noun::slab::NounSlab; +use nockvm::noun::{Atom, D, T}; + +pub fn string_to_atom(slab: &mut NounSlab, s: &str) -> Result> { + let bytes = Bytes::from(s.as_bytes().to_vec()); + Ok(Atom::from_bytes(slab, &bytes)) +} + +pub const GRPC_PORT: &str = "5555"; diff --git a/crates/nockup/templates/grpc/src/listen.rs b/crates/nockup/templates/grpc/src/listen.rs new file mode 100644 index 000000000..57c311750 --- /dev/null +++ b/crates/nockup/templates/grpc/src/listen.rs @@ -0,0 +1,48 @@ +use std::error::Error; +use std::fs; +use std::io::{self, Write}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::path::Path; + +use nockapp::driver::{make_driver, IODriverFn, NockAppHandle, Operation}; +use nockapp::kernel::boot; +use nockapp::noun::slab::NounSlab; +use nockapp::wire::{SystemWire, Wire, WireRepr, WireTag as AppWireTag}; +use nockapp::{exit_driver, file_driver}; +use nockapp::{AtomExt, Bytes, NockApp, NockAppError, Noun}; +use nockapp_grpc::client::NockAppGrpcClient; +use nockapp_grpc::driver::{grpc_listener_driver, grpc_server_driver}; +use nockapp_grpc::wire_conversion::create_grpc_wire; +use nockapp_grpc::NockAppGrpcServer; +use nockvm::noun::{Atom, D, T}; +use nockvm_macros::tas; +use noun_serde::{NounDecode, NounDecodeError, NounEncode}; +use tracing::{error, info}; + +use grpc::string_to_atom; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let cli = boot::default_boot_cli(false); + boot::init_default_tracing(&cli); + + let source_filename = Path::new(file!()).file_stem().unwrap().to_str().unwrap(); + let fallback_filename = format!("{}.jam", source_filename); + + let kernel = fs::read("out.jam") + .or_else(|_| fs::read(&fallback_filename)) + .map_err(|e| format!("Failed to read kernel file: {}", e))?; + let mut nockapp: NockApp = boot::setup(&kernel, Some(cli), &[], source_filename, None) + .await + .map_err(|e| format!("Kernel setup failed: {}", e))?; + + // Set up drivers. + nockapp.add_io_driver(grpc_server_driver()).await; + nockapp.add_io_driver(exit_driver()).await; + + // Run app kernel. + println!("Starting main kernel loop..."); + nockapp.run().await.expect("Failed to run app"); + + Ok(()) +} diff --git a/crates/nockup/templates/grpc/src/talk.rs b/crates/nockup/templates/grpc/src/talk.rs new file mode 100644 index 000000000..9eb71791d --- /dev/null +++ b/crates/nockup/templates/grpc/src/talk.rs @@ -0,0 +1,71 @@ +use std::error::Error; +use std::fs; +use std::io::{self, Write}; +use std::net::{IpAddr, Ipv4Addr, SocketAddr}; +use std::path::Path; + +use nockapp::driver::{make_driver, IODriverFn, NockAppHandle, Operation}; +use nockapp::kernel::boot; +use nockapp::noun::slab::NounSlab; +use nockapp::wire::{SystemWire, Wire, WireRepr, WireTag as AppWireTag}; +use nockapp::{AtomExt, Bytes, NockApp, NockAppError, Noun}; +use nockapp::{exit_driver, file_driver}; +use nockapp::utils::make_tas; +use nockapp_grpc::NockAppGrpcServer; +use nockapp_grpc::client::NockAppGrpcClient; +use nockapp_grpc::driver::{GrpcEffect, grpc_listener_driver, grpc_server_driver}; +use nockapp_grpc::wire_conversion::{create_grpc_wire, grpc_wire_to_nockapp}; +use nockvm::noun::{Atom, D, T}; +use nockvm_macros::tas; +use noun_serde::{NounDecode, NounDecodeError, NounEncode}; +use tracing::{error, info}; + +use grpc::string_to_atom; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let cli = boot::default_boot_cli(false); + boot::init_default_tracing(&cli); + + let source_filename = Path::new(file!()) + .file_stem() + .unwrap() + .to_str() + .unwrap(); + let fallback_filename = format!("{}.jam", source_filename); + + let kernel = fs::read("out.jam") + .or_else(|_| fs::read(&fallback_filename)) + .map_err(|e| format!("Failed to read kernel file: {}", e))?; + let mut nockapp: NockApp = boot::setup( + &kernel, + Some(cli), + &[], + source_filename, + None + ) + .await + .map_err(|e| format!("Kernel setup failed: {}", e))?; + + // Load demo poke. + let mut poke_slab = NounSlab::new(); + let str_atom = string_to_atom(&mut poke_slab, "hello world")?; + let head = make_tas(&mut poke_slab, "poke-value").as_noun(); + let command_noun = T(&mut poke_slab, &[head, str_atom.as_noun()]); + poke_slab.set_root(command_noun); + + // The demo poke generates a %grpc effect which we want to emit. + nockapp + .add_io_driver(nockapp::one_punch_driver(poke_slab, Operation::Poke)) + .await; + nockapp + .add_io_driver(grpc_listener_driver(format!("http://127.0.0.1:{}", grpc::GRPC_PORT.to_string()))) + .await; + nockapp + .add_io_driver(exit_driver()) + .await; + + nockapp.run().await; + + Ok(()) +} diff --git a/crates/nockup/templates/http-server/Cargo.toml b/crates/nockup/templates/http-server/Cargo.toml new file mode 100644 index 000000000..cf6d1997a --- /dev/null +++ b/crates/nockup/templates/http-server/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "http-server" +version = "0.1.0" +edition = "2021" +authors = ["N. E. Davis "] +description = "A NockApp project" + +[[bin]] +name = "http-server" +path = "src/main.rs" + +[dependencies] +# NockVM dependency +nockapp = { git = "https://github.com/nockchain/nockchain.git", rev = "336f744b6b83448ec2b86473a3dec29b15858999" } +nockvm = { git = "https://github.com/nockchain/nockchain.git", rev = "336f744b6b83448ec2b86473a3dec29b15858999" } +nockvm_macros = { git = "https://github.com/nockchain/nockchain.git", rev = "336f744b6b83448ec2b86473a3dec29b15858999" } + +# Common dependencies for NockApps +serde = { version = "1.0", features = ["derive"] } +toml = "0.8" +anyhow = "1.0" +bytes = "1.5.0" +tokio = { version = "1.32", features = [ + "fs", + "io-util", + "macros", + "net", + "rt-multi-thread", + "rt", + "signal", +] } +tokio-util = "0.7.11" + +[build-dependencies] +# Build script dependencies if needed \ No newline at end of file diff --git a/crates/nockup/templates/http-server/README.md b/crates/nockup/templates/http-server/README.md new file mode 100644 index 000000000..9960be7d1 --- /dev/null +++ b/crates/nockup/templates/http-server/README.md @@ -0,0 +1,61 @@ +# {{project_name}} + +A NockApp project created with `nockup`. + +## Description + +{{project_description}} + +## Building + +To build this project: + +```bash +nockup build {{project_name}} +``` + +Or using cargo directly: + +```bash +cargo build --release +``` + +## Running + +To run this project: + +```bash +nockup run {{project_name}} +``` + +Or using cargo directly: + +```bash +cargo run +``` + +## Project Structure + +- `src/main.rs` - Main Rust entry point +- `src/lib.rs` - Core NockApp library code +- `src/app.hoon` - Hoon application logic +- `manifest.toml` - NockApp configuration +- `build.rs` - Build script for compiling Hoon code +- `Cargo.toml` - Rust dependencies and configuration + +## Development + +This project uses both Rust and Hoon: + +- **Rust** handles the runtime, VM integration, and system interfaces +- **Hoon** contains the core application logic that compiles to Nock +- The `build.rs` script automatically compiles Hoon to Nock during the build process + +## Dependencies + +- [NockApp](https://github.com/nockchain/nockchain) - Nock virtual machine +- Standard Rust crates for serialization and error handling + +## License + +This project is licensed under {{license}}. diff --git a/crates/nockup/templates/http-server/hoon/app/app.hoon b/crates/nockup/templates/http-server/hoon/app/app.hoon new file mode 100644 index 000000000..6cb7870d2 --- /dev/null +++ b/crates/nockup/templates/http-server/hoon/app/app.hoon @@ -0,0 +1,117 @@ +/+ *http +/= * /common/wrapper +=> +|% ++$ server-state [%0 value=@] +++ page + ^- tape + %- trip + ''' + + + +

Hello NockApp!

+
+ Count: COUNT +
+ +
+ +
+ +
+ +
+ + + ''' +-- +:: +=> +|% +++ moat (keep server-state) +:: +++ inner + |_ state=server-state + :: + :: +load: upgrade from previous state + :: + ++ load + |= arg=server-state + ^- server-state + arg + :: + :: +peek: external inspect + :: + ++ peek + |= =path + ^- (unit (unit *)) + ~> %slog.[0 'Peeks awaiting implementation'] + ~ + :: + :: +poke: external apply + :: + ++ poke + |= =ovum:moat + ^- [(list effect) server-state] + =/ sof-cau=(unit cause) ((soft cause) cause.input.ovum) + ?~ sof-cau + ~& "cause incorrectly formatted!" + ~& now.input.ovum + !! + :: Parse request into components. + =/ [id=@ uri=@t =method headers=(list header) body=(unit octs)] +.u.sof-cau + :: + ?+ method [~[[%res ~ %400 ~ ~]] state] + %'GET' + :_ state + :_ ~ + ^- effect + :* %res id=id %200 + ['content-type' 'text/html']~ + %- to-octs + %- crip + ^- tape + =/ index (find "COUNT" page) + ;: weld + (scag (need index) page) + (scow %ud value.state) + (slag (add (need index) ^~((lent "COUNT"))) page) + == == + :: + %'POST' + ?: =('/increment' uri) + :_ state(value +(value.state)) + :_ ~ + ^- effect + :* %res id=id %200 + ['content-type' 'text/html']~ + %- to-octs + %- crip + ^- tape + =/ index (find "COUNT" page) + ;: weld + (scag (need index) page) + (scow %ud +(value.state)) + (slag (add (need index) ^~((lent "COUNT"))) page) + == == + :: + ?> =('/reset' uri) + :_ state(value 0) + :_ ~ + ^- effect + :* %res id=id %200 + ['content-type' 'text/html']~ + %- to-octs + %- crip + ^- tape + =/ index (find "COUNT" page) + ;: weld + (scag (need index) page) + (scow %ud 0) + (slag (add (need index) ^~((lent "COUNT"))) page) + == == + == + -- +-- +((moat |) inner) diff --git a/crates/nockup/templates/http-server/hoon/common/wrapper.hoon b/crates/nockup/templates/http-server/hoon/common/wrapper.hoon new file mode 100644 index 000000000..ba0b5cb97 --- /dev/null +++ b/crates/nockup/templates/http-server/hoon/common/wrapper.hoon @@ -0,0 +1,103 @@ +~% %wrapper ..ut ~ +|% ++$ goof [mote=term =tang] ++$ wire path ++$ ovum [=wire =input] ++$ crud [=goof =input] ++$ input [eny=@ our=@ux now=@da cause=*] +:: +++ keep + |* inner=mold + => + |% + +$ inner-state inner + +$ outer-state + $% [%0 desk-hash=(unit @uvI) internal=inner] + == + +$ outer-fort + $_ ^| + |_ outer-state + ++ load + |~ arg=outer-state + ** + ++ peek + |~ arg=path + *(unit (unit *)) + ++ poke + |~ [num=@ ovum=*] + *[(list *) *] + ++ wish + |~ txt=@ + ** + -- + :: + +$ fort + $_ ^| + |_ state=inner-state + ++ load + |~ arg=inner-state + *inner-state + ++ peek + |~ arg=path + *(unit (unit *)) + ++ poke + |~ arg=ovum + [*(list *) *inner-state] + -- + -- + :: + |= crash=? + |= inner=fort + |= hash=@uvI + =< .(desk-hash.outer `hash) + |_ outer=outer-state + +* inner-fort ~(. inner internal.outer) + ++ load + |= old=outer-state + ~& build-hash+hash + ?+ -.old ~&("wrapper +load: invalid old state" !!) + %0 + =/ new-internal (load:inner-fort internal.old) + ..load(internal.outer new-internal) + == + :: + ++ peek + |= arg=path + ^- (unit (unit *)) + =/ pax ((soft path) arg) + ?~ pax + ~> %slog.[0 leaf+"wrapper +poke: arg is not a path"] + ~ + (peek:inner-fort u.pax) + :: + ++ wish + |= txt=@ + ^- * + q:(slap !>(~) (ream txt)) + :: + ++ poke + |= [num=@ ovum=*] + ^- [(list *) _..poke] + =/ effects=(list *) ?:(crash ~[exit/0] ~) + ?+ ovum ~&("wrapper +poke invalid arg: {}" effects^..poke) + [[%$ %arvo ~] *] + =/ g ((soft crud) +.ovum) + ?~ g ~&(%invalid-goof effects^..poke) + ?: ?=(%intr mote.goof.u.g) + [effects ..poke] + =- [effects ..poke] + (slog tang.goof.u.g) + :: + [[%poke *] *] + =/ ovum ((soft ^ovum) ovum) + ?~ ovum ~&("wrapper +poke invalid arg: {}" ~^..poke) + =/ o ((soft input) input.u.ovum) + ?~ o + ~& "wrapper: could not mold poke type: {}" + ~^..poke + =^ effects internal.outer + (poke:inner-fort u.ovum) + [effects ..poke(internal.outer internal.outer)] + == + -- +-- diff --git a/crates/nockup/templates/http-server/hoon/lib/http.hoon b/crates/nockup/templates/http-server/hoon/lib/http.hoon new file mode 100644 index 000000000..ad6c1b31e --- /dev/null +++ b/crates/nockup/templates/http-server/hoon/lib/http.hoon @@ -0,0 +1,216 @@ +^? +|% +:: $header: a single HTTP header key-value pair +:: ++$ header [k=@t v=@t] +:: +header-list: an ordered list of http headers +:: ++$ header-list +(list [key=@t value=@t]) +:: +method: exhaustive list of http verbs +:: ++$ method +$? %'CONNECT' + %'DELETE' + %'GET' + %'HEAD' + %'OPTIONS' + %'PATCH' + %'POST' + %'PUT' + %'TRACE' +== +:: $octs: length in bytes and payload +:: ++$ octs [p=@ q=@] +:: +to-octs: convert an atom to octs +:: +++ to-octs + |= bod=@ + ^- (unit octs) + =/ len (met 3 bod) + ?: =(len 0) ~ + `[len bod] +:: $cause: the cause of an HTTP event +:: ++$ cause + $: %req + id=@ + uri=@t + =method + headers=(list header) + body=(unit octs) + == +:: $effect: the result of an HTTP event +:: ++$ effect + $: %res + id=@ + status=@ud + headers=(list header) + body=(unit octs) + == +:: +request: a single http request +:: ++$ request +$: :: method: http method + :: + method=method + :: url: the url requested + :: + :: The url is not escaped. There is no escape. + :: + url=@t + :: header-list: headers to pass with this request + :: + =header-list + :: body: optionally, data to send with this request + :: + body=(unit octs) +== +:: +response-header: the status code and header list on an http request +:: +:: We separate these away from the body data because we may not wait for +:: the entire body before we send a %progress to the caller. +:: ++$ response-header +$: :: status: http status code + :: + status-code=@ud + :: headers: http headers + :: + headers=header-list +== +:: +http-event: packetized http +:: +:: Urbit treats Earth's HTTP servers as pipes, where Urbit sends or +:: receives one or more %http-events. The first of these will always be a +:: %start or an %error, and the last will always be %cancel or will have +:: :complete set to %.y to finish the connection. +:: +:: Calculation of control headers such as 'Content-Length' or +:: 'Transfer-Encoding' should be performed at a higher level; this structure +:: is merely for what gets sent to or received from Earth. +:: ++$ http-event +$% :: %start: the first packet in a response + :: + $: %start + :: response-header: first event information + :: + =response-header + :: data: data to pass to the pipe + :: + data=(unit octs) + :: whether this completes the request + :: + complete=? + == + :: %continue: every subsequent packet + :: + $: %continue + :: data: data to pass to the pipe + :: + data=(unit octs) + :: complete: whether this completes the request + :: + complete=? + == + :: %cancel: represents unsuccessful termination + :: + [%cancel ~] +== +:: +get-header: returns the value for :header, if it exists in :header-list +:: +++ get-header +|= [header=@t =header-list] +^- (unit @t) +:: +?~ header-list + ~ +:: +?: =(key.i.header-list header) + `value.i.header-list +:: +$(header-list t.header-list) +:: +set-header: sets the value of an item in the header list +:: +:: This adds to the end if it doesn't exist. +:: +++ set-header +|= [header=@t value=@t =header-list] +^- ^header-list +:: +?~ header-list + :: we didn't encounter the value, add it to the end + :: + [[header value] ~] +:: +?: =(key.i.header-list header) + [[header value] t.header-list] +:: +[i.header-list $(header-list t.header-list)] +:: +delete-header: removes the first instance of a header from the list +:: +++ delete-header +|= [header=@t =header-list] +^- ^header-list +:: +?~ header-list + ~ +:: if we see it in the list, remove it +:: +?: =(key.i.header-list header) + t.header-list +:: +[i.header-list $(header-list t.header-list)] +:: +unpack-header: parse header field values +:: +++ unpack-header +|^ |= value=@t + ^- (unit (list (map @t @t))) + (rust (cass (trip value)) values) +:: +++ values + %+ more + (ifix [. .]:(star ;~(pose ace (just '\09'))) com) + pairs +:: +++ pairs + %+ cook + ~(gas by *(map @t @t)) + %+ most (ifix [. .]:(star ace) mic) + ;~(plug token ;~(pose ;~(pfix tis value) (easy ''))) +:: +++ value + ;~(pose token quoted-string) +:: +++ token :: 7230 token + %+ cook crip + ::NOTE this is ptok:de-purl:html, but can't access that here + %- plus + ;~ pose + aln zap hax buc cen pam soq tar lus + hep dot ket cab tic bar sig + == +:: +++ quoted-string :: 7230 quoted string + %+ cook crip + %+ ifix [. .]:;~(less (jest '\\"') doq) + %- star + ;~ pose + ;~(pfix bas ;~(pose (just '\09') ace prn)) + ;~(pose (just '\09') ;~(less (mask "\22\5c\7f") (shim 0x20 0xff))) + == +-- +:: +simple-payload: a simple, one event response used for generators +:: ++$ simple-payload +$: :: response-header: status code, etc + :: + =response-header + :: data: the data returned as the body + :: + data=(unit octs) +== +-- \ No newline at end of file diff --git a/crates/nockup/templates/http-server/hoon/lib/lib.hoon b/crates/nockup/templates/http-server/hoon/lib/lib.hoon new file mode 100644 index 000000000..f0f441d6e --- /dev/null +++ b/crates/nockup/templates/http-server/hoon/lib/lib.hoon @@ -0,0 +1,4 @@ +|% +++ arm !! +++ leg 0 +-- diff --git a/crates/nockup/templates/http-server/manifest.toml b/crates/nockup/templates/http-server/manifest.toml new file mode 100644 index 000000000..621108d47 --- /dev/null +++ b/crates/nockup/templates/http-server/manifest.toml @@ -0,0 +1,12 @@ +[project] +name = "{{name}}" +project_name = "{{project_name}}" +version = "{{version}}" +description = "{{description}}" +author_name = "{{author_name}}" +author_email = "{{author_email}}" +github_username = "{{github_username}}" +license = "{{license}}" +keywords = ["nockapp", "nockchain", "hoon"] +nockapp_commit_hash = "{{nockapp_commit_hash}}" +template = "http-server" diff --git a/crates/nockup/templates/http-server/src/main.rs b/crates/nockup/templates/http-server/src/main.rs new file mode 100644 index 000000000..3bedeb204 --- /dev/null +++ b/crates/nockup/templates/http-server/src/main.rs @@ -0,0 +1,28 @@ +use std::error::Error; +use std::fs; +use std::io::{self, Write}; + +use nockapp::http_driver; +use nockapp::kernel::boot; +use nockapp::noun::slab::NounSlab; +use nockapp::wire::{SystemWire, Wire}; +use nockapp::{AtomExt, NockApp}; +use nockvm::noun::{Atom, D, T}; +use nockvm_macros::tas; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let cli = boot::default_boot_cli(false); + boot::init_default_tracing(&cli); + + let kernel = fs::read("out.jam").map_err(|e| format!("Failed to read out.jam: {}", e))?; + + let mut nockapp: NockApp = boot::setup(&kernel, Some(cli), &[], "http-server", None) + .await + .map_err(|e| format!("Kernel setup failed: {}", e))?; + + nockapp.add_io_driver(http_driver()).await; + nockapp.run().await.expect("Failed to run app"); + + Ok(()) +} diff --git a/crates/nockup/templates/http-static/Cargo.toml b/crates/nockup/templates/http-static/Cargo.toml new file mode 100644 index 000000000..cf6d1997a --- /dev/null +++ b/crates/nockup/templates/http-static/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "http-server" +version = "0.1.0" +edition = "2021" +authors = ["N. E. Davis "] +description = "A NockApp project" + +[[bin]] +name = "http-server" +path = "src/main.rs" + +[dependencies] +# NockVM dependency +nockapp = { git = "https://github.com/nockchain/nockchain.git", rev = "336f744b6b83448ec2b86473a3dec29b15858999" } +nockvm = { git = "https://github.com/nockchain/nockchain.git", rev = "336f744b6b83448ec2b86473a3dec29b15858999" } +nockvm_macros = { git = "https://github.com/nockchain/nockchain.git", rev = "336f744b6b83448ec2b86473a3dec29b15858999" } + +# Common dependencies for NockApps +serde = { version = "1.0", features = ["derive"] } +toml = "0.8" +anyhow = "1.0" +bytes = "1.5.0" +tokio = { version = "1.32", features = [ + "fs", + "io-util", + "macros", + "net", + "rt-multi-thread", + "rt", + "signal", +] } +tokio-util = "0.7.11" + +[build-dependencies] +# Build script dependencies if needed \ No newline at end of file diff --git a/crates/nockup/templates/http-static/README.md b/crates/nockup/templates/http-static/README.md new file mode 100644 index 000000000..9960be7d1 --- /dev/null +++ b/crates/nockup/templates/http-static/README.md @@ -0,0 +1,61 @@ +# {{project_name}} + +A NockApp project created with `nockup`. + +## Description + +{{project_description}} + +## Building + +To build this project: + +```bash +nockup build {{project_name}} +``` + +Or using cargo directly: + +```bash +cargo build --release +``` + +## Running + +To run this project: + +```bash +nockup run {{project_name}} +``` + +Or using cargo directly: + +```bash +cargo run +``` + +## Project Structure + +- `src/main.rs` - Main Rust entry point +- `src/lib.rs` - Core NockApp library code +- `src/app.hoon` - Hoon application logic +- `manifest.toml` - NockApp configuration +- `build.rs` - Build script for compiling Hoon code +- `Cargo.toml` - Rust dependencies and configuration + +## Development + +This project uses both Rust and Hoon: + +- **Rust** handles the runtime, VM integration, and system interfaces +- **Hoon** contains the core application logic that compiles to Nock +- The `build.rs` script automatically compiles Hoon to Nock during the build process + +## Dependencies + +- [NockApp](https://github.com/nockchain/nockchain) - Nock virtual machine +- Standard Rust crates for serialization and error handling + +## License + +This project is licensed under {{license}}. diff --git a/crates/nockup/templates/http-static/hoon/app/app.hoon b/crates/nockup/templates/http-static/hoon/app/app.hoon new file mode 100644 index 000000000..b4fc75d32 --- /dev/null +++ b/crates/nockup/templates/http-static/hoon/app/app.hoon @@ -0,0 +1,103 @@ +/= * /common/wrapper +=> +|% ++$ server-state %stateless ++$ header [k=@t v=@t] ++$ octs [p=@ q=@] ++$ method + $? %'GET' + %'HEAD' + %'POST' + %'PUT' + %'DELETE' + %'CONNECT' + %'OPTIONS' + %'TRACE' + %'PATCH' + == +:: ++$ cause + $: %req + id=@ + uri=@t + =method + headers=(list header) + body=(unit octs) + == +:: ++$ effect + $: %res + id=@ + status=@ud + headers=(list header) + body=(unit octs) + == +:: +++ to-octs + |= bod=@ + ^- (unit octs) + =/ len (met 3 bod) + ?: =(len 0) ~ + `[len bod] +-- +:: +=> +|% +++ moat (keep server-state) +:: +++ inner + |_ k=server-state + :: + :: +load: upgrade from previous state + :: (but the server is stateless) + :: + ++ load + |= arg=server-state + arg + :: + :: +peek: external inspect + :: + ++ peek + |= =path + ^- (unit (unit *)) + ~> %slog.[0 'Peeks awaiting implementation'] + ~ + :: + :: +poke: external apply + :: + ++ poke + |= =ovum:moat + ^- [(list effect) server-state] + =/ sof-cau=(unit cause) ((soft cause) cause.input.ovum) + ?~ sof-cau + ~& "cause incorrectly formatted!" + ~& now.input.ovum + !! + =/ [id=@ uri=@t =method headers=(list header) body=(unit octs)] +.u.sof-cau + ~> %slog.[0 [id+id uri+uri method+method headers+headers]] + :_ k + :_ ~ + ^- effect + =- ~& effect+- + - + ?+ method [%res ~ %400 ~ ~] + %'GET' + :* %res id=id %200 + ['content-type' 'text/html']~ + %- to-octs + ''' + + + +

Hello NockApp!

+ + + ''' + == + :: + %'POST' + !! + == + -- +-- +((moat |) inner) diff --git a/crates/nockup/templates/http-static/hoon/common/wrapper.hoon b/crates/nockup/templates/http-static/hoon/common/wrapper.hoon new file mode 100644 index 000000000..ba0b5cb97 --- /dev/null +++ b/crates/nockup/templates/http-static/hoon/common/wrapper.hoon @@ -0,0 +1,103 @@ +~% %wrapper ..ut ~ +|% ++$ goof [mote=term =tang] ++$ wire path ++$ ovum [=wire =input] ++$ crud [=goof =input] ++$ input [eny=@ our=@ux now=@da cause=*] +:: +++ keep + |* inner=mold + => + |% + +$ inner-state inner + +$ outer-state + $% [%0 desk-hash=(unit @uvI) internal=inner] + == + +$ outer-fort + $_ ^| + |_ outer-state + ++ load + |~ arg=outer-state + ** + ++ peek + |~ arg=path + *(unit (unit *)) + ++ poke + |~ [num=@ ovum=*] + *[(list *) *] + ++ wish + |~ txt=@ + ** + -- + :: + +$ fort + $_ ^| + |_ state=inner-state + ++ load + |~ arg=inner-state + *inner-state + ++ peek + |~ arg=path + *(unit (unit *)) + ++ poke + |~ arg=ovum + [*(list *) *inner-state] + -- + -- + :: + |= crash=? + |= inner=fort + |= hash=@uvI + =< .(desk-hash.outer `hash) + |_ outer=outer-state + +* inner-fort ~(. inner internal.outer) + ++ load + |= old=outer-state + ~& build-hash+hash + ?+ -.old ~&("wrapper +load: invalid old state" !!) + %0 + =/ new-internal (load:inner-fort internal.old) + ..load(internal.outer new-internal) + == + :: + ++ peek + |= arg=path + ^- (unit (unit *)) + =/ pax ((soft path) arg) + ?~ pax + ~> %slog.[0 leaf+"wrapper +poke: arg is not a path"] + ~ + (peek:inner-fort u.pax) + :: + ++ wish + |= txt=@ + ^- * + q:(slap !>(~) (ream txt)) + :: + ++ poke + |= [num=@ ovum=*] + ^- [(list *) _..poke] + =/ effects=(list *) ?:(crash ~[exit/0] ~) + ?+ ovum ~&("wrapper +poke invalid arg: {}" effects^..poke) + [[%$ %arvo ~] *] + =/ g ((soft crud) +.ovum) + ?~ g ~&(%invalid-goof effects^..poke) + ?: ?=(%intr mote.goof.u.g) + [effects ..poke] + =- [effects ..poke] + (slog tang.goof.u.g) + :: + [[%poke *] *] + =/ ovum ((soft ^ovum) ovum) + ?~ ovum ~&("wrapper +poke invalid arg: {}" ~^..poke) + =/ o ((soft input) input.u.ovum) + ?~ o + ~& "wrapper: could not mold poke type: {}" + ~^..poke + =^ effects internal.outer + (poke:inner-fort u.ovum) + [effects ..poke(internal.outer internal.outer)] + == + -- +-- diff --git a/crates/nockup/templates/http-static/hoon/lib/lib.hoon b/crates/nockup/templates/http-static/hoon/lib/lib.hoon new file mode 100644 index 000000000..f0f441d6e --- /dev/null +++ b/crates/nockup/templates/http-static/hoon/lib/lib.hoon @@ -0,0 +1,4 @@ +|% +++ arm !! +++ leg 0 +-- diff --git a/crates/nockup/templates/http-static/manifest.toml b/crates/nockup/templates/http-static/manifest.toml new file mode 100644 index 000000000..a2d11080a --- /dev/null +++ b/crates/nockup/templates/http-static/manifest.toml @@ -0,0 +1,12 @@ +[project] +name = "{{name}}" +project_name = "{{project_name}}" +version = "{{version}}" +description = "{{description}}" +author_name = "{{author_name}}" +author_email = "{{author_email}}" +github_username = "{{github_username}}" +license = "{{license}}" +keywords = ["nockapp", "nockchain", "hoon"] +nockapp_commit_hash = "{{nockapp_commit_hash}}" +template = "http-static" diff --git a/crates/nockup/templates/http-static/src/main.rs b/crates/nockup/templates/http-static/src/main.rs new file mode 100644 index 000000000..3bedeb204 --- /dev/null +++ b/crates/nockup/templates/http-static/src/main.rs @@ -0,0 +1,28 @@ +use std::error::Error; +use std::fs; +use std::io::{self, Write}; + +use nockapp::http_driver; +use nockapp::kernel::boot; +use nockapp::noun::slab::NounSlab; +use nockapp::wire::{SystemWire, Wire}; +use nockapp::{AtomExt, NockApp}; +use nockvm::noun::{Atom, D, T}; +use nockvm_macros::tas; + +#[tokio::main] +async fn main() -> Result<(), Box> { + let cli = boot::default_boot_cli(false); + boot::init_default_tracing(&cli); + + let kernel = fs::read("out.jam").map_err(|e| format!("Failed to read out.jam: {}", e))?; + + let mut nockapp: NockApp = boot::setup(&kernel, Some(cli), &[], "http-server", None) + .await + .map_err(|e| format!("Kernel setup failed: {}", e))?; + + nockapp.add_io_driver(http_driver()).await; + nockapp.run().await.expect("Failed to run app"); + + Ok(()) +} diff --git a/crates/nockup/templates/repl/Cargo.toml b/crates/nockup/templates/repl/Cargo.toml new file mode 100644 index 000000000..5e6052a56 --- /dev/null +++ b/crates/nockup/templates/repl/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "repl" +version = "0.1.0" +edition = "2021" +authors = ["N. E. Davis "] +description = "A NockApp project" + +[[bin]] +name = "repl" +path = "src/main.rs" + +[dependencies] +# NockVM dependency +nockapp = { git = "https://github.com/nockchain/nockchain.git", rev = "336f744b6b83448ec2b86473a3dec29b15858999" } +nockvm = { git = "https://github.com/nockchain/nockchain.git", rev = "336f744b6b83448ec2b86473a3dec29b15858999" } +nockvm_macros = { git = "https://github.com/nockchain/nockchain.git", rev = "336f744b6b83448ec2b86473a3dec29b15858999" } + +# Common dependencies for NockApps +serde = { version = "1.0", features = ["derive"] } +toml = "0.8" +anyhow = "1.0" +bytes = "1.5.0" +tokio = { version = "1.32", features = [ + "fs", + "io-util", + "macros", + "net", + "rt-multi-thread", + "rt", + "signal", +] } +tokio-util = "0.7.11" + +[build-dependencies] +# Build script dependencies if needed \ No newline at end of file diff --git a/crates/nockup/templates/repl/README.md b/crates/nockup/templates/repl/README.md new file mode 100644 index 000000000..9960be7d1 --- /dev/null +++ b/crates/nockup/templates/repl/README.md @@ -0,0 +1,61 @@ +# {{project_name}} + +A NockApp project created with `nockup`. + +## Description + +{{project_description}} + +## Building + +To build this project: + +```bash +nockup build {{project_name}} +``` + +Or using cargo directly: + +```bash +cargo build --release +``` + +## Running + +To run this project: + +```bash +nockup run {{project_name}} +``` + +Or using cargo directly: + +```bash +cargo run +``` + +## Project Structure + +- `src/main.rs` - Main Rust entry point +- `src/lib.rs` - Core NockApp library code +- `src/app.hoon` - Hoon application logic +- `manifest.toml` - NockApp configuration +- `build.rs` - Build script for compiling Hoon code +- `Cargo.toml` - Rust dependencies and configuration + +## Development + +This project uses both Rust and Hoon: + +- **Rust** handles the runtime, VM integration, and system interfaces +- **Hoon** contains the core application logic that compiles to Nock +- The `build.rs` script automatically compiles Hoon to Nock during the build process + +## Dependencies + +- [NockApp](https://github.com/nockchain/nockchain) - Nock virtual machine +- Standard Rust crates for serialization and error handling + +## License + +This project is licensed under {{license}}. diff --git a/crates/nockup/templates/repl/build.rs b/crates/nockup/templates/repl/build.rs new file mode 100644 index 000000000..cc9ece4e0 --- /dev/null +++ b/crates/nockup/templates/repl/build.rs @@ -0,0 +1,42 @@ +use std::env; +use std::fs; +use std::path::Path; +use std::process::Command; + +fn main() { + println!("cargo:rerun-if-changed=hoon/app/app.hoon"); + println!("cargo:rerun-if-changed=hoon/lib/lib.hoon"); + + let out_dir = env::var("OUT_DIR").unwrap(); + let hoon_app_file = "hoon/app/app.hoon"; + + if Path::new(hoon_app_file).exists() { + // Compile Hoon to Nock (this is a placeholder - adjust based on actual hoonc usage) + let output = Command::new("hoonc") + .args(&[hoon_app_file, "--output", &format!("{}/app.nock", out_dir)]) + .output(); + + match output { + Ok(result) => { + if !result.status.success() { + panic!( + "Failed to compile Hoon: {}", + String::from_utf8_lossy(&result.stderr) + ); + } + println!("cargo:rustc-env=COMPILED_HOON_PATH={}/app.nock", out_dir); + } + Err(e) => { + println!( + "cargo:warning=Could not run hoonc: {}. Skipping Hoon compilation.", + e + ); + } + } + } else { + println!( + "cargo:warning=No Hoon file found at {}. Skipping Hoon compilation.", + hoon_app_file + ); + } +} diff --git a/crates/nockup/templates/repl/hoon/app/app.hoon b/crates/nockup/templates/repl/hoon/app/app.hoon new file mode 100644 index 000000000..5211461bf --- /dev/null +++ b/crates/nockup/templates/repl/hoon/app/app.hoon @@ -0,0 +1,60 @@ +/+ lib +/= * /common/wrapper +:: +=> +|% ++$ versioned-state + $: %v1 + ~ + == +:: ++$ effect + $% [%response str=@t] + [%exit code=@] + == +:: ++$ cause + $% [%call str=@t] + == +-- +|% +++ moat (keep versioned-state) +:: +++ inner + |_ state=versioned-state + :: + ++ load + |= old-state=versioned-state + ^- _state + ?: =(-.old-state %v1) + old-state + old-state + :: + ++ peek + |= =path + ^- (unit (unit *)) + ~> %slog.[0 'Peeks awaiting implementation'] + ~ + :: + ++ poke + |= =ovum:moat + ^- [(list effect) _state] + =/ cause ((soft cause) cause.input.ovum) + ?~ cause + ~> %slog.[3 (crip "invalid cause {}")] + :_ state + ^- (list effect) + ~[[%exit 1]] + ?> ?=(%call -.u.cause) + ?: (~(has in `(set term)`(sy ~[%exit %x %q %quit])) str.u.cause) + :_ state + ^- (list effect) + :~ [%exit 0] + == + :_ state + ^- (list effect) + :~ [%response str.u.cause] + == + -- +-- +((moat |) inner) diff --git a/crates/nockup/templates/repl/hoon/common/wrapper.hoon b/crates/nockup/templates/repl/hoon/common/wrapper.hoon new file mode 100644 index 000000000..ba0b5cb97 --- /dev/null +++ b/crates/nockup/templates/repl/hoon/common/wrapper.hoon @@ -0,0 +1,103 @@ +~% %wrapper ..ut ~ +|% ++$ goof [mote=term =tang] ++$ wire path ++$ ovum [=wire =input] ++$ crud [=goof =input] ++$ input [eny=@ our=@ux now=@da cause=*] +:: +++ keep + |* inner=mold + => + |% + +$ inner-state inner + +$ outer-state + $% [%0 desk-hash=(unit @uvI) internal=inner] + == + +$ outer-fort + $_ ^| + |_ outer-state + ++ load + |~ arg=outer-state + ** + ++ peek + |~ arg=path + *(unit (unit *)) + ++ poke + |~ [num=@ ovum=*] + *[(list *) *] + ++ wish + |~ txt=@ + ** + -- + :: + +$ fort + $_ ^| + |_ state=inner-state + ++ load + |~ arg=inner-state + *inner-state + ++ peek + |~ arg=path + *(unit (unit *)) + ++ poke + |~ arg=ovum + [*(list *) *inner-state] + -- + -- + :: + |= crash=? + |= inner=fort + |= hash=@uvI + =< .(desk-hash.outer `hash) + |_ outer=outer-state + +* inner-fort ~(. inner internal.outer) + ++ load + |= old=outer-state + ~& build-hash+hash + ?+ -.old ~&("wrapper +load: invalid old state" !!) + %0 + =/ new-internal (load:inner-fort internal.old) + ..load(internal.outer new-internal) + == + :: + ++ peek + |= arg=path + ^- (unit (unit *)) + =/ pax ((soft path) arg) + ?~ pax + ~> %slog.[0 leaf+"wrapper +poke: arg is not a path"] + ~ + (peek:inner-fort u.pax) + :: + ++ wish + |= txt=@ + ^- * + q:(slap !>(~) (ream txt)) + :: + ++ poke + |= [num=@ ovum=*] + ^- [(list *) _..poke] + =/ effects=(list *) ?:(crash ~[exit/0] ~) + ?+ ovum ~&("wrapper +poke invalid arg: {}" effects^..poke) + [[%$ %arvo ~] *] + =/ g ((soft crud) +.ovum) + ?~ g ~&(%invalid-goof effects^..poke) + ?: ?=(%intr mote.goof.u.g) + [effects ..poke] + =- [effects ..poke] + (slog tang.goof.u.g) + :: + [[%poke *] *] + =/ ovum ((soft ^ovum) ovum) + ?~ ovum ~&("wrapper +poke invalid arg: {}" ~^..poke) + =/ o ((soft input) input.u.ovum) + ?~ o + ~& "wrapper: could not mold poke type: {}" + ~^..poke + =^ effects internal.outer + (poke:inner-fort u.ovum) + [effects ..poke(internal.outer internal.outer)] + == + -- +-- diff --git a/crates/nockup/templates/repl/hoon/lib/lib.hoon b/crates/nockup/templates/repl/hoon/lib/lib.hoon new file mode 100644 index 000000000..f0f441d6e --- /dev/null +++ b/crates/nockup/templates/repl/hoon/lib/lib.hoon @@ -0,0 +1,4 @@ +|% +++ arm !! +++ leg 0 +-- diff --git a/crates/nockup/templates/repl/manifest.toml b/crates/nockup/templates/repl/manifest.toml new file mode 100644 index 000000000..3239d7a41 --- /dev/null +++ b/crates/nockup/templates/repl/manifest.toml @@ -0,0 +1,12 @@ +[project] +name = "{{name}}" +project_name = "{{project_name}}" +version = "{{version}}" +description = "{{description}}" +author_name = "{{author_name}}" +author_email = "{{author_email}}" +github_username = "{{github_username}}" +license = "{{license}}" +keywords = ["nockapp", "nockchain", "hoon"] +nockapp_commit_hash = "{{nockapp_commit_hash}}" +template = "repl" diff --git a/crates/nockup/templates/repl/src/main.rs b/crates/nockup/templates/repl/src/main.rs new file mode 100644 index 000000000..476d23349 --- /dev/null +++ b/crates/nockup/templates/repl/src/main.rs @@ -0,0 +1,106 @@ +use std::error::Error; +use std::fs; +use std::io::{self, Write}; + +use bytes::Bytes; +use nockapp::exit_driver; +use nockapp::kernel::boot; +use nockapp::noun::slab::NounSlab; +use nockapp::wire::{SystemWire, Wire}; +use nockapp::{AtomExt, NockApp}; +use nockvm::noun::{Atom, D, T}; +use nockvm_macros::tas; + +fn string_to_atom(slab: &mut NounSlab, s: &str) -> Result> { + let bytes = Bytes::from(s.as_bytes().to_vec()); + Ok(Atom::from_bytes(slab, &bytes)) +} + +async fn process_input(nockapp: &mut NockApp, input: &str) -> Result> { + // Handle empty input + if input.trim().is_empty() { + return Ok(String::new()); + } + + let mut poke_slab = NounSlab::new(); + + let str_atom = string_to_atom(&mut poke_slab, input)?; + let command_noun = T(&mut poke_slab, &[D(tas!(b"call")), str_atom.as_noun()]); + poke_slab.set_root(command_noun); + + nockapp + .add_io_driver(exit_driver()) + .await; + + match nockapp.poke(SystemWire.to_wire(), poke_slab).await { + Ok(effects) => { + let mut results = Vec::new(); + for (_i, effect) in effects.iter().enumerate() { + let effect_noun = unsafe { effect.root() }; + if let Ok(cell) = effect_noun.as_cell() { + let Ok(head_atom) = cell.head().as_atom() else { + todo!() + }; + let code = std::str::from_utf8(head_atom.as_direct()?.as_ne_bytes())? + .trim_end_matches(char::from(0)) + .to_string(); + if code == "exit" { + return Err("Exit command received".into()); + } + let Ok(tail_atom) = cell.tail().as_atom() else { + todo!() + }; + let Ok(tail_string) = std::str::from_utf8(tail_atom.as_ne_bytes()) else { + todo!() + }; + results.push(tail_string.trim_end_matches('\0').to_string()); + } + } + Ok(results.last().unwrap_or(&String::new()).clone()) + } + Err(_e) => Ok("command failed".to_string()), + } +} + +#[tokio::main] +async fn main() -> Result<(), Box> { + let cli = boot::default_boot_cli(false); + boot::init_default_tracing(&cli); + + let kernel = fs::read("out.jam").map_err(|e| format!("Failed to read out.jam: {}", e))?; + + let mut nockapp: NockApp = boot::setup(&kernel, Some(cli), &[], "repl", None).await?; + + loop { + print!("repl> "); + io::stdout().flush().unwrap(); + let mut input = String::new(); + match io::stdin().read_line(&mut input) { + Ok(0) => { + break; + } + Ok(_) => { + let input = input.trim(); + match process_input(&mut nockapp, input).await { + Ok(result) => { + println!("{}", result); + } + Err(e) => { + if e.to_string().contains("Exit command received") { + println!("Exiting..."); + break; + } else { + println!("Error: {}", e); + } + } + } + } + Err(error) => { + println!("Closing program..."); + break; + } + } + } + + Ok(()) +} diff --git a/crates/nockup/tests/cli_tests.rs b/crates/nockup/tests/cli_tests.rs new file mode 100644 index 000000000..7d3204f51 --- /dev/null +++ b/crates/nockup/tests/cli_tests.rs @@ -0,0 +1,275 @@ +use std::process::Command; + +use assert_cmd::prelude::*; +use predicates::prelude::*; +use tempfile::TempDir; + +#[cfg(test)] +mod cli_input_validation_tests { + use super::*; + + // Test basic command structure + #[test] + fn test_no_args_shows_version() { + let mut cmd = Command::cargo_bin("nockup").unwrap(); + cmd.assert() + .success() + .stdout(predicate::str::contains("version")); + } + + #[test] + fn test_help_command() { + let mut cmd = Command::cargo_bin("nockup").unwrap(); + cmd.arg("help"); + cmd.assert() + .success() + .stdout(predicate::str::contains("Initialize nockup cache")); + } + + #[test] + fn test_invalid_command() { + let mut cmd = Command::cargo_bin("nockup").unwrap(); + cmd.arg("invalid-command"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("error")) + .stderr(predicate::str::contains("invalid-command")); + } + + // Test install command validation + #[test] + fn test_install_with_invalid_flags() { + let mut cmd = Command::cargo_bin("nockup").unwrap(); + cmd.args(&["install", "--invalid-flag"]); + cmd.assert() + .failure() + .stderr(predicate::str::contains("unexpected argument")); + } + + // Test start command validation + #[test] + fn test_start_without_project_name() { + let mut cmd = Command::cargo_bin("nockup").unwrap(); + cmd.arg("start"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("required")); + } + + #[test] + fn test_start_with_empty_project_name() { + let mut cmd = Command::cargo_bin("nockup").unwrap(); + cmd.args(&["start", ""]); + cmd.assert().failure().stderr(predicate::str::contains( + "Error: Project configuration file '.toml' not found", + )); + } + + #[test] + fn test_start_with_valid_project_names() { + let valid_names = vec!["myproject", "my-project", "my_project", "project123"]; + + for name in valid_names { + let temp_dir = TempDir::new().unwrap(); + let mut cmd = Command::cargo_bin("nockup").unwrap(); + cmd.current_dir(temp_dir.path()).args(&["start", name]); + + // This might fail due to missing cache, but shouldn't fail on name validation + let output = cmd.output().unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!stderr.contains("invalid project name")); + } + } + + // Test build command validation + #[test] + fn test_build_without_project_name() { + let mut cmd = Command::cargo_bin("nockup").unwrap(); + cmd.arg("build"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("required")); + } + + #[test] + fn test_build_nonexistent_project() { + let temp_dir = TempDir::new().unwrap(); + let mut cmd = Command::cargo_bin("nockup").unwrap(); + cmd.current_dir(temp_dir.path()) + .args(&["build", "nonexistent-project"]); + cmd.assert().failure().stderr( + predicate::str::contains("Project directory") + .and(predicate::str::contains("not found")), + ); + } + + // Test run command validation + #[test] + fn test_run_without_project_name() { + let mut cmd = Command::cargo_bin("nockup").unwrap(); + cmd.arg("run"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("required")); + } + + // Test channel command validation + #[test] + fn test_channel_without_subcommand() { + let mut cmd = Command::cargo_bin("nockup").unwrap(); + cmd.arg("channel"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("nockup channel ")); + } + + #[test] + fn test_channel_list_with_extra_args() { + let mut cmd = Command::cargo_bin("nockup").unwrap(); + cmd.args(&["channel", "list", "extra-arg"]); + cmd.assert() + .failure() + .stderr(predicate::str::contains("unexpected argument")); + } + + #[test] + fn test_channel_set_without_channel_name() { + let mut cmd = Command::cargo_bin("nockup").unwrap(); + cmd.args(&["channel", "set"]); + cmd.assert() + .failure() + .stderr(predicate::str::contains("required")); + } + + #[test] + fn test_channel_set_invalid_channel() { + let mut cmd = Command::cargo_bin("nockup").unwrap(); + cmd.args(&["channel", "set", "invalid-channel"]); + cmd.assert() + .failure() + .stderr(predicate::str::contains("Invalid channel")); + } + + #[test] + fn test_channel_set_valid_channels() { + let channels = vec!["stable", "nightly"]; + + for channel in channels { + let mut cmd = Command::cargo_bin("nockup").unwrap(); + cmd.args(&["channel", "set", channel]); + + // This might fail due to missing cache, but shouldn't fail on channel validation + let output = cmd.output().unwrap(); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!stderr.contains("invalid channel")); + } + } + + // // Test path validation + // #[test] + // fn test_start_in_existing_directory() { + // let temp_dir = TempDir::new().unwrap(); + // let project_path = temp_dir.path().join("existing-project"); + // std::fs::create_dir_all(&project_path).unwrap(); + // std::fs::write(project_path.join("dummy.txt"), "exists").unwrap(); + + // let mut cmd = Command::cargo_bin("nockup").unwrap(); + // // copy the local default-manifest.toml file to tempdir + // std::fs::copy("default-manifest.toml", temp_dir.path().join("default-manifest.toml")).unwrap(); + // cmd.current_dir(temp_dir.path()) + // .args(&["start", "default-manifest"]); + // cmd.assert() + // .success() + // .stdout(predicate::str::contains("Project 'arcadia' created successfully")); + // // new command + // cmd.current_dir(temp_dir.path()) + // .args(&["start", "default-manifest"]); + // cmd.assert() + // .failure() + // .stderr(predicate::str::contains("already exists. Please choose")); + // // Clear ./default-manifest + // std::fs::remove_dir_all(temp_dir.path().join("default-manifest")).unwrap(); + // } + + // Test configuration file validation (if manifest is required) + #[test] + fn test_build_without_manifest() { + let temp_dir = TempDir::new().unwrap(); + let project_dir = temp_dir.path().join("test-project"); + std::fs::create_dir_all(&project_dir).unwrap(); + + let mut cmd = Command::cargo_bin("nockup").unwrap(); + cmd.current_dir(&project_dir).args(&["build", "."]); + cmd.assert().failure().stderr(predicate::str::contains( + "Error: Not a NockApp project: '.' missing manifest.toml", + )); + } +} + +// Unit tests for argument parsing (if you have a separate args module) +#[cfg(test)] +mod unit_input_validation_tests { + // use super::*; + + // These would test your argument parsing functions directly + // Example assuming you have a validate_project_name function: + + /* + #[test] + fn test_validate_project_name_valid() { + assert!(validate_project_name("valid-project").is_ok()); + assert!(validate_project_name("valid_project").is_ok()); + assert!(validate_project_name("project123").is_ok()); + } + + #[test] + fn test_validate_project_name_invalid() { + assert!(validate_project_name("").is_err()); + assert!(validate_project_name("project with spaces").is_err()); + assert!(validate_project_name("project/with/slashes").is_err()); + assert!(validate_project_name("project@with@symbols").is_err()); + } + + #[test] + fn test_validate_channel_name() { + assert!(validate_channel_name("stable").is_ok()); + assert!(validate_channel_name("nightly").is_ok()); + assert!(validate_channel_name("invalid").is_err()); + assert!(validate_channel_name("").is_err()); + } + */ +} + +// Property-based testing example (add proptest = "1.0" to dev-dependencies) +// #[cfg(feature = "proptest")] +// mod property_tests { +// use proptest::prelude::*; + +// proptest! { +// #[test] +// fn test_project_name_chars(s in "[a-zA-Z0-9_-]{1,50}") { +// // Valid project names should only contain alphanumeric, underscore, hyphen +// let mut cmd = Command::cargo_bin("nockup").unwrap(); +// cmd.args(&["start", &s]); +// let output = cmd.output().unwrap(); +// let stderr = String::from_utf8_lossy(&output.stderr); +// // Should not fail on name validation (might fail for other reasons) +// assert!(!stderr.contains("invalid project name")); +// } + +// #[test] +// fn test_invalid_project_name_chars(s in "[^a-zA-Z0-9_-]+") { +// // Invalid characters should be rejected +// let mut cmd = Command::cargo_bin("nockup").unwrap(); +// cmd.args(&["start", &s]); +// cmd.assert().failure(); +// } +// } +// } + +// Helper functions for test setup +#[cfg(test)] +mod test_helpers { + // use super::*; + // use std::fs; +} diff --git a/crates/nockup/zorp-gpg-key.pub b/crates/nockup/zorp-gpg-key.pub new file mode 100644 index 000000000..a253c6499 --- /dev/null +++ b/crates/nockup/zorp-gpg-key.pub @@ -0,0 +1,52 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +BUJcs1DYuNSQ +=krId +-----END PGP PUBLIC KEY BLOCK----- \ No newline at end of file From 07f58f267f00ccb1a36e90b66f595ca26f68a8d0 Mon Sep 17 00:00:00 2001 From: tacryt-socryp <1377557+tacryt-socryp@users.noreply.github.com> Date: Wed, 26 Nov 2025 13:53:51 -0600 Subject: [PATCH 24/99] nockchain-wallet: note rendering standardization and support spending some edge-case v0 notes --- crates/nockchain-wallet/src/command.rs | 20 +++++- crates/nockchain-wallet/src/main.rs | 10 ++- hoon/apps/wallet/lib/tx-builder.hoon | 38 ++++++++-- hoon/apps/wallet/lib/types.hoon | 3 + hoon/apps/wallet/lib/utils.hoon | 79 +++++++++++++++++---- hoon/apps/wallet/wallet.hoon | 98 ++++++++++++++++++++++++-- scripts/run_nockchain_node_fakenet.sh | 2 +- 7 files changed, 219 insertions(+), 31 deletions(-) mode change 100644 => 100755 scripts/run_nockchain_node_fakenet.sh diff --git a/crates/nockchain-wallet/src/command.rs b/crates/nockchain-wallet/src/command.rs index 99ce9e9fb..c6879381d 100644 --- a/crates/nockchain-wallet/src/command.rs +++ b/crates/nockchain-wallet/src/command.rs @@ -1,7 +1,7 @@ use std::str::FromStr; use clap::builder::BoolishValueParser; -use clap::{ArgAction, Parser, Subcommand}; +use clap::{ArgAction, Parser, Subcommand, ValueEnum}; use nockapp::driver::Operation; use nockapp::kernel::boot::Cli as BootCli; use nockapp::wire::{Wire, WireRepr}; @@ -171,6 +171,21 @@ impl FromStr for TimelockRangeCli { } } +#[derive(Copy, Clone, Debug, ValueEnum)] +pub enum NoteSelectionStrategyCli { + Ascending, + Descending, +} + +impl NoteSelectionStrategyCli { + pub fn tas_label(&self) -> &'static str { + match self { + NoteSelectionStrategyCli::Ascending => "asc", + NoteSelectionStrategyCli::Descending => "desc", + } + } +} + #[derive(Parser, Debug, Clone)] #[command(author, version, about, long_about = None)] pub struct WalletCli { @@ -394,6 +409,9 @@ pub enum Commands { /// txs-debug folder in the current working directory. #[arg(long, default_value = "false")] save_raw_tx: bool, + /// Note selection strategy (ascending selects smallest notes first) + #[arg(long = "note-selection", value_enum, default_value = "ascending")] + note_selection_strategy: NoteSelectionStrategyCli, }, /// Sign a multisig transaction diff --git a/crates/nockchain-wallet/src/main.rs b/crates/nockchain-wallet/src/main.rs index 712c0a7fd..4260caafc 100644 --- a/crates/nockchain-wallet/src/main.rs +++ b/crates/nockchain-wallet/src/main.rs @@ -14,7 +14,9 @@ use clap::Parser; use command::TimelockRangeCli; #[cfg(test)] use command::WalletWire; -use command::{ClientType, CommandNoun, Commands, WalletCli, WatchSubcommand}; +use command::{ + ClientType, CommandNoun, Commands, NoteSelectionStrategyCli, WalletCli, WatchSubcommand, +}; use kernels::wallet::KERNEL; use nockapp::driver::*; use nockapp::kernel::boot::{self, NockStackSize}; @@ -283,6 +285,7 @@ async fn main() -> Result<(), NockAppError> { include_data, sign_keys, save_raw_tx, + note_selection_strategy, } => { let recipient_specs = recipient_tokens_to_specs(recipients.clone())?; let signing_keys = Wallet::collect_signing_keys(*index, *hardened, sign_keys)?; @@ -294,6 +297,7 @@ async fn main() -> Result<(), NockAppError> { signing_keys, *include_data, *save_raw_tx, + *note_selection_strategy, ) } Commands::SignMultisigTx { @@ -885,6 +889,7 @@ impl Wallet { sign_keys: Vec<(u64, bool)>, include_data: bool, save_raw_tx: bool, + note_selection: NoteSelectionStrategyCli, ) -> CommandNoun { let mut slab = NounSlab::new(); @@ -917,12 +922,13 @@ impl Wallet { }; let include_data_noun = include_data.to_noun(&mut slab); let save_raw_tx_noun = save_raw_tx.to_noun(&mut slab); + let note_selection_noun = make_tas(&mut slab, note_selection.tas_label()).as_noun(); Self::wallet( "create-tx", &[ names_noun, order_noun, fee_noun, sign_key_noun, refund_noun, include_data_noun, - save_raw_tx_noun, + save_raw_tx_noun, note_selection_noun, ], Operation::Poke, &mut slab, diff --git a/hoon/apps/wallet/lib/tx-builder.hoon b/hoon/apps/wallet/lib/tx-builder.hoon index 19d0aebb1..7e1b5b238 100644 --- a/hoon/apps/wallet/lib/tx-builder.hoon +++ b/hoon/apps/wallet/lib/tx-builder.hoon @@ -11,6 +11,7 @@ refund-pkh=(unit hash:transact) get-note=$-(nname:transact nnote:transact) include-data=? + note-selection=selection-strategy:wt == |^ ^- $: spends:v1:transact @@ -30,6 +31,7 @@ =/ sender-pubkey=schnorr-pubkey:transact i.signer-pubkeys =/ sender-pkh=hash:transact (hash:schnorr-pubkey:transact sender-pubkey) =/ notes=(list nnote:transact) (turn names get-note) +=/ ascending=? ?=(%asc note-selection) :: If all notes are v0 =/ [raw-spends=spends:v1:transact =witness-data:wt display=transaction-display:wt] ?: (levy notes |=(=nnote:transact ?=(^ -.nnote))) @@ -43,7 +45,7 @@ =. notes-v0 %+ sort notes-v0 |= [a=nnote:v0:transact b=nnote:v0:transact] - (gth assets.a assets.b) + ?:(ascending (lth assets.a assets.b) (gth assets.a assets.b)) =/ refund-lock=lock:transact [%pkh [m=1 (z-silt:zo ~[u.refund-pkh])]]~ (create-spends-0 notes-v0 orders fee sender-pubkey refund-lock) :: If all notes are v1 @@ -56,7 +58,7 @@ =. notes-v1 %+ sort notes-v1 |= [a=nnote-1:v1:transact b=nnote-1:v1:transact] - (gth assets.a assets.b) + ?:(ascending (lth assets.a assets.b) (gth assets.a assets.b)) =/ multisig-lock=(unit lock:transact) :: :: ensure that all multisig locks are the same in the input notes @@ -162,8 +164,10 @@ ?~ notes state =/ note i.notes - ?. ?& =(1 m.sig.note) - (~(has z-in:zo pubkeys.sig.note) pubkey) + ?. ?| =(pubkeys.sig.note (z-silt:zo ~[pubkey])) + ?& =(1 m.sig.note) + (~(has z-in:zo pubkeys.sig.note) pubkey) + == == ~> %slog.[0 'Note not spendable by signing key'] !! =/ [pending-orders=(list order:wt) specs=(list order:wt) remainder=@] @@ -234,10 +238,34 @@ ((soft note-data:v1:transact) note-data.note) ?~ nd ~> %slog.[0 'error: note-data malformed in note!'] !! - =+ pulled=(pull-lock:locks:utils [u.nd name.note (some sender-pkh)]) + =+ pulled=(pull:locks:utils [u.nd name.note (some sender-pkh)]) ?~ pulled =+ name-cord=(name:v1:display:utils name.note) ~| "Error processing note {}. Reason: first-name did not correspond to a supported lock." !! + =/ pkh=(unit pkh:v1:transact) + =/ input-lock=spend-condition:transact u.pulled + |- + ?~ input-lock + ~ + ?: ?=(%pkh -.i.input-lock) + `+.i.input-lock + $(input-lock t.input-lock) + =/ signable=? + ?~ pkh %.y + %+ levy sign-keys + |= sk=schnorr-seckey:transact + %- ~(has z-in:zo h.u.pkh) + %- hash:schnorr-pubkey:transact + %- from-sk:schnorr-pubkey:transact + (to-atom:schnorr-seckey:transact sk) + ?. signable + ~| ^- @t + ;: (cury cat 3) + 'One or more of the provided signing keys is not required by note ' + (name:v1:display:utils name.note) + '.' + == + !! =/ input-lock=lock:transact u.pulled =/ allocation (allocate-orders orders.state assets.note) =/ [pending-orders=(list order:wt) specs=(list order:wt) remainder=@] diff --git a/hoon/apps/wallet/lib/types.hoon b/hoon/apps/wallet/lib/types.hoon index 406b0c04c..0ef8df5e5 100644 --- a/hoon/apps/wallet/lib/types.hoon +++ b/hoon/apps/wallet/lib/types.hoon @@ -264,6 +264,8 @@ +$ balance-v4 $+(balance-v4 balance-v3) +$ balance balance-v4 :: ++$ selection-strategy ?(%asc %desc) +:: +$ ledger-v0-engine %- list $: name=nname:transact @@ -412,6 +414,7 @@ :: doesn't keep track of the lock. save-raw-tx=? :: if %.y, saves jams of the raw-tx and its hashable into a txs-debug folder :: in the current working directory + =selection-strategy == [%list-active-addresses ~] [%list-notes ~] diff --git a/hoon/apps/wallet/lib/utils.hoon b/hoon/apps/wallet/lib/utils.hoon index 982128467..0e7f6793a 100644 --- a/hoon/apps/wallet/lib/utils.hoon +++ b/hoon/apps/wallet/lib/utils.hoon @@ -110,9 +110,9 @@ -- ++ locks |% - ++ pull-lock-inner + ++ pull-inner |= [nd=note-data:v1:transact nn=nname:transact pkh=(unit hash:transact)] - ^- (unit lock:transact) + ^- (unit spend-condition:transact) ?~ lock-noun=(~(get z-by:zo nd) %lock) ?~ pkh ~ @@ -127,11 +127,13 @@ ~ ?~ soft-lock=((soft lock-data:wt) u.lock-noun) ~> %slog.[0 'lock data in note is malformed'] ~ + ?@ -.lock.u.soft-lock + ~> %slog.[0 'lock data in note is not a single spend condition'] ~ (some lock.u.soft-lock) - ++ pull-lock + ++ pull |= [nd=note-data:v1:transact nn=nname:transact pkh=(unit hash:transact)] - ^- (unit lock:transact) - ?~ lok=(pull-lock-inner [nd nn pkh]) + ^- (unit spend-condition:transact) + ?~ lok=(pull-inner [nd nn pkh]) ~ ?: =((first:nname:transact (hash:lock:transact u.lok)) -.nn) lok @@ -740,17 +742,38 @@ (bool-text include-data.data) (spend-condition u.cond) == - :: - ++ note - |= [note=nnote:transact output=? output-lock-map=output-lock-map:wt] - ^- @t + :: + ++ note-from-balance + |= note=nnote-1:v1:transact + (^note note (lock-data note-data.note) %.n) + :: + ++ note-from-output + |= $: note=nnote-1:v1:transact + metadata=(unit lock-metadata:wt) + == ?> ?=(@ -.note) - =/ metadata=(unit lock-metadata:wt) - (~(get z-by:zo output-lock-map) -.name.note) - =/ output-lock-info=@t + =/ lock-info=@t ?~ metadata (lock-data note-data.note) (lock-metadata u.metadata) + (^note note lock-info %.y) + :: + ++ note-from-input + |= $: note=nnote-1:v1:transact + sc=spend-condition:transact + == + =/ lock-info=@t (spend-condition sc) + (^note note lock-info %.n) + :: + :: + :: +note: display note. Sometimes lock data is not included in note, it can be passed in + :: separately in the output-lock-map which is accumulated in the tx-builder. + ++ note + |= $: note=nnote-1:v1:transact + lock-info=@t + output=? + == + ^- @t ;: (cury cat 3) ''' @@ -768,7 +791,7 @@ 'N/A (output note has not been submitted yet)' (format-ui:common origin-page.note) '\0a- Lock Information: ' - output-lock-info + lock-info == :: ++ witness-data @@ -824,28 +847,54 @@ min-text ', max: ' max-text == :: + :: show-tx should require sync now ++ transaction |= $: name=@t outs=outputs:v1:transact fees=@ display=transaction-display:wt + get-note=$-(nname:transact nnote:transact) wd=(unit witness-data:wt) == ^- @t + =/ input-notes=tape + ?: ?=(%0 -.inputs.display) + %- zing + %+ turn + ~(tap z-in:zo ~(key z-by:zo p.inputs.display)) + |= =nname:transact + =+ note=(get-note nname) + ?@ -.note + ~| %expected-v0-note-but-got-v1-note !! + "\0a{(trip (note:v0 note))}" + %- zing + %+ turn + ~(tap z-by:zo p.inputs.display) + |= [name=nname:transact sc=spend-condition:transact] + =/ out-note=nnote:transact (get-note name) + ?^ -.out-note + ~| %expected-v1-note-but-got-v0-note !! + "\0a{(trip (note-from-input out-note sc))}" =/ output-notes=tape %- zing %+ turn ~(tap z-in:zo outs) |= out=output:v1:transact =/ out-note=nnote:v1:transact note.out - "\0a{(trip (note out-note %.y outputs.display))}" - :: Input note display is not working because they are not accumulate in builder + =+ fn=~(first-name get:nnote:transact out-note) + =+ metadata=(~(get z-by:zo outputs.display) fn) + ?^ -.out-note + "\0a{(trip (note:v0 out-note))}" + "\0a{(trip (note-from-output out-note metadata))}" %- crip """ ## Transaction Information - Name: {(trip name)} - Fee: {(trip (format-ui:common fees))} + ### Input Notes + {input-notes} + ### Output Notes {output-notes} diff --git a/hoon/apps/wallet/wallet.hoon b/hoon/apps/wallet/wallet.hoon index c13a9d0b8..fc7024186 100644 --- a/hoon/apps/wallet/wallet.hoon +++ b/hoon/apps/wallet/wallet.hoon @@ -715,7 +715,14 @@ =/ =tx:v1:transact (new:tx:v1:transact raw height.balance.state) =/ fees=@ (roll-fees:spends:v1:transact signed-spends) =/ tx-display=@t - (transaction:v1:display:utils transaction-name outputs.tx fees display.transaction `witness-data) + %: transaction:v1:display:utils + transaction-name + outputs.tx + fees + display.transaction + get-note:v + `witness-data + == =+ data=data:*blockchain-constants:transact =/ valid=(reason:dumb ~) %- validate-with-context:spends:transact @@ -774,13 +781,13 @@ =/ fees=@ (roll-fees:spends:v1:transact spends) =/ =raw-tx:v1:transact (new:raw-tx:v1:transact spends) =/ =tx:v1:transact (new:tx:v1:transact raw-tx height.balance.state) - ~& display+display.transaction =/ markdown-text=@t %: transaction:v1:display:utils transaction-name outputs.tx fees display.transaction + get-note:v `witness-data.transaction == :_ state @@ -1109,7 +1116,7 @@ |= note=nnote:transact ?^ -.note (trip (note:v0:display:utils note)) - (trip (note:v1:display:utils note %.n ~)) + (trip (note-from-balance:v1:display:utils note)) :: [%exit 0] == @@ -1164,7 +1171,7 @@ %- trip ?^ -.nnote (note:v0:display:utils nnote) - (note:v1:display:utils nnote output=%.n ~) + (note-from-balance:v1:display:utils nnote) :: [%exit 0] == @@ -1270,6 +1277,7 @@ refund-pkh.cause get-note:v include-data.cause + selection-strategy.cause == =/ multisig-recv-locks=(z-set:zo lock:transact) (gather-multisig-locks orders) @@ -1307,7 +1315,14 @@ =/ =witness-data:wt witness-data.tx-ser =/ fees=@ (roll-fees:spends:v1:transact spends.tx-ser) =/ markdown-text=@t - (transaction:v1:display:utils name.tx-ser outputs.tx fees display.tx-ser `witness-data) + %: transaction:v1:display:utils + name.tx-ser + outputs.tx + fees + display.tx-ser + get-note:v + `witness-data + == :: jam inputs and save as transaction =/ transaction-jam (jam tx-ser) =/ tx-path=@t @@ -1657,8 +1672,10 @@ == =/ =transaction:wt dat.cause =/ =witness-data:wt witness-data.transaction + ?> ?& ?=(%1 -.witness-data) + ?=(%1 -.inputs.display.transaction) + == =/ =spends:v1:transact spends.transaction - ?> ?=(%1 -.witness-data) :: get sign-keys from wallet :: if sign-keys is not provided, use master key =/ sign-keys=(list schnorr-seckey:transact) @@ -1667,6 +1684,53 @@ %+ turn u.sign-keys.cause |= key-info=[child-index=@ud hardened=?] (sign-key:get:v [~ key-info]) + =+ num-keys-provided=(lent sign-keys) + =/ signer-pkhs=(z-set:zo hash:transact) + %- z-silt:zo + %+ turn sign-keys + |= sk=schnorr-seckey:transact + %- hash:schnorr-pubkey:transact + %- from-sk:schnorr-pubkey:transact + (to-atom:schnorr-seckey:transact sk) + :: + :: we assume that there is at most one pkh in a single-spend condition + =/ pkh-lps=(z-map:zo nname:transact pkh:v1:transact) + %- ~(rep z-by:zo p.inputs.display.transaction) + |= $: [k=nname:transact v=spend-condition:transact] + acc=(z-map:zo nname:transact pkh:v1:transact) + == + ?~ v + acc + ?: ?=(%pkh -.i.v) + (~(put z-by:zo acc) k +.i.v) + $(v t.v) + =/ not-required=(z-set:zo hash:transact) + %- ~(rep z-by:zo pkh-lps) + |= $: [k=* =pkh:v1:transact] + acc=(z-set:zo hash:transact) + == + %- ~(uni z-in:zo acc) + (~(dif z-in:zo signer-pkhs) h.pkh) + ?^ not-required + =/ pkhs-list=@t + %+ roll ~(tap z-in:zo `(z-set:zo hash:transact)`not-required) + |= [=hash:transact acc=@t] + ;: (cury cat 3) + acc + '\0a - ' + (to-b58:hash:transact hash) + == + =/ markdown-text=@t + ;: (cury cat 3) + '\0a## Error signing multisig' + '\0a- Attempted to sign transaction with keys that are not required by inputs.' + '\0a- PKHs that are not required: ' + pkhs-list + == + :_ state + :~ [%exit 0] + [%markdown markdown-text] + == :: sign all spends with all sign-keys =. witness-data :- %1 @@ -1677,11 +1741,31 @@ ?> ?=(%1 -.spend) =+ sig-hash=(sig-hash:spend-1:v1:transact +.spend) =+ curr-witness=(~(got z-by:zo p.witness-data) name) + =+ curr-pkh=(~(got z-by:zo pkh-lps) name) + =+ num-signed=~(wyt z-by:zo pkh.curr-witness) + ?: =(m.curr-pkh num-signed) + ~| ^- @t + ;: (cury cat 3) + 'No more signatures are required to spend note: ' + (name:v1:display:utils name) + '. Providing more signatures than required will result in an invalid transaction.' + == + !! + =+ num-needed=(sub m.curr-pkh num-signed) + ?: (gth num-keys-provided num-needed) + ~| ^- @t + ;: (cury cat 3) + 'Number of sign keys exceeds the required remaining signatures. ' + 'Needed: ' + (format-ui:common:display:utils num-needed) + ', but provided: ' + (format-ui:common:display:utils num-keys-provided) + == + !! %+ ~(put z-by:zo wd) name %+ roll sign-keys |= [sk=schnorr-seckey:transact acc=_curr-witness] (sign:witness:transact acc sk sig-hash) - ::>) TODO: should the transaction name be changed to reflect the new signature? (save-signed-transaction transaction(witness-data witness-data) sign-keys) :: ++ save-signed-transaction diff --git a/scripts/run_nockchain_node_fakenet.sh b/scripts/run_nockchain_node_fakenet.sh old mode 100644 new mode 100755 index b17e1687f..fcbaeac70 --- a/scripts/run_nockchain_node_fakenet.sh +++ b/scripts/run_nockchain_node_fakenet.sh @@ -3,4 +3,4 @@ source .env export RUST_LOG export MINIMAL_LOG_FORMAT export MINING_PKH -nockchain --fakenet --grpc-address http://127.0.0.1:5555 --bind /ip4/127.0.0.1/udp/3006/quic-v1 +nockchain --fakenet --bind-public-grpc-addr 127.0.0.1:5555 --bind /ip4/127.0.0.1/udp/3006/quic-v1 From a7dc6439a727277aacc779febee103d723974c24 Mon Sep 17 00:00:00 2001 From: Sigilante <57601680+sigilante@users.noreply.github.com> Date: Wed, 26 Nov 2025 13:55:52 -0600 Subject: [PATCH 25/99] Update README to remove status and development notes (#74) * Update README to remove status and development notes Removed pre-release status and development notes from README. * Fix URL branch reference in installation command * Add permissions for contents in release workflow --- .github/workflows/release.yml | 4 +++- crates/nockup/README.md | 10 +--------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2fcbc939f..250bffdeb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -322,7 +322,9 @@ jobs: needs: build runs-on: ubuntu-latest if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/nightly' || github.ref_type == 'tag') - + permissions: + contents: write + steps: - name: Checkout repository uses: actions/checkout@v4 diff --git a/crates/nockup/README.md b/crates/nockup/README.md index 9d240ae80..ce8543d70 100644 --- a/crates/nockup/README.md +++ b/crates/nockup/README.md @@ -2,14 +2,6 @@ *Nockup* is a command-line tool to produce [NockApps](https://github.com/nockchain/nockchain) and manage project builds and dependencies. -> 🚨 **Status** Pre-release development. -> -> * Nockup has CLI functionality and basic project templating. -> -> * Nockchain interaction templates are in active development. -> -> * Nockup will be officially released as a crate within [Nockchain](https://github.com/nockchain/nockchain). - [The NockApp platform](https://github.com/nockchain/nockchain) is a general-purpose framework for building apps that run using the Nock instruction set architecture. It is particularly well-suited for use with [Nockchain](https://nockchain.org) and the Nock ZKVM. ![](./img/hero.jpg) @@ -21,7 +13,7 @@ Prerequisites: Rust toolchain (`rustup`, `cargo`, &c.), Git. ```sh -curl -fsSL https://raw.githubusercontent.com/nockchain/nockchain/refs/head/master/crates/nockup/install.sh | bash +curl -fsSL https://raw.githubusercontent.com/nockchain/nockchain/refs/heads/master/crates/nockup/install.sh | bash ``` This checks for dependencies and then installs the Nockup binary and its requirements, including the GPG key used to verify binaries on Linux. (This is from the `stable` channel by default; see [Channels](#channels) for more information.) From fe4f5870d0622031de900ac18ef93a8ed4e0b094 Mon Sep 17 00:00:00 2001 From: tacryt-socryp <1377557+tacryt-socryp@users.noreply.github.com> Date: Fri, 28 Nov 2025 17:43:28 -0600 Subject: [PATCH 26/99] README.md update --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 997b27fd7..cbbb2d92c 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Nockchain -**Nockchain is programmable sound money that scales.** +**Nockchain is programmable gold that scales.** -Nockchain is a ZK-Proof of Work L1 that combines sound money incentives with modern research into data availability, app-rollups, and intent-based composability. +Nockchain is a ZK-Proof of Work blockchain that combines sound money incentives with modern research into data availability, app-rollups, and intent-based composability. *Nockchain is entirely experimental and many parts are unaudited. We make no representations or guarantees as to the behavior of this software.* From a9a973fba084d77d297eaacfe9a27372ae146541 Mon Sep 17 00:00:00 2001 From: Sigilante <57601680+sigilante@users.noreply.github.com> Date: Mon, 1 Dec 2025 08:14:25 -0600 Subject: [PATCH 27/99] Update build artifacts and Nockup retrieval format. (#75) * Post new deploy chain. * Add pruning. * Add protobuf support * Add protobuf support * Add protobuf support * Tweak CI * Tweak CI * Tweak CI * Tweak CI * Fix nockup for new manifest format. * Adjust * Fix so build cleanup doesn't happen on PR. --- .github/workflows/release.yml | 233 ++++++++++++++++++++------- crates/nockup/src/commands/common.rs | 139 ++++++++-------- 2 files changed, 245 insertions(+), 127 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 250bffdeb..2126ffc0f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,21 +64,26 @@ jobs: # Get nockup version NOCKUP_VERSION=$(grep '^version' crates/nockup/Cargo.toml | cut -d'"' -f2) + # Get nockchain version + NOCKCHAIN_VERSION=$(grep '^version' crates/nockchain/Cargo.toml | cut -d'"' -f2) + # For nightly, append date if [[ "${{ steps.channel.outputs.channel }}" == "nightly" ]]; then DATE=$(date +%Y%m%d) HOON_VERSION="${HOON_VERSION}-${DATE}" HOONC_VERSION="${HOONC_VERSION}-${DATE}" NOCKUP_VERSION="${NOCKUP_VERSION}-${DATE}" + NOCKCHAIN_VERSION="${NOCKCHAIN_VERSION}-${DATE}" fi # Debug output echo "HOON_VERSION='$HOON_VERSION'" echo "HOONC_VERSION='$HOONC_VERSION'" echo "NOCKUP_VERSION='$NOCKUP_VERSION'" + echo "NOCKCHAIN_VERSION='$NOCKCHAIN_VERSION'" # Validate versions - if [[ -z "$HOON_VERSION" || -z "$HOONC_VERSION" || -z "$NOCKUP_VERSION" ]]; then + if [[ -z "$HOON_VERSION" || -z "$HOONC_VERSION" || -z "$NOCKUP_VERSION" || -z "$NOCKCHAIN_VERSION" ]]; then echo "Error: Missing version in one or more Cargo.toml files" exit 1 fi @@ -86,7 +91,8 @@ jobs: echo "hoon_version=$HOON_VERSION" >> $GITHUB_OUTPUT echo "hoonc_version=$HOONC_VERSION" >> $GITHUB_OUTPUT echo "nockup_version=$NOCKUP_VERSION" >> $GITHUB_OUTPUT - echo "Extracted versions: hoon=$HOON_VERSION, hoonc=$HOONC_VERSION, nockup=$NOCKUP_VERSION" + echo "nockchain_version=$NOCKCHAIN_VERSION" >> $GITHUB_OUTPUT + echo "Extracted versions: hoon=$HOON_VERSION, hoonc=$HOONC_VERSION, nockup=$NOCKUP_VERSION, nockchain=$NOCKCHAIN_VERSION" - name: Install Rust toolchain uses: dtolnay/rust-toolchain@nightly @@ -109,6 +115,16 @@ jobs: # Add target rustup target add ${{ matrix.target }} + - name: Install Protocol Buffers compiler (Linux) + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y protobuf-compiler + + - name: Install Protocol Buffers compiler (macOS) + if: runner.os == 'macOS' + run: brew install protobuf + - name: Cache Rust dependencies uses: swatinem/rust-cache@v2 with: @@ -134,6 +150,17 @@ jobs: sudo apt-get update sudo apt-get install -y perl + # Stage 1a: Build native hoonc for .jam generation (cross-compile only) + - name: Build hoonc-native (for .jam generation) + if: matrix.use_zigbuild == true + run: | + echo "Building native x86_64 hoonc for .jam generation..." + cargo build --release --bin hoonc --manifest-path crates/hoonc/Cargo.toml --locked + # Save it with a different name + cp target/release/hoonc target/release/hoonc-native + file target/release/hoonc-native + + # Stage 1b: Build hoon (all platforms) - name: Build hoon env: CC: ${{ matrix.use_zigbuild && 'zig cc -target aarch64-linux-gnu' || '' }} @@ -148,7 +175,8 @@ jobs: file target/release/hoon fi - - name: Build hoonc + # Stage 1c: Build hoonc for target platform + - name: Build hoonc (target) env: CC: ${{ matrix.use_zigbuild && 'zig cc -target aarch64-linux-gnu' || '' }} CXX: ${{ matrix.use_zigbuild && 'zig c++ -target aarch64-linux-gnu' || '' }} @@ -162,7 +190,34 @@ jobs: file target/release/hoonc fi - - name: Build nockup + # Stage 2: Generate .jam files using native hoonc + - name: Generate .jam assets + run: | + # Use the appropriate hoonc for .jam generation + if [[ "${{ matrix.use_zigbuild }}" == "true" ]]; then + # Cross-compile: use native hoonc + HOONC_PATH="target/release/hoonc-native" + echo "Using native hoonc for .jam generation: $HOONC_PATH" + else + # Native build: use regular hoonc + HOONC_PATH="target/release/hoonc" + echo "Using regular hoonc for .jam generation: $HOONC_PATH" + fi + + # Make hoonc available in PATH + export PATH="$(dirname $(readlink -f $HOONC_PATH)):$PATH" + + # Verify hoonc works + hoonc --version + + # Generate .jam files using Makefile + mkdir -p assets hoon + make assets/dumb.jam assets/miner.jam assets/wal.jam assets/peek.jam + + # Verify they were created + ls -lah assets/*.jam + + - name: Build nockup (Stage 3 - independent) env: CC: ${{ matrix.use_zigbuild && 'zig cc -target aarch64-linux-gnu' || '' }} CXX: ${{ matrix.use_zigbuild && 'zig c++ -target aarch64-linux-gnu' || '' }} @@ -176,6 +231,34 @@ jobs: file target/release/nockup fi + - name: Build nockchain (Stage 3 - needs .jam files) + env: + CC: ${{ matrix.use_zigbuild && 'zig cc -target aarch64-linux-gnu' || '' }} + CXX: ${{ matrix.use_zigbuild && 'zig c++ -target aarch64-linux-gnu' || '' }} + AR: ${{ matrix.use_zigbuild && 'zig ar' || '' }} + run: | + if [[ "${{ matrix.use_zigbuild }}" == "true" ]]; then + cargo zigbuild --release --bin nockchain --manifest-path crates/nockchain/Cargo.toml --target ${{ matrix.target }} + file target/${{ matrix.target }}/release/nockchain + else + cargo build --release --bin nockchain --manifest-path crates/nockchain/Cargo.toml --locked + file target/release/nockchain + fi + + - name: Build nockchain-wallet + env: + CC: ${{ matrix.use_zigbuild && 'zig cc -target aarch64-linux-gnu' || '' }} + CXX: ${{ matrix.use_zigbuild && 'zig c++ -target aarch64-linux-gnu' || '' }} + AR: ${{ matrix.use_zigbuild && 'zig ar' || '' }} + run: | + if [[ "${{ matrix.use_zigbuild }}" == "true" ]]; then + cargo zigbuild --release --bin nockchain-wallet --manifest-path crates/nockchain-wallet/Cargo.toml --target ${{ matrix.target }} + file target/${{ matrix.target }}/release/nockchain-wallet + else + cargo build --release --bin nockchain-wallet --manifest-path crates/nockchain-wallet/Cargo.toml --locked + file target/release/nockchain-wallet + fi + - name: Set up GPG if: runner.os == 'Linux' && github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/nightly' || github.ref_type == 'tag') run: | @@ -209,25 +292,25 @@ jobs: BINARY_DIR="target/release" fi - # Copy and make executable - for binary in hoon hoonc nockup; do + # Package each binary: sign it, then include both binary and .asc in tarball + for binary in hoon hoonc nockup nockchain nockchain-wallet; do if [ -f "$BINARY_DIR/$binary" ]; then cp "$BINARY_DIR/$binary" dist/$binary chmod +x dist/$binary - # Sign individual binary if this is a release build + # Sign the binary if this is a release build if [[ "$SHOULD_SIGN" == "true" ]]; then echo "${{ secrets.GPG_PASSPHRASE }}" | gpg --batch --yes --passphrase-fd 0 --pinentry-mode loopback --detach-sign --armor --default-key "${{ secrets.GPG_KEY_ID }}" dist/$binary echo "Created dist/$binary.asc" + + # Create tarball with both binary and signature + tar -czf dist/${binary}-${{ matrix.target }}.tar.gz -C dist $binary ${binary}.asc + else + # No signing, just create tarball with binary + tar -czf dist/${binary}-${{ matrix.target }}.tar.gz -C dist $binary fi - # Create archive - tar -czf dist/${binary}-${CHANNEL}-latest-${{ matrix.target }}.tar.gz -C dist $binary - - # Sign the archive if this is a release build - if [[ "$SHOULD_SIGN" == "true" ]]; then - echo "${{ secrets.GPG_PASSPHRASE }}" | gpg --batch --yes --passphrase-fd 0 --pinentry-mode loopback --detach-sign --armor --default-key "${{ secrets.GPG_KEY_ID }}" dist/${binary}-${CHANNEL}-latest-${{ matrix.target }}.tar.gz - fi + echo "Created dist/${binary}-${{ matrix.target }}.tar.gz" else echo "Skipping $binary: binary missing at $BINARY_DIR/$binary" fi @@ -248,13 +331,13 @@ jobs: BINARY=$1 TARGET=$2 - CHANNEL=$3 - VERSION=$4 + VERSION=$3 COMMIT_SHA=${GITHUB_SHA:-$(git rev-parse HEAD)} + COMMIT_SHORT=$(echo $COMMIT_SHA | cut -c1-7) DATE=$(date +%Y-%m-%d) # Calculate hashes from the packaged archive - ARCHIVE_PATH="dist/${BINARY}-${CHANNEL}-latest-${TARGET}.tar.gz" + ARCHIVE_PATH="dist/${BINARY}-${TARGET}.tar.gz" if [ ! -f "$ARCHIVE_PATH" ]; then echo "Error: Archive not found at $ARCHIVE_PATH" >&2 @@ -264,13 +347,15 @@ jobs: BLAKE3_HASH=$(b3sum "$ARCHIVE_PATH" | cut -d' ' -f1) SHA1_HASH=$(sha1sum "$ARCHIVE_PATH" 2>/dev/null | cut -d' ' -f1 || shasum -a 1 "$ARCHIVE_PATH" | cut -d' ' -f1) - # Generate URL - URL="https://github.com/nockchain/nockchain/releases/download/$CHANNEL-build-$COMMIT_SHA/$BINARY-$CHANNEL-latest-$TARGET.tar.gz" + # Generate URL - using commit-based release tag + URL="https://github.com/nockchain/nockchain/releases/download/build-$COMMIT_SHA/$BINARY-$TARGET.tar.gz" # Generate manifest cat << MANIFEST_EOF manifest-version = "1" date = "$DATE" + commit = "$COMMIT_SHA" + commit_short = "$COMMIT_SHORT" [pkg.$BINARY] version = "$VERSION" @@ -286,12 +371,30 @@ jobs: chmod +x scripts/generate-manifest.sh + # Get version for nockchain binaries (same for both) + NOCKCHAIN_VERSION="${{ steps.cargo-versions.outputs.nockchain_version }}" + # Generate manifests for each binary that was built - CHANNEL="${{ steps.channel.outputs.channel }}" - for binary in hoon hoonc nockup; do - if [ -f "dist/${binary}-${CHANNEL}-latest-${{ matrix.target }}.tar.gz" ]; then - ./scripts/generate-manifest.sh $binary ${{ matrix.target }} "${CHANNEL}" "latest" > "${binary}-${{ matrix.target }}-manifest.toml" - echo "Generated manifest for $binary" + for binary in hoon hoonc nockup nockchain nockchain-wallet; do + if [ -f "dist/${binary}-${{ matrix.target }}.tar.gz" ]; then + # Determine version based on binary name + case "$binary" in + hoon) + VERSION="${{ steps.cargo-versions.outputs.hoon_version }}" + ;; + hoonc) + VERSION="${{ steps.cargo-versions.outputs.hoonc_version }}" + ;; + nockup) + VERSION="${{ steps.cargo-versions.outputs.nockup_version }}" + ;; + nockchain|nockchain-wallet) + VERSION="$NOCKCHAIN_VERSION" + ;; + esac + + ./scripts/generate-manifest.sh $binary ${{ matrix.target }} "$VERSION" > "${binary}-${{ matrix.target }}-manifest.toml" + echo "Generated manifest for $binary (version $VERSION)" fi done @@ -307,15 +410,37 @@ jobs: if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/nightly' || github.ref_type == 'tag') uses: softprops/action-gh-release@v1 with: - tag_name: ${{ steps.channel.outputs.channel }}-build-${{ github.sha }} - name: ${{ steps.channel.outputs.channel }} Build ${{ github.sha }} + tag_name: build-${{ github.sha }} + name: Build ${{ github.sha }} files: | - dist/hoon-${{ steps.channel.outputs.channel }}-latest-${{ matrix.target }}.tar.gz - ${{ runner.os == 'Linux' && format('dist/hoon-{0}-latest-{1}.tar.gz.asc', steps.channel.outputs.channel, matrix.target) || '' }} - dist/hoonc-${{ steps.channel.outputs.channel }}-latest-${{ matrix.target }}.tar.gz - ${{ runner.os == 'Linux' && format('dist/hoonc-{0}-latest-{1}.tar.gz.asc', steps.channel.outputs.channel, matrix.target) || '' }} - dist/nockup-${{ steps.channel.outputs.channel }}-latest-${{ matrix.target }}.tar.gz - ${{ runner.os == 'Linux' && format('dist/nockup-{0}-latest-{1}.tar.gz.asc', steps.channel.outputs.channel, matrix.target) || '' }} + dist/hoon-${{ matrix.target }}.tar.gz + dist/hoonc-${{ matrix.target }}.tar.gz + dist/nockup-${{ matrix.target }}.tar.gz + dist/nockchain-${{ matrix.target }}.tar.gz + dist/nockchain-wallet-${{ matrix.target }}.tar.gz + + - name: Clean up old releases + if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || github.ref == 'refs/heads/nightly' || github.ref_type == 'tag') + env: + GH_TOKEN: ${{ github.token }} + run: | + # Get the last N commits + KEEP_COUNT=10 + COMMITS_TO_KEEP=$(git rev-list --max-count=$KEEP_COUNT HEAD) + + # Get all releases + ALL_RELEASES=$(gh release list --limit 1000 --json tagName -q '.[].tagName') + + # Delete releases not in the keep list + for release_tag in $ALL_RELEASES; do + # Extract commit from tag (build-) + COMMIT=${release_tag#build-} + + if ! echo "$COMMITS_TO_KEEP" | grep -q "$COMMIT"; then + echo "Deleting old release: $release_tag" + gh release delete "$release_tag" --yes + fi + done collate-manifests: name: Collate Channel Manifests @@ -351,8 +476,9 @@ jobs: #!/bin/bash set -euo pipefail - CHANNEL=$1 - MANIFEST_DIR=$2 + MANIFEST_DIR=$1 + COMMIT_SHA=${GITHUB_SHA:-$(git rev-parse HEAD)} + COMMIT_SHORT=$(echo $COMMIT_SHA | cut -c1-7) # Get nockup version from the workspace NOCKUP_VERSION=$(grep '^version[[:space:]]*=' crates/nockup/Cargo.toml | sed 's/.*=[[:space:]]*"\([^"]*\)".*/\1/' | head -1) @@ -362,16 +488,12 @@ jobs: exit 1 fi - # For nightly, append date - if [[ "$CHANNEL" == "nightly" ]]; then - DATE=$(date +%Y%m%d) - NOCKUP_VERSION="${NOCKUP_VERSION}-${DATE}" - fi - # Start with global metadata cat << HEADER_EOF manifest-version = "1" date = "$(date +%Y-%m-%d)" + commit = "$COMMIT_SHA" + commit_short = "$COMMIT_SHORT" # Global package info [pkg.nockup] @@ -388,7 +510,7 @@ jobs: HEADER_EOF # Process manifests by package, then by target - for package in nockup hoonc hoon; do + for package in nockup nockchain nockchain-wallet hoonc hoon; do echo "# $package binaries" # Find all manifests for this package @@ -419,40 +541,27 @@ jobs: chmod +x scripts/collate-manifests.sh - - name: Determine channel from ref - id: channel - run: | - if [[ "${{ github.ref_type }}" == "tag" ]]; then - echo "channel=stable" >> $GITHUB_OUTPUT - elif [[ "${{ github.ref_name }}" == "master" ]]; then - echo "channel=stable" >> $GITHUB_OUTPUT - elif [[ "${{ github.ref_name }}" == "nightly" ]]; then - echo "channel=nightly" >> $GITHUB_OUTPUT - else - echo "channel=dev" >> $GITHUB_OUTPUT - fi - - name: Collate manifests run: | - ./scripts/collate-manifests.sh ${{ steps.channel.outputs.channel }} toolchains/ > ${{ steps.channel.outputs.channel }}-manifest.toml + ./scripts/collate-manifests.sh toolchains/ > nockchain-manifest.toml - name: Show final manifest run: | - echo "=== Final ${{ steps.channel.outputs.channel }} manifest ===" - cat ${{ steps.channel.outputs.channel }}-manifest.toml + echo "=== Final nockchain manifest ===" + cat nockchain-manifest.toml - name: Upload final manifest uses: actions/upload-artifact@v4 with: - name: ${{ steps.channel.outputs.channel }}-manifest - path: ${{ steps.channel.outputs.channel }}-manifest.toml + name: nockchain-manifest + path: nockchain-manifest.toml retention-days: 30 - name: Add manifest to existing release uses: softprops/action-gh-release@v1 with: - tag_name: ${{ steps.channel.outputs.channel }}-build-${{ github.sha }} + tag_name: build-${{ github.sha }} files: | - ${{ steps.channel.outputs.channel }}-manifest.toml + nockchain-manifest.toml env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/crates/nockup/src/commands/common.rs b/crates/nockup/src/commands/common.rs index 3c76df083..451baed3f 100644 --- a/crates/nockup/src/commands/common.rs +++ b/crates/nockup/src/commands/common.rs @@ -300,41 +300,21 @@ async fn clone_toolchain_files(toolchain_dir: &PathBuf) -> Result<()> { ); async fn get_latest_manifest(channel: &str, toolchain_dir: &PathBuf) -> Result<()> { - let manifest_file = format!("{}-manifest.toml", channel); + let manifest_file = format!("nockchain-manifest.toml"); let output_file = toolchain_dir.join(format!("channel-nockup-{}.toml", channel)); - println!("{} Fetching latest {} manifest...", "🔍".yellow(), channel); - - let api_url = "https://api.github.com/repos/nockchain/nockchain/releases"; - let client = reqwest::Client::new(); - let response = client - .get(api_url) - .header("User-Agent", "nockup") - .send() - .await - .context("Failed to fetch releases from GitHub API")?; - - if !response.status().is_success() { - return Err(anyhow::anyhow!( - "Failed to fetch releases: HTTP {}", - response.status() - )); - } - - let releases: serde_json::Value = response - .json() - .await - .context("Failed to parse releases JSON")?; + println!("{} Fetching manifest for {}...", "🔍".yellow(), channel); let latest_tag = get_git_commit_id().await?; let manifest_url = format!( - "https://github.com/nockchain/nockchain/releases/download/{}-build-{}/{}", - channel, latest_tag, manifest_file + "https://github.com/nockchain/nockchain/releases/download/build-{}/{}", + latest_tag, manifest_file ); println!("{} Downloading from: {}", "⬇️".blue(), manifest_url); + let client = reqwest::Client::new(); let response = client .get(&manifest_url) .header("User-Agent", "nockup") @@ -367,7 +347,7 @@ async fn clone_toolchain_files(toolchain_dir: &PathBuf) -> Result<()> { Ok(()) } - let channels = ["stable", "nightly"]; + let channels = ["stable"]; let mut errors = Vec::new(); for channel in &channels { @@ -437,7 +417,6 @@ pub async fn download_binaries(config: &toml::Value) -> Result<()> { .as_str() .ok_or_else(|| anyhow::anyhow!("{} Invalid URL for {} binary", "❌".red(), index))?; let archive_url = archive_url.replace("http://", "https://"); - let signature_url = format!("{}.asc", archive_url); let archive_blake3 = manifest["pkg"][index]["target"][architecture]["hash_blake3"] .as_str() @@ -450,15 +429,38 @@ pub async fn download_binaries(config: &toml::Value) -> Result<()> { anyhow::anyhow!("{} Invalid SHA1 hash for {} binary", "❌".red(), index) })?; + let archive_path = download_file(&archive_url).await?; + + verify_checksums(&archive_path, &archive_blake3, &archive_sha1).await?; println!("{} Blake3 checksum passed.", "✅".green()); println!("{} SHA1 checksum passed.", "✅".green()); - let archive_path = download_file(&archive_url).await?; + let target_dir = get_cache_dir()?; + let binary_path = target_dir.join("bin"); + fs::create_dir_all(&binary_path)?; + + let temp_extract_dir = std::env::temp_dir().join(format!("nockup_extract_{}", index)); + if temp_extract_dir.exists() { + fs::remove_dir_all(&temp_extract_dir)?; + } + fs::create_dir_all(&temp_extract_dir)?; + extract_archive_contents(&archive_path, &temp_extract_dir, index).await?; + + // Verify GPG signature if on Linux if std::env::consts::OS == "linux" { - let signature_path = download_file(&signature_url).await?; - verify_gpg_signature(&archive_path, &signature_path).await?; - fs::remove_file(&signature_path)?; + let binary_temp_path = temp_extract_dir.join(index); + let signature_temp_path = temp_extract_dir.join(format!("{}.asc", index)); + + if signature_temp_path.exists() { + verify_gpg_signature(&binary_temp_path, &signature_temp_path).await?; + } else { + println!( + "{} Warning: No signature file found in archive for {}", + "⚠️".yellow(), + index + ); + } } else { println!( "{} Skipping signature verification on {} (not yet supported)", @@ -467,14 +469,32 @@ pub async fn download_binaries(config: &toml::Value) -> Result<()> { ); } - verify_checksums(&archive_path, &archive_blake3, &archive_sha1).await?; + let final_binary_path = binary_path.join(index); + let binary_temp_path = temp_extract_dir.join(index); - let target_dir = get_cache_dir()?; - let binary_path = target_dir.join("bin"); - fs::create_dir_all(&binary_path)?; + if final_binary_path.exists() { + fs::remove_file(&final_binary_path)?; + } - extract_binary_from_archive(&archive_path, &binary_path, index).await?; + fs::rename(&binary_temp_path, &final_binary_path)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(&final_binary_path)?.permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&final_binary_path, perms)?; + } + + println!( + "{} Installed {} to {}", + "✅".green(), + index, + final_binary_path.display() + ); + + // Clean up + fs::remove_dir_all(&temp_extract_dir)?; fs::remove_file(&archive_path)?; } @@ -583,13 +603,13 @@ async fn verify_gpg_signature( Ok(()) } -async fn extract_binary_from_archive( +async fn extract_archive_contents( archive_path: &std::path::Path, target_dir: &std::path::Path, binary_name: &str, ) -> Result<()> { println!( - "{} Extracting {} from archive...", + "{} Extracting {} and signature from archive...", "📦".yellow(), binary_name ); @@ -598,49 +618,38 @@ async fn extract_binary_from_archive( let decoder = GzDecoder::new(file); let mut archive = Archive::new(decoder); - let mut found_binary = false; - for entry in archive .entries() .context("Failed to read archive entries")? { let mut entry = entry.context("Failed to read archive entry")?; let entry_path = entry.path().context("Failed to get entry path")?; + let file_name = entry_path + .file_name() + .ok_or_else(|| anyhow::anyhow!("Invalid file name in archive"))?; - if entry_path.file_name() == Some(std::ffi::OsStr::new(binary_name)) { - let target_path = target_dir.join(binary_name); + // Extract both the binary and its .asc signature + if file_name == std::ffi::OsStr::new(binary_name) + || file_name == std::ffi::OsStr::new(&format!("{}.asc", binary_name)) + { + let target_path = target_dir.join(file_name); let mut buffer = Vec::new(); entry .read_to_end(&mut buffer) - .context("Failed to read binary from archive")?; - - let temp_path = target_path.with_extension("tmp"); - std::fs::write(&temp_path, buffer).context("Failed to write extracted binary")?; - - std::fs::rename(&temp_path, &target_path) - .context("Failed to move binary to final location")?; + .context("Failed to read file from archive")?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(&target_path)?.permissions(); - perms.set_mode(0o755); - std::fs::set_permissions(&target_path, perms)?; - } - - println!( - "{} Extracted {} to {}", - "✅".green(), - binary_name, + std::fs::write(&target_path, buffer).context(format!( + "Failed to write extracted file: {}", target_path.display() - ); - found_binary = true; - break; + ))?; + + println!("{} Extracted {}", "✅".green(), target_path.display()); } } - if !found_binary { + // Verify binary was extracted + if !target_dir.join(binary_name).exists() { return Err(anyhow::anyhow!( "Binary '{}' not found in archive", binary_name )); From 5539271e8bc37f6fcbf4d1e6ca2bd793f6f4df02 Mon Sep 17 00:00:00 2001 From: Sigilante <57601680+sigilante@users.noreply.github.com> Date: Mon, 1 Dec 2025 15:14:00 -0600 Subject: [PATCH 28/99] Fix install script path. (#77) * Post new deploy chain. * Add pruning. * Add protobuf support * Add protobuf support * Add protobuf support * Tweak CI * Tweak CI * Tweak CI * Tweak CI * Fix nockup for new manifest format. * Adjust * Fix so build cleanup doesn't happen on PR. * Fix install script and update old links to sigilante repo. * Fix last URLs --- crates/nockup/Cargo.toml | 4 +- crates/nockup/README.md | 2 +- crates/nockup/install.sh | 76 ++++++++++++++-------------- crates/nockup/quick_start.sh | 2 +- crates/nockup/replit/setup-nockup.sh | 2 +- crates/nockup/src/commands/common.rs | 2 +- 6 files changed, 44 insertions(+), 44 deletions(-) diff --git a/crates/nockup/Cargo.toml b/crates/nockup/Cargo.toml index 49fa8877e..2d3f5169f 100644 --- a/crates/nockup/Cargo.toml +++ b/crates/nockup/Cargo.toml @@ -5,8 +5,8 @@ edition = "2021" authors = [""] description = "A developer support framework for NockApp development" license = "MIT" -repository = "https://github.com/sigilante/nockup" -homepage = "https://github.com/sigilante/nockup" +repository = "https://github.com/nockchain/nockchain" +homepage = "https://github.com/nockchain/nockchain" documentation = "https://docs.nockchain.org/" readme = "README.md" keywords = ["nockchain", "nockapp", "development", "cli"] diff --git a/crates/nockup/README.md b/crates/nockup/README.md index ce8543d70..3aedacd09 100644 --- a/crates/nockup/README.md +++ b/crates/nockup/README.md @@ -280,7 +280,7 @@ To that end, Nockup supports three patterns for importing libraries: 2. Repository imports, simple structure. 3. Nested repository imports. -Examples of each are provided in [`example-manifest-with-libraries.toml`](https://github.com/sigilante/nockup/blob/master/manifests/example-manifest-with-libraries.toml). +Examples of each are provided in [`example-manifest-with-libraries.toml`](https://github.com/nockchain/nockchain/blob/master/crates/nockup/manifests/example-manifest-with-libraries.toml). #### Single Libraries diff --git a/crates/nockup/install.sh b/crates/nockup/install.sh index fb28e9f62..2ed6495f8 100644 --- a/crates/nockup/install.sh +++ b/crates/nockup/install.sh @@ -17,8 +17,8 @@ GITHUB_REPO="nockchain/nockchain" VERSION="unknown" RELEASE_TAG="unknown" CHANNEL="stable" -CONFIG_URL_MACOS="https://raw.githubusercontent.com/nockchain/nockup/refs/heads/master/default-config-aarch64-apple-darwin.toml" -CONFIG_URL_LINUX="https://raw.githubusercontent.com/nockchain/nockup/refs/heads/master/default-config-x86_64-unknown-linux-gnu.toml" +CONFIG_URL_MACOS="https://raw.githubusercontent.com/nockchain/nockchain/refs/heads/master/crates/nockup/default-config-aarch64-apple-darwin.toml" +CONFIG_URL_LINUX="https://raw.githubusercontent.com/nockchain/nockchain/refs/heads/master/crates/nockup/default-config-x86_64-unknown-linux-gnu.toml" # Determine config URL based on OS if [[ "$(uname -s)" == "Darwin" ]]; then CONFIG_URL="$CONFIG_URL_MACOS" @@ -112,35 +112,35 @@ get_latest_release() { fi print_info "Latest commit: ${latest_commit_sha:0:7}" - - # Now construct the expected tag name - local expected_tag="${channel}-build-${latest_commit_sha}" - - print_info "Looking for release: $expected_tag" - + + # Construct the release tag (channel is ignored in the actual release tag) + local release_tag="build-${latest_commit_sha}" + + print_info "Looking for release: $release_tag" + # Verify this release exists - local releases_url="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${expected_tag}" + local releases_url="https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${release_tag}" local temp_release="/tmp/nockup-release-$$.json" - + if ! download_file "$releases_url" "$temp_release"; then - print_error "Release not found for tag: $expected_tag" + print_error "Release not found for tag: $release_tag" print_info "The build may still be in progress" rm -f "$temp_release" return 1 fi - + # Extract version from release name local version="" version=$(grep -o "\"name\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$temp_release" | \ sed 's/"name"[[:space:]]*:[[:space:]]*"\([^"]*\)"/\1/' | head -1) || true - + if [[ -z "$version" ]]; then - version=$(echo "$expected_tag" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') || version="latest" + version=$(echo "$release_tag" | grep -oE '[0-9]+\.[0-9]+\.[0-9]+') || version="latest" fi - + rm -f "$temp_release" - - echo "$expected_tag|$version" + + echo "$release_tag|$version" } # Function to detect platform and architecture @@ -192,32 +192,32 @@ setup_toolchain() { get_latest_manifest() { local channel="$1" - local manifest_file="${channel}-manifest.toml" + local manifest_file="nockchain-manifest.toml" local output_file="${toolchain_dir}/channel-nockup-${channel}.toml" - + print_info "Fetching latest ${channel} manifest..." - - local api_url="https://api.github.com/repos/nockchain/nockchain/releases" - local temp_releases="/tmp/releases_${channel}.json" - - if ! download_file "$api_url" "$temp_releases"; then - print_warning "Failed to fetch releases from GitHub API for ${channel}" + + # Get the latest commit SHA from the master branch + local commits_url="https://api.github.com/repos/nockchain/nockchain/commits/master" + local temp_commit="/tmp/commit_${channel}.json" + + if ! download_file "$commits_url" "$temp_commit"; then + print_warning "Failed to fetch latest commit from GitHub API for ${channel}" return 1 fi - - local latest_tag="" - # ✅ Fixed: Added [[:space:]]* to handle spaces in JSON - latest_tag=$(grep -o "\"tag_name\"[[:space:]]*:[[:space:]]*\"${channel}-build-[^\"]*\"" "$temp_releases" 2>/dev/null | \ - sed 's/"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)"/\1/' | head -1) || true - - rm -f "$temp_releases" - - if [[ -z "$latest_tag" ]]; then - print_warning "No ${channel} releases found" + + local latest_commit_sha="" + latest_commit_sha=$(grep -o "\"sha\"[[:space:]]*:[[:space:]]*\"[^\"]*\"" "$temp_commit" | \ + sed 's/"sha"[[:space:]]*:[[:space:]]*"\([^"]*\)"/\1/' | head -1) || true + + rm -f "$temp_commit" + + if [[ -z "$latest_commit_sha" ]]; then + print_warning "No commit SHA found for ${channel}" return 1 fi - - local manifest_url="https://github.com/nockchain/nockchain/releases/download/${latest_tag}/${manifest_file}" + + local manifest_url="https://github.com/nockchain/nockchain/releases/download/build-${latest_commit_sha}/${manifest_file}" print_info "Downloading from: $manifest_url" if download_file "$manifest_url" "$output_file"; then @@ -430,7 +430,7 @@ main() { } trap cleanup EXIT - local archive_name="nockup-${CHANNEL}-latest-${target}.tar.gz" + local archive_name="nockup-${target}.tar.gz" local download_url="https://github.com/${GITHUB_REPO}/releases/download/${RELEASE_TAG}/${archive_name}" local archive_path="${temp_dir}/${archive_name}" diff --git a/crates/nockup/quick_start.sh b/crates/nockup/quick_start.sh index ff03f39d1..1f7f902a1 100644 --- a/crates/nockup/quick_start.sh +++ b/crates/nockup/quick_start.sh @@ -83,7 +83,7 @@ check_nockup() { if ! command -v nockup >/dev/null 2>&1; then print_error "Nockup not found. Please run the installer first:" - print_info " curl -fsSL https://raw.githubusercontent.com/sigilante/nockup/master/install.sh | bash" + print_info " curl -fsSL https://raw.githubusercontent.com/nockchain/nockchain/master/crates/nockup/install.sh | bash" exit 1 fi diff --git a/crates/nockup/replit/setup-nockup.sh b/crates/nockup/replit/setup-nockup.sh index e00e6f994..d67e965cd 100644 --- a/crates/nockup/replit/setup-nockup.sh +++ b/crates/nockup/replit/setup-nockup.sh @@ -18,7 +18,7 @@ fi # Install nockup echo "📦 Installing nockup..." -curl -H "Cache-Control: no-cache" -H "Pragma: no-cache" -fsSL https://raw.githubusercontent.com/sigilante/nockup/master/install.sh | bash +curl -H "Cache-Control: no-cache" -H "Pragma: no-cache" -fsSL https://raw.githubusercontent.com/nockchain/nockchain/master/crates/nockup/install.sh | bash # Source the activation script to make nockup available immediately if [ -f "$HOME/.nockup/activate.sh" ]; then diff --git a/crates/nockup/src/commands/common.rs b/crates/nockup/src/commands/common.rs index 451baed3f..5b690b2a0 100644 --- a/crates/nockup/src/commands/common.rs +++ b/crates/nockup/src/commands/common.rs @@ -12,7 +12,7 @@ use tar::Archive; use tokio::fs as tokio_fs; use tokio::process::Command; -const GITHUB_REPO: &str = "sigilante/nockup"; +const GITHUB_REPO: &str = "nockchain/nockchain"; const TEMPLATES_BRANCH: &str = "master"; pub fn get_cache_dir() -> Result { From 237c5eef655bc2c4c765db017f970f702fd28161 Mon Sep 17 00:00:00 2001 From: ~nallux-dozryl <97745768+nallux-dozryl@users.noreply.github.com> Date: Tue, 2 Dec 2025 05:14:13 +0800 Subject: [PATCH 29/99] excluded txs peek (#76) --- hoon/apps/dumbnet/inner.hoon | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/hoon/apps/dumbnet/inner.hoon b/hoon/apps/dumbnet/inner.hoon index 94ab206c2..0e50ebd01 100644 --- a/hoon/apps/dumbnet/inner.hoon +++ b/hoon/apps/dumbnet/inner.hoon @@ -296,6 +296,11 @@ [%raw-transactions ~] ^- (unit (unit (z-map tx-id:t [=raw-tx:t heard-at=@]))) ``raw-txs.c.k + :: + :: transactions unneeded by any block + [%excluded-txs ~] + ^- (unit (unit (z-set tx-id:t))) + ``excluded-txs.c.k :: :: For %block, %transaction, %raw-transaction, and %balance scries, the ID is :: passed as a base58 encoded string in the scry path. From d1e6fb1ab367b388d96b62082cf2913879b6a04e Mon Sep 17 00:00:00 2001 From: Sigilante <57601680+sigilante@users.noreply.github.com> Date: Tue, 2 Dec 2025 15:23:31 -0600 Subject: [PATCH 30/99] Fix cleanup logic. (#78) * Post new deploy chain. * Add pruning. * Add protobuf support * Add protobuf support * Add protobuf support * Tweak CI * Tweak CI * Tweak CI * Tweak CI * Fix nockup for new manifest format. * Adjust * Fix so build cleanup doesn't happen on PR. * Fix install script and update old links to sigilante repo. * Fix last URLs * Fix cleanup logic. --- .github/workflows/release.yml | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2126ffc0f..50dc023ab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -424,21 +424,26 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - # Get the last N commits - KEEP_COUNT=10 - COMMITS_TO_KEEP=$(git rev-list --max-count=$KEEP_COUNT HEAD) - - # Get all releases - ALL_RELEASES=$(gh release list --limit 1000 --json tagName -q '.[].tagName') - - # Delete releases not in the keep list - for release_tag in $ALL_RELEASES; do - # Extract commit from tag (build-) - COMMIT=${release_tag#build-} - - if ! echo "$COMMITS_TO_KEEP" | grep -q "$COMMIT"; then - echo "Deleting old release: $release_tag" - gh release delete "$release_tag" --yes + # Keep previous releases for the last N days instead of last N commits + # This avoids issues with parallel merges and non-linear history + KEEP_DAYS=28 + CUTOFF_DATE=$(date -u -d "$KEEP_DAYS days ago" +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -v-${KEEP_DAYS}d +%Y-%m-%dT%H:%M:%SZ) + + echo "Keeping releases created after: $CUTOFF_DATE" + + # Get all releases with their creation dates + gh release list --limit 1000 --json tagName,createdAt | jq -r '.[] | "\(.tagName)|\(.createdAt)"' | while IFS='|' read -r release_tag created_at; do + # Skip non-build tags + if [[ ! "$release_tag" =~ ^build- ]]; then + continue + fi + + # Compare dates + if [[ "$created_at" < "$CUTOFF_DATE" ]]; then + echo "Deleting old release: $release_tag (created $created_at)" + gh release delete "$release_tag" --yes || echo "Failed to delete $release_tag" + else + echo "Keeping recent release: $release_tag (created $created_at)" fi done From dcb1de98ce7c5fc3a46445db55c83f70f732c0f4 Mon Sep 17 00:00:00 2001 From: Sigilante <57601680+sigilante@users.noreply.github.com> Date: Tue, 2 Dec 2025 16:00:49 -0600 Subject: [PATCH 31/99] Change to new templates directory location. (#80) * Post new deploy chain. * Add pruning. * Add protobuf support * Add protobuf support * Add protobuf support * Tweak CI * Tweak CI * Tweak CI * Tweak CI * Fix nockup for new manifest format. * Adjust * Fix so build cleanup doesn't happen on PR. * Fix install script and update old links to sigilante repo. * Fix last URLs * Fix cleanup logic. * Fix templates dir. --- crates/nockup/src/commands/common.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/nockup/src/commands/common.rs b/crates/nockup/src/commands/common.rs index 5b690b2a0..6a7c8d94a 100644 --- a/crates/nockup/src/commands/common.rs +++ b/crates/nockup/src/commands/common.rs @@ -173,7 +173,7 @@ async fn clone_templates(templates_dir: &PathBuf) -> Result<()> { )); } - let repo_templates_dir = temp_dir.join("templates"); + let repo_templates_dir = temp_dir.join("crates/nockup/templates"); if !repo_templates_dir.exists() { fs::remove_dir_all(&temp_dir).ok(); return Err(anyhow::anyhow!( @@ -190,7 +190,7 @@ async fn clone_templates(templates_dir: &PathBuf) -> Result<()> { Err(e) => return Err(e.into()), } - let repo_manifests_dir = temp_dir.join("manifests"); + let repo_manifests_dir = temp_dir.join("crates/nockup/manifests"); if !repo_manifests_dir.exists() { fs::remove_dir_all(&temp_dir).ok(); return Err(anyhow::anyhow!( From 1722a9827a16be4134310d5db7ba58e797693d68 Mon Sep 17 00:00:00 2001 From: tacryt-socryp <1377557+tacryt-socryp@users.noreply.github.com> Date: Thu, 4 Dec 2025 10:07:13 -0600 Subject: [PATCH 32/99] nockchain-api: add grpc support for v1 to grpc, lock details and metrics to gRPC explorer Introduces new gRPC endpoints for block details and explorer metrics in the proto definition. Implements full page/block details retrieval, explorer metrics snapshot, and latency tracking in the block explorer cache. Updates related service logic and increases v1 cache page size. Adds supporting types and decoding logic for new block and transaction formats. --- Cargo.toml | 4 +- .../proto/nockchain/public/v2/nockchain.proto | 149 +- crates/nockapp-grpc/Cargo.toml | 1 + .../src/services/public_nockchain/v1/cache.rs | 3 +- .../public_nockchain/v2/block_explorer.rs | 1718 +++++++++++++++-- .../src/services/public_nockchain/v2/cache.rs | 2 +- .../services/public_nockchain/v2/metrics.rs | 85 + .../services/public_nockchain/v2/server.rs | 402 +++- crates/nockchain-api/Cargo.toml | 12 + crates/nockchain-api/benches/peek_refresh.rs | 284 +++ crates/nockchain-explorer-tui/src/main.rs | 1293 +++++++++++-- .../src/tx_engine/common/mod.rs | 3 + .../src/tx_engine/common/page.rs | 380 ++++ 13 files changed, 3999 insertions(+), 337 deletions(-) create mode 100644 crates/nockchain-api/benches/peek_refresh.rs create mode 100644 crates/nockchain-types/src/tx_engine/common/page.rs diff --git a/Cargo.toml b/Cargo.toml index de528fb7d..4bab8757a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ resolver = "2" [workspace] -members = ["crates/nockchain-api", "crates/nockchain-explorer-tui", "crates/nockchain-peek", "crates/equix-latency", "crates/hoon", "crates/hoonc", "crates/kernels", "crates/nockapp", "crates/nockapp-grpc", "crates/nockapp-grpc-proto", "crates/nockchain", "crates/nockchain-libp2p-io", "crates/nockchain-math", "crates/nockchain-types", "crates/nockchain-wallet", "crates/raw-tx-checker", "crates/nockvm/rust/ibig", "crates/nockvm/rust/murmur3", "crates/nockvm/rust/nockvm", "crates/nockvm/rust/nockvm_macros", "crates/noun-serde", "crates/noun-serde-derive", "crates/zkvm-jetpack", "crates/nockup"] +members = ["crates/nockchain-api", "crates/nockchain-explorer-tui", "crates/nockchain-peek", "crates/equix-latency", "crates/hoon", "crates/hoonc", "crates/kernels", "crates/nockapp", "crates/nockapp-grpc", "crates/nockapp-grpc-proto", "crates/nockchain", "crates/nockchain-libp2p-io", "crates/nockchain-math", "crates/nockchain-types", "crates/nockchain-wallet", "crates/raw-tx-checker", "crates/nockvm/rust/ibig", "crates/nockvm/rust/murmur3", "crates/nockup", "crates/nockvm/rust/nockvm", "crates/nockvm/rust/nockvm_macros", "crates/noun-serde", "crates/noun-serde-derive", "crates/zkvm-jetpack"] [workspace.package] version = "0.1.0" @@ -45,7 +45,7 @@ futures = "0.3.31" gdt-cpus = "25.5.0" getrandom = { version = "0.3.3", features = ["std"] } glob = "0.3" -gnort = "0.1.1" +gnort = "0.2.0" hex = "0.4" hex-literal = "1.0.0" hickory-proto = "0.25.0-alpha.4" diff --git a/crates/nockapp-grpc-proto/proto/nockchain/public/v2/nockchain.proto b/crates/nockapp-grpc-proto/proto/nockchain/public/v2/nockchain.proto index e54cec4a2..b78de3868 100644 --- a/crates/nockapp-grpc-proto/proto/nockchain/public/v2/nockchain.proto +++ b/crates/nockapp-grpc-proto/proto/nockchain/public/v2/nockchain.proto @@ -61,12 +61,20 @@ message TransactionAcceptedResponse { service NockchainBlockService { rpc GetBlocks(GetBlocksRequest) returns (GetBlocksResponse); + rpc GetBlockDetails(GetBlockDetailsRequest) + returns (GetBlockDetailsResponse); rpc GetTransactionBlock(GetTransactionBlockRequest) returns (GetTransactionBlockResponse); rpc GetTransactionDetails(GetTransactionDetailsRequest) returns (GetTransactionDetailsResponse); } +// Metrics service for explorer/cache status +service NockchainMetricsService { + rpc GetExplorerMetrics(GetExplorerMetricsRequest) + returns (GetExplorerMetricsResponse); +} + message GetBlocksRequest { common.v1.PageRequest page = 1; } @@ -135,8 +143,12 @@ message TransactionDetails { uint64 version = 5; uint64 size_bytes = 6; common.v1.Nicks total_input = 7; - common.v1.Nicks total_output = 8; - common.v1.Nicks fee = 9; + oneof total_output_required { + common.v1.Nicks total_output = 8; + } + oneof fee_required { + common.v1.Nicks fee = 9; + } repeated TransactionInput inputs = 10; repeated TransactionOutput outputs = 11; common.v1.Hash parent = 12; @@ -151,6 +163,137 @@ message TransactionInput { message TransactionOutput { string note_name_b58 = 1; - common.v1.Nicks amount = 2; + oneof amount_required { + common.v1.Nicks amount = 2; + } string lock_summary = 3; } + +// =================================================================== +// Block Details - Full Page Information +// =================================================================== + +message GetBlockDetailsRequest { + oneof selector { + uint64 height = 1; + common.v1.Base58Hash block_id = 2; + } +} + +message GetBlockDetailsResponse { + oneof result { + BlockDetails details = 1; + common.v1.ErrorStatus error = 2; + } +} + +// =================================================================== +// Explorer Metrics +// =================================================================== + +message GetExplorerMetricsRequest {} + +message GetExplorerMetricsResponse { + oneof result { + ExplorerMetrics metrics = 1; + common.v1.ErrorStatus error = 2; + } +} + +message ExplorerMetrics { + uint64 cache_height = 1; + uint64 heaviest_height = 2; + uint64 cache_lowest_height = 3; + uint64 cache_span = 4; + double cache_coverage_ratio = 5; + double refresh_age_seconds = 6; + double backfill_age_seconds = 7; + bool seed_ready = 8; + double seed_time_seconds = 9; + int64 backfill_resume_height = 10; // -1 when none + double cache_age_seconds = 11; + uint64 refresh_success_count = 12; + uint64 refresh_error_count = 13; + uint64 backfill_success_count = 14; + uint64 backfill_error_count = 15; + double get_blocks_p50_ms = 16; + double get_blocks_p90_ms = 17; + double get_blocks_p99_ms = 18; + double get_block_details_p50_ms = 19; + double get_block_details_p90_ms = 20; + double get_block_details_p99_ms = 21; +} + +message BlockDetails { + // Identity + common.v1.Hash block_id = 1; + uint64 height = 2; + common.v1.Hash parent = 3; + + // Proof of Work + ProofOfWork pow = 4; + + // Consensus + uint64 timestamp = 5; + uint64 epoch_counter = 6; + BigNum target = 7; + BigNum accumulated_work = 8; + + // Content + repeated common.v1.Base58Hash tx_ids = 9; + CoinbaseSplit coinbase = 10; + PageMsg msg = 11; + + // Computed/derived fields + uint32 tx_count = 12; + bool has_pow = 13; + uint32 version = 14; // 0 for v0 pages, 1 for v1 pages +} + +message ProofOfWork { + bool present = 1; + optional bytes raw_proof = 2; +} + +message BigNum { + // Base58 string representation for display + string display = 1; + // Raw little-endian bytes for precision + bytes raw_bytes = 2; + // Hex string for debugging + string hex = 3; +} + +message CoinbaseSplit { + oneof version { + CoinbaseSplitV0 v0 = 1; + CoinbaseSplitV1 v1 = 2; + } +} + +message CoinbaseSplitV0 { + repeated CoinbaseSplitV0Entry entries = 1; + uint32 entry_count = 2; // Number of entries (when we can't decode full details) + string note = 3; // Human-readable note about the format +} + +message CoinbaseSplitV0Entry { + common.v1.Base58Pubkey pubkey = 1; + common.v1.Nicks amount = 2; +} + +message CoinbaseSplitV1 { + repeated CoinbaseSplitV1Entry entries = 1; +} + +message CoinbaseSplitV1Entry { + common.v1.Base58Hash lock_hash = 1; + common.v1.Nicks amount = 2; +} + +message PageMsg { + // Raw message bytes + bytes raw = 1; + // UTF-8 decoded if valid, otherwise empty + string decoded = 2; +} diff --git a/crates/nockapp-grpc/Cargo.toml b/crates/nockapp-grpc/Cargo.toml index 1f8b1e3a3..71cd640b6 100644 --- a/crates/nockapp-grpc/Cargo.toml +++ b/crates/nockapp-grpc/Cargo.toml @@ -21,6 +21,7 @@ anyhow = { workspace = true } bytes = { workspace = true } dashmap = { workspace = true } gnort = { workspace = true } +ibig = { workspace = true } prost = { workspace = true } prost-build = { workspace = true } prost-types = { workspace = true } diff --git a/crates/nockapp-grpc/src/services/public_nockchain/v1/cache.rs b/crates/nockapp-grpc/src/services/public_nockchain/v1/cache.rs index 8e923a9d6..1ecf6bd5c 100644 --- a/crates/nockapp-grpc/src/services/public_nockchain/v1/cache.rs +++ b/crates/nockapp-grpc/src/services/public_nockchain/v1/cache.rs @@ -11,8 +11,9 @@ use crate::pb::common::v1::{ErrorCode, ErrorStatus}; use crate::pb::public::v1::{wallet_get_balance_response, WalletGetBalanceResponse}; use crate::v1::pagination::{encode_cursor, name_key, PageCursor, PageKey}; +// Max for gRPC is 4 MiB anyway, so we leave some slack here for the envelope. pub const MAX_PAGE_BYTES: u64 = 3 * 1024 * 1024; -pub const MAX_PAGE_SIZE: usize = 1000; +pub const MAX_PAGE_SIZE: usize = 4096; pub const DEFAULT_PAGE_BYTES: u64 = 1024 * 1024; pub const DEFAULT_PAGE_SIZE: usize = 600; const PER_ENTRY_OVERHEAD: usize = 8; diff --git a/crates/nockapp-grpc/src/services/public_nockchain/v2/block_explorer.rs b/crates/nockapp-grpc/src/services/public_nockchain/v2/block_explorer.rs index eff06c0d7..44180a199 100644 --- a/crates/nockapp-grpc/src/services/public_nockchain/v2/block_explorer.rs +++ b/crates/nockapp-grpc/src/services/public_nockchain/v2/block_explorer.rs @@ -1,19 +1,24 @@ -use std::collections::{BTreeMap, HashMap}; +use std::collections::{BTreeMap, HashMap, VecDeque}; use std::convert::TryFrom; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use nockapp::noun::slab::NounSlab; -use nockapp_grpc_proto::pb::public::v2::{TransactionDetails, TransactionInput, TransactionOutput}; +use nockapp_grpc_proto::pb::public::v2::{ + transaction_details, transaction_output, BigNum as ProtoBigNum, BlockDetails, + CoinbaseSplit as ProtoCoinbaseSplit, CoinbaseSplitV0 as ProtoCoinbaseSplitV0, + CoinbaseSplitV1 as ProtoCoinbaseSplitV1, CoinbaseSplitV1Entry, PageMsg as ProtoPageMsg, + ProofOfWork, TransactionDetails, TransactionInput, TransactionOutput, +}; use nockchain_math::noun_ext::NounMathExt; use nockchain_math::structs::HoonMapIter; -use nockchain_types::tx_engine::common::{BlockHeight, Hash, Name}; +use nockchain_types::tx_engine::common::{BlockHeight, Hash, Name, Page}; use nockchain_types::tx_engine::v0::{Lock, NoteV0, RawTx}; use nockvm::noun::{Noun, SIG}; use noun_serde::{NounDecode, NounDecodeError, NounEncode}; use tokio::sync::{RwLock, Semaphore}; -use tracing::{debug, info, warn}; +use tracing::{debug, error, info, warn}; use crate::error::{NockAppGrpcError, Result as GrpcResult}; use crate::pb::common::v1 as pb_common; @@ -30,6 +35,144 @@ pub struct BlockMetadata { pub tx_ids: Vec, } +#[derive(Debug, Clone)] +pub struct ExplorerMetricsSnapshot { + pub cache_height: u64, + pub heaviest_height: u64, + pub cache_lowest_height: u64, + pub cache_span: u64, + pub cache_coverage_ratio: f64, + pub seed_ready: bool, + pub backfill_resume_height: Option, + pub cache_age_seconds: f64, + pub refresh_age_seconds: f64, + pub backfill_age_seconds: Option, + pub seed_time_seconds: f64, + pub refresh_success_count: u64, + pub refresh_error_count: u64, + pub backfill_success_count: u64, + pub backfill_error_count: u64, + pub get_blocks_p50_ms: f64, + pub get_blocks_p90_ms: f64, + pub get_blocks_p99_ms: f64, + pub get_block_details_p50_ms: f64, + pub get_block_details_p90_ms: f64, + pub get_block_details_p99_ms: f64, +} + +#[derive(Debug)] +pub struct LatencyTracker { + samples: Mutex>, + capacity: usize, +} + +impl LatencyTracker { + pub fn new(capacity: usize) -> Self { + Self { + samples: Mutex::new(VecDeque::with_capacity(capacity)), + capacity, + } + } + + pub fn record(&self, duration: Duration) { + let mut guard = self.samples.lock().expect("latency tracker poisoned"); + if guard.len() == self.capacity { + guard.pop_front(); + } + guard.push_back(duration); + } + + fn quantile(sorted: &[f64], quantile: f64) -> f64 { + if sorted.is_empty() { + return 0.0; + } + let idx = ((sorted.len().saturating_sub(1)) as f64 * quantile).round() as usize; + sorted[idx.min(sorted.len().saturating_sub(1))] + } + + pub fn quantiles_ms(&self) -> (f64, f64, f64) { + let guard = self.samples.lock().expect("latency tracker poisoned"); + if guard.is_empty() { + return (0.0, 0.0, 0.0); + } + let mut values: Vec = guard.iter().map(|d| d.as_secs_f64() * 1000.0).collect(); + values.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + + ( + Self::quantile(&values, 0.50), + Self::quantile(&values, 0.90), + Self::quantile(&values, 0.99), + ) + } +} + +/// Full page details with all blockchain consensus fields +#[derive(Debug, Clone)] +pub struct FullPageDetails { + /// Block identity + pub height: u64, + pub block_id: Hash, + pub parent_id: Hash, + + /// Block version (0 for v0, 1 for v1) + pub version: u32, + + /// Proof of work + pub pow_present: bool, + pub pow_raw: Option>, + + /// Consensus parameters + pub timestamp: u64, + pub epoch_counter: u64, + pub target: BigNumValue, + pub accumulated_work: BigNumValue, + + /// Block content + pub tx_ids: Vec, + pub coinbase: CoinbaseSplitValue, + pub msg: PageMsgValue, +} + +/// BigNum representation for display +#[derive(Debug, Clone)] +pub struct BigNumValue { + pub raw_bytes: Vec, +} + +impl BigNumValue { + pub fn to_display(&self) -> String { + if self.raw_bytes.is_empty() { + return "0".to_string(); + } + let ubig = ibig::UBig::from_le_bytes(&self.raw_bytes); + ubig.to_string() + } + + pub fn to_hex(&self) -> String { + if self.raw_bytes.is_empty() { + return "0x0".to_string(); + } + format!("0x{}", hex::encode(&self.raw_bytes)) + } +} + +/// Coinbase split value (v0 or v1) +#[derive(Debug, Clone)] +pub enum CoinbaseSplitValue { + /// V0 with raw bytes (legacy format) + V0(Vec), + /// V0 with count of entries (when we can't decode the sig keys) + V0Raw(usize), + /// V1 with hash-to-coins map + V1(Vec<(Hash, u64)>), +} + +/// Page message value +#[derive(Debug, Clone)] +pub struct PageMsgValue { + pub raw: Vec, +} + /// Block explorer cache that maintains indexes over the heaviest chain pub struct BlockExplorerCache { /// Blocks indexed by height (for pagination) @@ -44,20 +187,32 @@ pub struct BlockExplorerCache { /// Base58 string index for prefix lookups tx_b58_index: Arc>>, - /// Current maximum height + /// Current maximum height in cache max_height: Arc, + /// Current heaviest chain height (updated by refresh, read by metrics) + heaviest_height: Arc, + /// Last update timestamp last_updated: Arc>, + refresh_success: AtomicU64, + refresh_error: AtomicU64, + backfill_success: AtomicU64, + backfill_error: AtomicU64, metrics: Arc, seed_ready: Arc, backfill_resume: Arc>>, chunk_semaphore: Arc, + seed_start: Instant, + last_refresh: Arc>, + last_backfill: Arc>>, + get_blocks_latency: Arc, + get_block_details_latency: Arc, } impl BlockExplorerCache { - const RANGE_CHUNK: u64 = 8; + const RANGE_CHUNK: u64 = 1024; const INITIAL_SEED_RETRY_DELAY: Duration = Duration::from_secs(2); const INITIAL_SEED_MAX_WAIT: Duration = Duration::from_secs(120); const MIN_TX_PREFIX_LEN: usize = 8; @@ -69,11 +224,21 @@ impl BlockExplorerCache { tx_to_block: Arc::new(RwLock::new(HashMap::new())), tx_b58_index: Arc::new(RwLock::new(BTreeMap::new())), max_height: Arc::new(AtomicU64::new(0)), + heaviest_height: Arc::new(AtomicU64::new(0)), last_updated: Arc::new(RwLock::new(Instant::now())), + refresh_success: AtomicU64::new(0), + refresh_error: AtomicU64::new(0), + backfill_success: AtomicU64::new(0), + backfill_error: AtomicU64::new(0), metrics, seed_ready: Arc::new(AtomicBool::new(false)), backfill_resume: Arc::new(RwLock::new(None)), chunk_semaphore: Arc::new(Semaphore::new(1)), + seed_start: Instant::now(), + last_refresh: Arc::new(RwLock::new(Instant::now())), + last_backfill: Arc::new(RwLock::new(None)), + get_blocks_latency: Arc::new(LatencyTracker::new(512)), + get_block_details_latency: Arc::new(LatencyTracker::new(512)), } } @@ -116,11 +281,11 @@ impl BlockExplorerCache { fields(max_height = tracing::field::Empty) )] async fn initialize_inner(self: Arc, handle: Arc) -> GrpcResult { - // Get current height - let (max_height, _tip_block_id) = match self.peek_heaviest_chain(&handle).await { + // Get current height (try heaviest-chain first, fall back to heaviest-block) + let (max_height, _tip_block_id) = match self.get_heaviest_tip(&handle).await { Ok(val) => val, Err(NockAppGrpcError::PeekReturnedNoData) => { - debug!("Heaviest chain is empty; skipping cache initialization"); + debug!("Heaviest chain is empty (both derived and consensus state); skipping cache initialization"); // Ensure we expose an empty cache instead of bubbling an error { *self.blocks_by_height.write().await = BTreeMap::new(); @@ -129,10 +294,13 @@ impl BlockExplorerCache { *self.tx_b58_index.write().await = BTreeMap::new(); self.max_height.store(0, Ordering::Release); } + self.metrics.block_explorer_seed_ready.swap(0.0); return Ok(false); } Err(err) => return Err(err), }; + // Update cached heaviest height for metrics (avoids peek on every metrics call) + self.heaviest_height.store(max_height, Ordering::Release); tracing::Span::current().record("max_height", &tracing::field::display(max_height)); info!( max_height, @@ -153,18 +321,43 @@ impl BlockExplorerCache { } let first_start = max_height.saturating_sub(Self::RANGE_CHUNK - 1); + info!( + "Attempting to fetch blocks range {}..={}", + first_start, max_height + ); let first_chunk = match self .peek_blocks_range(&handle, first_start, max_height) .await { Ok(chunk) => chunk, Err(NockAppGrpcError::PeekReturnedNoData) => { - debug!( + warn!( "Heaviest chain range {}..={} returned no blocks; cache seed will retry", first_start, max_height ); return Ok(false); } + Err(NockAppGrpcError::NounDecode(decode_err)) => { + error!( + "FATAL: Block explorer cache failed to decode blocks in range {}..={}\n\ + \n\ + Decode error: {:?}\n\ + \n\ + This indicates the Page decoder cannot handle the data format returned by the kernel.\n\ + The Page decoder (nockchain-types/src/tx_engine/common/page.rs) must correctly decode:\n\ + - v0 pages: [digest pow parent tx-ids coinbase timestamp epoch-counter target accumulated-work height msg]\n\ + - v1 pages: [%1 digest pow parent tx-ids coinbase timestamp epoch-counter target accumulated-work height msg]\n\ + \n\ + Known format requirements:\n\ + - tx-ids: (z-set tx-id) - balanced tree, NOT a list\n\ + - target/accumulated-work: [%bn (list u32)] bignum format\n\ + - coinbase: v0=(z-map sig coins), v1=(z-map hash coins)\n\ + \n\ + Server must shut down to prevent silent data issues.", + first_start, max_height, decode_err + ); + return Err(NockAppGrpcError::NounDecode(decode_err)); + } Err(err) => return Err(err), }; let inserted = first_chunk.len(); @@ -185,8 +378,14 @@ impl BlockExplorerCache { let resume_height = first_start - 1; info!(resume_height, "Queueing block explorer cache backfill task"); *self.backfill_resume.write().await = Some(resume_height); + self.metrics + .block_explorer_backfill_resume_height + .swap(resume_height as f64); } else { *self.backfill_resume.write().await = None; + self.metrics + .block_explorer_backfill_resume_height + .swap(-1.0); } info!( @@ -195,6 +394,13 @@ impl BlockExplorerCache { "Block explorer cache initialization complete" ); self.seed_ready.store(true, Ordering::Release); + self.metrics.block_explorer_seed_ready.swap(1.0); + self.metrics + .block_explorer_seed_time_seconds + .swap(self.seed_start.elapsed().as_secs_f64()); + self.metrics + .block_explorer_cache_height + .swap(self.max_height.load(Ordering::Acquire) as f64); Ok(true) } @@ -218,14 +424,20 @@ impl BlockExplorerCache { fields(last_height = tracing::field::Empty, current_height = tracing::field::Empty) )] async fn refresh_inner(&self, handle: &Arc) -> GrpcResult<()> { - let (current_height, _) = match self.peek_heaviest_chain(handle).await { + // Get current height (try heaviest-chain first, fall back to heaviest-block) + let (current_height, _) = match self.get_heaviest_tip(handle).await { Ok(val) => val, Err(NockAppGrpcError::PeekReturnedNoData) => { - debug!("Heaviest chain is empty; skipping cache refresh"); + debug!("Heaviest chain is empty (both derived and consensus state); skipping cache refresh"); + self.metrics.block_explorer_refresh_error.increment(); + self.refresh_error.fetch_add(1, Ordering::Relaxed); return Ok(()); } Err(err) => return Err(err), }; + // Update cached heaviest height for metrics (avoids peek on every metrics call) + self.heaviest_height + .store(current_height, Ordering::Release); tracing::Span::current().record("current_height", &tracing::field::display(current_height)); let last_height = self.max_height.load(Ordering::Acquire); tracing::Span::current().record("last_height", &tracing::field::display(last_height)); @@ -235,6 +447,7 @@ impl BlockExplorerCache { "No new blocks to fetch (current: {}, cached: {})", current_height, last_height ); + self.metrics.block_explorer_refresh_success.increment(); return Ok(()); } @@ -248,36 +461,41 @@ impl BlockExplorerCache { end_height = current_height, "Refreshing block explorer cache with new blocks" ); - let new_blocks = match self - .peek_blocks_range_chunked(handle, last_height + 1, current_height) - .await - { - Ok(blocks) => blocks, - Err(NockAppGrpcError::PeekReturnedNoData) => { - debug!( - "No block data available yet for range {}-{}; deferring refresh", - last_height + 1, - current_height - ); - return Ok(()); + let mut inserted = 0usize; + let mut chunk_start = last_height + 1; + while chunk_start <= current_height { + let chunk_end = (chunk_start + Self::RANGE_CHUNK - 1).min(current_height); + match self.peek_blocks_range(handle, chunk_start, chunk_end).await { + Ok(blocks) => { + if !blocks.is_empty() { + inserted += blocks.len(); + self.insert_blocks(blocks).await; + } + } + Err(NockAppGrpcError::PeekReturnedNoData) => { + debug!( + "No block data available yet for range {}-{}; deferring refresh", + chunk_start, chunk_end + ); + self.metrics.block_explorer_refresh_error.increment(); + self.refresh_error.fetch_add(1, Ordering::Relaxed); + break; + } + Err(err) => return Err(err), } - Err(err) => return Err(err), - }; - if new_blocks.is_empty() { - debug!( - "Block explorer refresh range {}-{} returned no entries; waiting for data", - last_height + 1, - current_height - ); - return Ok(()); + + if chunk_end == u64::MAX { + break; + } + chunk_start = chunk_end.saturating_add(1); } - let count = new_blocks.len(); - self.insert_blocks(new_blocks).await; debug!( "Block explorer cache refreshed to height {}, inserted {} blocks", - current_height, count + current_height, inserted ); + self.metrics.block_explorer_refresh_success.increment(); + self.refresh_success.fetch_add(1, Ordering::Relaxed); Ok(()) } /// Get paginated blocks (descending by height) @@ -321,6 +539,170 @@ impl BlockExplorerCache { self.max_height.load(Ordering::Acquire) } + pub fn record_get_blocks_latency(&self, duration: Duration) { + self.get_blocks_latency.record(duration); + } + + pub fn record_get_block_details_latency(&self, duration: Duration) { + self.get_block_details_latency.record(duration); + } + + /// Get metrics snapshot. This is a fast path that only reads atomics and cached values. + /// The heaviest_height is updated by the refresh task, not fetched fresh here. + pub async fn metrics_snapshot(&self) -> ExplorerMetricsSnapshot { + let cache_height = self.max_height.load(Ordering::Acquire); + let cache_lowest_height = self + .blocks_by_height + .read() + .await + .first_key_value() + .map(|(h, _)| *h) + .unwrap_or(0); + let cache_span = cache_height.saturating_sub(cache_lowest_height); + let backfill_resume_height = *self.backfill_resume.read().await; + let cache_age_seconds = self.last_updated.read().await.elapsed().as_secs_f64(); + // Read cached heaviest height instead of doing a kernel peek + let heaviest_height = self.heaviest_height.load(Ordering::Acquire); + let refresh_age = self.last_refresh.read().await.elapsed().as_secs_f64(); + let backfill_age = self + .last_backfill + .read() + .await + .map(|t| t.elapsed().as_secs_f64()); + let coverage_ratio = if heaviest_height > 0 { + (cache_span as f64) / (heaviest_height as f64).max(1.0) + } else { + 0.0 + }; + let (get_blocks_p50_ms, get_blocks_p90_ms, get_blocks_p99_ms) = + self.get_blocks_latency.quantiles_ms(); + let (get_block_details_p50_ms, get_block_details_p90_ms, get_block_details_p99_ms) = + self.get_block_details_latency.quantiles_ms(); + ExplorerMetricsSnapshot { + cache_height, + heaviest_height, + cache_lowest_height, + cache_span, + cache_coverage_ratio: coverage_ratio, + seed_ready: self.seed_ready.load(Ordering::Acquire), + backfill_resume_height, + cache_age_seconds, + refresh_age_seconds: refresh_age, + backfill_age_seconds: backfill_age, + seed_time_seconds: self.seed_start.elapsed().as_secs_f64(), + refresh_success_count: self.refresh_success.load(Ordering::Relaxed), + refresh_error_count: self.refresh_error.load(Ordering::Relaxed), + backfill_success_count: self.backfill_success.load(Ordering::Relaxed), + backfill_error_count: self.backfill_error.load(Ordering::Relaxed), + get_blocks_p50_ms, + get_blocks_p90_ms, + get_blocks_p99_ms, + get_block_details_p50_ms, + get_block_details_p90_ms, + get_block_details_p99_ms, + } + } + + /// Load full page details by height + #[tracing::instrument( + name = "block_explorer_cache.load_full_page_by_height", + skip(self, handle), + fields(height = tracing::field::Empty) + )] + pub async fn load_full_page_by_height( + &self, + handle: &Arc, + height: u64, + ) -> GrpcResult { + tracing::Span::current().record("height", &tracing::field::display(height)); + self.peek_full_page(handle, height).await + } + + /// Load full page details by block ID + #[tracing::instrument( + name = "block_explorer_cache.load_full_page_by_id", + skip(self, handle), + fields(block_id = tracing::field::Empty) + )] + pub async fn load_full_page_by_id( + &self, + handle: &Arc, + block_id: &str, + ) -> GrpcResult { + tracing::Span::current().record("block_id", &tracing::field::display(block_id)); + + let hash = Hash::from_base58(block_id).map_err(|_| { + NockAppGrpcError::InvalidRequest(format!("Invalid block_id: {}", block_id)) + })?; + + let blocks = self.blocks_by_id.read().await; + let meta = blocks.get(&hash).ok_or(NockAppGrpcError::NotFound)?.clone(); + drop(blocks); + + self.peek_full_page(handle, meta.height).await + } + + /// Peek full page from kernel + #[tracing::instrument( + name = "block_explorer_cache.peek_full_page", + skip(self, handle), + fields(height = tracing::field::Empty) + )] + async fn peek_full_page( + &self, + handle: &Arc, + height: u64, + ) -> GrpcResult { + tracing::Span::current().record("height", &tracing::field::display(height)); + + let mut path_slab = NounSlab::new(); + let tag = nockapp::utils::make_tas(&mut path_slab, "heaviest-chain-blocks-range").as_noun(); + let start_noun = nockvm::noun::D(height); + let end_noun = nockvm::noun::D(height); + let path_noun = nockvm::noun::T(&mut path_slab, &[tag, start_noun, end_noun, SIG]); + path_slab.set_root(path_noun); + + let result = handle + .peek(path_slab) + .await + .map_err(NockAppGrpcError::from)? + .ok_or(NockAppGrpcError::PeekFailed)?; + + let result_noun = unsafe { result.root() }; + + tracing::debug!( + noun_is_atom = result_noun.is_atom(), + "peek_full_page raw result" + ); + + let opt: Option>> = NounDecode::from_noun(&result_noun) + .map_err(|e| { + tracing::error!("Failed to decode FullPageEntryNoun list: {:?}", e); + NockAppGrpcError::NounDecode(e) + })?; + let entries = opt.flatten().ok_or(NockAppGrpcError::PeekReturnedNoData)?; + + let parsed: Vec = entries + .into_iter() + .map(|entry| { + let entry_height = entry.height.0 .0; + FullPageDetails::try_from(entry).map_err(|e| { + tracing::error!( + height = entry_height, + error = %e, + "Failed to decode FullPageDetails" + ); + NockAppGrpcError::NounDecode(e) + }) + }) + .collect::>>()?; + + parsed + .into_iter() + .find(|p| p.height == height) + .ok_or(NockAppGrpcError::PeekReturnedNoData) + } + #[tracing::instrument( name = "block_explorer_cache.resolve_tx_id", skip(self), @@ -478,14 +860,102 @@ impl BlockExplorerCache { let result_noun = unsafe { result.root() }; // Decode Option> - let opt: Option> = - NounDecode::from_noun(&result_noun).map_err(NockAppGrpcError::NounDecode)?; + let opt: Option> = NounDecode::from_noun(&result_noun) + .map_err(|e| { + error!( + "Failed to decode heaviest-chain peek result.\n\ + Decode error: {:?}\n\ + Expected: Option>\n\ + Got noun is_atom={}, is_cell={}", + e, + result_noun.is_atom(), + result_noun.is_cell() + ); + NockAppGrpcError::NounDecode(e) + })?; + + debug!("peek_heaviest_chain decoded: outer={:?}", opt.is_some()); + if let Some(ref inner) = opt { + debug!("peek_heaviest_chain inner: {:?}", inner.is_some()); + } - let (height, hash) = opt.flatten().ok_or(NockAppGrpcError::PeekReturnedNoData)?; + let (height, hash) = opt.flatten().ok_or_else(|| { + debug!("peek_heaviest_chain returned None/empty"); + NockAppGrpcError::PeekReturnedNoData + })?; + self.metrics + .block_explorer_heaviest_height + .swap(height.0 .0 as f64); + + debug!("peek_heaviest_chain success: height={}", height.0 .0); Ok((height.0 .0, hash)) // Extract u64 from BlockHeight(Belt) } + /// Peek /heaviest-block ~ to get heaviest block's full page data. + /// This is a fallback for when %heaviest-chain returns no data because + /// derived state (highest-block-height) isn't populated, even though + /// consensus state (heaviest-block, blocks) has data. + #[tracing::instrument(name = "block_explorer_cache.peek_heaviest_block", skip(self, handle))] + async fn peek_heaviest_block( + &self, + handle: &Arc, + ) -> GrpcResult<(u64, Hash)> { + let mut path_slab = NounSlab::new(); + let tag = nockapp::utils::make_tas(&mut path_slab, "heaviest-block").as_noun(); + let path_noun = nockvm::noun::T(&mut path_slab, &[tag, SIG]); + path_slab.set_root(path_noun); + + let result = handle + .peek(path_slab) + .await + .map_err(NockAppGrpcError::from)? + .ok_or(NockAppGrpcError::PeekFailed)?; + + let result_noun = unsafe { result.root() }; + + // Decode Option> + let opt: Option> = NounDecode::from_noun(&result_noun).map_err(|e| { + error!( + "Failed to decode heaviest-block peek result.\n\ + Decode error: {:?}\n\ + Expected: Option>", + e + ); + NockAppGrpcError::NounDecode(e) + })?; + + debug!( + "peek_heaviest_block decoded: outer={:?}", + opt.as_ref().map(|o| o.is_some()) + ); + + let page = opt.flatten().ok_or_else(|| { + debug!("peek_heaviest_block returned None/empty"); + NockAppGrpcError::PeekReturnedNoData + })?; + + let height = page.height; + let hash = page.digest; + + debug!("peek_heaviest_block success: height={}", height); + Ok((height, hash)) + } + + /// Get the current heaviest chain tip, trying %heaviest-chain first, + /// then falling back to %heaviest-block if derived state isn't populated. + #[tracing::instrument(name = "block_explorer_cache.get_heaviest_tip", skip(self, handle))] + async fn get_heaviest_tip(&self, handle: &Arc) -> GrpcResult<(u64, Hash)> { + match self.peek_heaviest_chain(handle).await { + Ok(result) => Ok(result), + Err(NockAppGrpcError::PeekReturnedNoData) => { + debug!("peek_heaviest_chain returned no data, trying peek_heaviest_block fallback"); + self.peek_heaviest_block(handle).await + } + Err(e) => Err(e), + } + } + #[tracing::instrument( name = "block_explorer_cache.transaction_pending", skip(self, handle), @@ -621,6 +1091,8 @@ impl BlockExplorerCache { upper, Self::INITIAL_SEED_RETRY_DELAY ); + self.metrics.block_explorer_backfill_error.increment(); + self.backfill_error.fetch_add(1, Ordering::Relaxed); tokio::time::sleep(Self::INITIAL_SEED_RETRY_DELAY).await; continue; } @@ -655,11 +1127,21 @@ impl BlockExplorerCache { upper = start.saturating_sub(1); } + self.metrics.block_explorer_backfill_success.increment(); + self.backfill_success.fetch_add(1, Ordering::Relaxed); + *self.last_backfill.write().await = Some(Instant::now()); Ok(()) } pub async fn take_backfill_resume(&self) -> Option { - self.backfill_resume.write().await.take() + let mut guard = self.backfill_resume.write().await; + let val = guard.take(); + if val.is_none() { + self.metrics + .block_explorer_backfill_resume_height + .swap(-1.0); + } + val } #[tracing::instrument( @@ -709,6 +1191,35 @@ impl BlockExplorerCache { self.max_height.fetch_max(max_in_batch, Ordering::Release); *self.last_updated.write().await = Instant::now(); + let cache_height = self.max_height.load(Ordering::Acquire) as f64; + self.metrics.block_explorer_cache_height.swap(cache_height); + let lowest = self + .blocks_by_height + .read() + .await + .first_key_value() + .map(|(h, _)| *h) + .unwrap_or(0); + self.metrics + .block_explorer_cache_lowest_height + .swap(lowest as f64); + let span = cache_height - lowest as f64; + self.metrics.block_explorer_cache_span.swap(span); + let heaviest = self.metrics.block_explorer_heaviest_height.load(); + if heaviest > 0.0 { + self.metrics + .block_explorer_cache_coverage_ratio + .swap(span / heaviest.max(1.0)); + } + *self.last_refresh.write().await = Instant::now(); + let age_secs = self.last_updated.read().await.elapsed().as_secs_f64(); + self.metrics.block_explorer_cache_age_seconds.swap(age_secs); + self.metrics.block_explorer_refresh_age_seconds.swap(0.0); + if let Some(last) = *self.last_backfill.read().await { + self.metrics + .block_explorer_backfill_age_seconds + .swap(last.elapsed().as_secs_f64()); + } debug!( batch_size, highest_seen = max_in_batch, @@ -743,18 +1254,89 @@ impl BlockExplorerCache { let result_noun = unsafe { result.root() }; + // Debug: log raw noun structure + let is_atom = result_noun.is_atom(); + let is_cell = result_noun.is_cell(); + if is_atom { + if let Ok(atom) = result_noun.as_atom() { + let val = atom.as_u64().unwrap_or(u64::MAX); + info!( + is_atom, + atom_val = val, + "peek_blocks_range raw result is atom" + ); + } + } else if is_cell { + if let Ok(cell) = result_noun.as_cell() { + let head_is_atom = cell.head().is_atom(); + let tail_is_atom = cell.tail().is_atom(); + info!( + head_is_atom, + tail_is_atom, "peek_blocks_range raw result is cell" + ); + } + } + // Decode Option>> // We need to extract fields from the page and txs let opt: Option>> = - NounDecode::from_noun(&result_noun).map_err(NockAppGrpcError::NounDecode)?; + NounDecode::from_noun(&result_noun).map_err(|e| { + // Log detailed noun structure to help diagnose format issues + let noun_debug = if result_noun.is_atom() { + format!( + "atom (value: {:?})", + result_noun.as_atom().ok().and_then(|a| a.as_u64().ok()) + ) + } else if let Ok(cell) = result_noun.as_cell() { + let head_type = if cell.head().is_atom() { + "atom" + } else { + "cell" + }; + let tail_type = if cell.tail().is_atom() { + "atom" + } else { + "cell" + }; + format!("cell [head={}, tail={}]", head_type, tail_type) + } else { + "unknown".to_string() + }; + + error!( + "Failed to decode BlockRangeEntryNoun list.\n\ + Decode error: {:?}\n\ + Result noun structure: {}\n\ + This is likely a Page decoder issue - check tx_ids (z-set vs list), bignum, or coinbase format.", + e, noun_debug + ); + NockAppGrpcError::NounDecode(e) + })?; - let entries = opt.flatten().ok_or(NockAppGrpcError::PeekReturnedNoData)?; + let outer_some = opt.is_some(); + let inner_some = opt.as_ref().map(|v| v.is_some()).unwrap_or(false); + info!( + outer_some, + inner_some, "peek_blocks_range decoded outer options" + ); + + let entries = opt.flatten().ok_or_else(|| { + warn!("peek_blocks_range: opt.flatten() returned None"); + NockAppGrpcError::PeekReturnedNoData + })?; + info!("Decoded {} raw block entries", entries.len()); if entries.is_empty() { + warn!("peek_blocks_range: entries list is empty"); return Err(NockAppGrpcError::PeekReturnedNoData); } let entries: Vec = entries .into_iter() - .map(BlockRangeEntry::try_from) + .map(|entry| { + BlockRangeEntry::try_from(entry).map_err(|e| { + error!("Failed to convert BlockRangeEntryNoun: {:?}", e); + e + }) + }) .collect::, _>>() .map_err(NockAppGrpcError::NounDecode)?; @@ -797,25 +1379,10 @@ struct BlockRangeEntryTail { #[derive(Debug, Clone, NounDecode)] struct PageAndTxs { - page: PageNoun, + page: Page, txs: Noun, } -#[derive(Debug, Clone, NounDecode)] -struct PageNoun { - _digest: Hash, - _pow: Noun, - parent: Hash, - _tx_ids: Noun, - _coinbase: Noun, - timestamp: Noun, - _epoch_counter: Noun, - _target: Noun, - _accumulated_work: Noun, - _height: BlockHeight, - _msg: Noun, -} - impl TryFrom for BlockRangeEntry { type Error = NounDecodeError; @@ -825,7 +1392,7 @@ impl TryFrom for BlockRangeEntry { let PageAndTxs { page, txs } = tail; let parent_id = page.parent; - let timestamp = u64::from_noun(&page.timestamp)?; + let timestamp = page.timestamp; let tx_ids = extract_tx_ids_from_map(&txs)?; Ok(Self { @@ -850,20 +1417,30 @@ fn extract_tx_ids_from_map( } // Iterate over the z-map and collect keys (tx-ids) - let tx_ids: Vec = HoonMapIter::from(*txs_noun) - .filter(|entry| entry.is_cell()) - .filter_map(|entry| { - let [key, _value] = entry.uncell().ok()?; - Hash::from_noun(&key).ok() - }) - .collect(); + let mut tx_ids = Vec::new(); + for (idx, entry) in HoonMapIter::from(*txs_noun).enumerate() { + if !entry.is_cell() { + return Err(NounDecodeError::Custom(format!( + "extract_tx_ids_from_map: entry {} is not a cell (expected z-map [key value] pair)", + idx + ))); + } + let [key, _value] = entry.uncell().map_err(|_| NounDecodeError::ExpectedCell)?; + let hash = Hash::from_noun(&key).map_err(|e| { + NounDecodeError::Custom(format!( + "extract_tx_ids_from_map: failed to decode tx_id at entry {}: {}", + idx, e + )) + })?; + tx_ids.push(hash); + } Ok(tx_ids) } struct BlockEntryWithTxs { metadata: BlockMetadata, - txs: Vec<(Hash, TxV0)>, + txs: Vec<(Hash, DecodedTx)>, } impl TryFrom for BlockEntryWithTxs { @@ -875,7 +1452,7 @@ impl TryFrom for BlockEntryWithTxs { let PageAndTxs { page, txs } = tail; let parent_id = page.parent; - let timestamp = u64::from_noun(&page.timestamp)?; + let timestamp = page.timestamp; let txs_full = extract_transactions_from_map(&txs)?; let tx_ids = txs_full.iter().map(|(hash, _)| hash.clone()).collect(); @@ -894,7 +1471,7 @@ impl TryFrom for BlockEntryWithTxs { fn extract_transactions_from_map( txs_noun: &Noun, -) -> std::result::Result, noun_serde::NounDecodeError> { +) -> std::result::Result, noun_serde::NounDecodeError> { if let Ok(atom) = txs_noun.as_atom() { if atom.as_u64()? == 0 { return Ok(Vec::new()); @@ -902,73 +1479,373 @@ fn extract_transactions_from_map( } let mut txs = Vec::new(); - for entry in HoonMapIter::from(*txs_noun) { + for (idx, entry) in HoonMapIter::from(*txs_noun).enumerate() { if !entry.is_cell() { - continue; + return Err(NounDecodeError::Custom(format!( + "extract_transactions_from_map: entry {} is not a cell (expected z-map [key value] pair)", + idx + ))); } let [key, value] = entry.uncell().map_err(|_| NounDecodeError::ExpectedCell)?; - let hash = Hash::from_noun(&key)?; - let tx = TxV0::from_noun(&value)?; + let hash = Hash::from_noun(&key).map_err(|e| { + NounDecodeError::Custom(format!( + "extract_transactions_from_map: failed to decode tx hash at entry {}: {}", + idx, e + )) + })?; + let tx = DecodedTx::from_noun(&value).map_err(|e| { + NounDecodeError::Custom(format!( + "extract_transactions_from_map: failed to decode tx at entry {} (hash={}): {}", + idx, + hash.to_base58(), + e + )) + })?; txs.push((hash, tx)); } Ok(txs) } +/// Transaction data decoded from kernel, supporting both v0 and v1 formats #[derive(Debug, Clone)] -struct TxV0 { - version: u64, +enum DecodedTx { + V0(TxV0Data), + V1(TxV1Data), +} + +#[derive(Debug, Clone)] +struct TxV0Data { raw_tx: RawTx, total_size: u64, - outputs: Vec, + outputs: Vec, +} + +#[derive(Debug, Clone)] +struct TxV1Data { + total_size: u64, + total_fee: u64, + /// Simplified input info: (name_hash, assets) + inputs: Vec, + /// Simplified output info: (lock_hash, assets) + outputs: Vec, } #[derive(Debug, Clone)] -struct TxOutput { +struct TxV1Input { + name_first: Hash, + // We don't have access to input amounts in v1 spends directly + // The amount comes from the note being spent, which we don't have here +} + +#[derive(Debug, Clone)] +struct TxV1Output { + lock_root: Hash, + assets: u64, +} + +#[derive(Debug, Clone)] +struct TxOutputV0 { lock: Lock, note: NoteV0, } -impl NounDecode for TxV0 { +impl NounDecode for DecodedTx { fn from_noun(noun: &Noun) -> Result { let cell = noun.as_cell()?; let version = u64::from_noun(&cell.head())?; - let tail = cell.tail(); - let cell = tail.as_cell()?; - let raw_tx = RawTx::from_noun(&cell.head())?; + match version { + 0 => { + // v0 tx: [%0 raw-tx:v0 total-size outputs:v0] + let tail = cell.tail(); + let cell = tail.as_cell()?; + let raw_tx = RawTx::from_noun(&cell.head())?; + + let tail = cell.tail(); + let cell = tail.as_cell()?; + let total_size = u64::from_noun(&cell.head())?; + let outputs = decode_outputs_v0(&cell.tail())?; + + Ok(DecodedTx::V0(TxV0Data { + raw_tx, + total_size, + outputs, + })) + } + 1 => { + // v1 tx: [%1 raw-tx:v1 total-size outputs:v1] + // raw-tx:v1 = [%1 id spends] + // outputs:v1 = (z-set output:v1) where output:v1 = [note seeds] + let tail = cell.tail(); + let cell = tail.as_cell().map_err(|e| { + NounDecodeError::Custom(format!("v1 tx outer tail not cell: {e:?}")) + })?; + let raw_tx_noun = cell.head(); + + // Parse raw-tx:v1 = [%1 id spends] + let raw_cell = raw_tx_noun + .as_cell() + .map_err(|e| NounDecodeError::Custom(format!("v1 raw-tx not cell: {e:?}")))?; + let raw_version = u64::from_noun(&raw_cell.head()).map_err(|e| { + NounDecodeError::Custom(format!("v1 raw-tx version not atom: {e:?}")) + })?; + if raw_version != 1 { + return Err(NounDecodeError::Custom(format!( + "Expected v1 raw-tx version 1, got {}", + raw_version + ))); + } + let raw_tail = raw_cell.tail().as_cell().map_err(|e| { + NounDecodeError::Custom(format!("v1 raw-tx tail not cell: {e:?}")) + })?; + let _tx_id = Hash::from_noun(&raw_tail.head()).map_err(|e| { + NounDecodeError::Custom(format!("v1 raw-tx id parse failed: {e:?}")) + })?; + let spends_noun = raw_tail.tail(); + + // Parse spends: (z-map nname spend) - returns (inputs, total_fee) + let (inputs, total_fee) = decode_v1_spends(&spends_noun).map_err(|e| { + NounDecodeError::Custom(format!("v1 spends parse failed: {e:?}")) + })?; + + let tail = cell.tail(); + let cell = tail.as_cell().map_err(|e| { + NounDecodeError::Custom(format!("v1 tx size/outputs tail not cell: {e:?}")) + })?; + let total_size = u64::from_noun(&cell.head()).map_err(|e| { + NounDecodeError::Custom(format!("v1 tx total_size not atom: {e:?}")) + })?; + let outputs = decode_outputs_v1(&cell.tail()).map_err(|e| { + NounDecodeError::Custom(format!("v1 outputs parse failed: {e:?}")) + })?; + + Ok(DecodedTx::V1(TxV1Data { + total_size, + total_fee, + inputs, + outputs, + })) + } + _ => Err(NounDecodeError::Custom(format!( + "Unknown tx version: {}", + version + ))), + } + } +} - let tail = cell.tail(); - let cell = tail.as_cell()?; - let total_size = u64::from_noun(&cell.head())?; - let outputs = decode_outputs(&cell.tail())?; +/// Returns (inputs, total_fee) +fn decode_v1_spends(noun: &Noun) -> Result<(Vec, u64), NounDecodeError> { + if let Ok(atom) = noun.as_atom() { + if atom.as_u64()? == 0 { + return Ok((Vec::new(), 0)); + } + } - Ok(Self { - version, - raw_tx, - total_size, - outputs, - }) + let mut inputs = Vec::new(); + let mut total_fee = 0u64; + for (idx, entry) in HoonMapIter::from(*noun).enumerate() { + if !entry.is_cell() { + return Err(NounDecodeError::Custom(format!( + "decode_v1_spends: entry {} is not a cell (expected z-map [nname spend] pair)", + idx + ))); + } + let [key, value] = entry.uncell().map_err(|_| NounDecodeError::ExpectedCell)?; + // key is nname (Name type) + let name = Name::from_noun(&key).map_err(|e| { + NounDecodeError::Custom(format!( + "decode_v1_spends: failed to decode name at entry {}: {}", + idx, e + )) + })?; + inputs.push(TxV1Input { + name_first: name.first, + }); + + // Extract fee from spend: spend is [tag [sig/witness [seeds fee]]] + // Navigate: value.tail().tail().tail() to get fee + if let Ok(spend_cell) = value.as_cell() { + // spend_cell is [tag spend-data] + if let Ok(spend_data) = spend_cell.tail().as_cell() { + // spend_data is [sig/witness [seeds fee]] + if let Ok(seeds_fee) = spend_data.tail().as_cell() { + // seeds_fee is [seeds fee] + let fee_noun = seeds_fee.tail(); + if let Ok(fee_atom) = fee_noun.as_atom() { + if let Ok(fee) = fee_atom.as_u64() { + total_fee += fee; + } + } + } + } + } } + + Ok((inputs, total_fee)) } -fn decode_outputs(noun: &Noun) -> Result, NounDecodeError> { - if let Ok(atom) = noun.as_atom() { +fn decode_outputs_v1(noun: &Noun) -> Result, NounDecodeError> { + // outputs:v1 can be either: + // 1. Tagged form from polymorphic outputs: [%1 (z-set output)] + // 2. Untagged form from tx:v1: (z-set output) + // We need to handle both cases. + + let zset_noun = if let Ok(cell) = noun.as_cell() { + // Check if head is the %1 tag (an atom) + if let Ok(atom) = cell.head().as_atom() { + if let Ok(tag) = atom.as_u64() { + if tag == 1 { + // Tagged form: [%1 z-set] - strip the tag + cell.tail() + } else { + // Head is an atom but not %1 - this is the z-set itself + // (z-set node structure: [n l r] where n could be atom for empty) + *noun + } + } else { + // Large atom - treat as z-set + *noun + } + } else { + // Head is a cell - this is a z-set node directly: [n=[output] l r] + *noun + } + } else { + // It's an atom (empty z-set = 0) + *noun + }; + + // Check for empty z-set + if let Ok(atom) = zset_noun.as_atom() { if atom.as_u64()? == 0 { return Ok(Vec::new()); } } let mut outputs = Vec::new(); - for entry in HoonMapIter::from(*noun) { + // z-set structure: [n=value l=z-set r=z-set] + // HoonMapIter returns n (the value) directly for each node + for (idx, entry) in HoonMapIter::from(zset_noun).enumerate() { if !entry.is_cell() { + return Err(NounDecodeError::Custom(format!( + "decode_outputs_v1: entry {} is not a cell (expected z-set output entry)", + idx + ))); + } + // output:v1 = [note seeds] where note is nnote:v1 + // (from tx-engine-1.hoon line 969: +$ form [note=nnote =seeds]) + // The entry IS the output directly (not a [value ~] pair) + let output_cell = entry.as_cell()?; + let note_noun = output_cell.head(); // note (nnote) + let _seeds = output_cell.tail(); // seeds - we don't parse fully + + // nnote is polymorphic: $^(nnote:v0 nnote-1) + // - nnote:v0: head is a cell (the old v0 note structure) + // - nnote-1 (v1): head is atom %1, then [origin-page name note-data assets] + let note_cell = note_noun.as_cell().map_err(|e| { + NounDecodeError::Custom(format!( + "decode_outputs_v1: output {} note not cell: {e:?}", + idx + )) + })?; + let note_head = note_cell.head(); + + // If head is a cell, this is a v0 note - skip it (polymorphic handling) + if note_head.is_cell() { continue; } + + let note_version = u64::from_noun(¬e_head).map_err(|e| { + NounDecodeError::Custom(format!( + "decode_outputs_v1: output {} note version not atom: {e:?}", + idx + )) + })?; + if note_version != 1 { + // Skip non-v1 notes (polymorphic handling - shouldn't happen but possible) + continue; + } + let note_tail = note_cell.tail().as_cell().map_err(|e| { + NounDecodeError::Custom(format!( + "decode_outputs_v1: output {} note tail (origin-page+) not cell: {e:?}", + idx + )) + })?; + let _origin_page = u64::from_noun(¬e_tail.head()).map_err(|e| { + NounDecodeError::Custom(format!( + "decode_outputs_v1: output {} origin-page not atom: {e:?}", + idx + )) + })?; + let note_tail = note_tail.tail().as_cell().map_err(|e| { + NounDecodeError::Custom(format!( + "decode_outputs_v1: output {} note tail (name+) not cell: {e:?}", + idx + )) + })?; + let name = Name::from_noun(¬e_tail.head()).map_err(|e| { + NounDecodeError::Custom(format!( + "decode_outputs_v1: output {} name parse failed: {e:?}", + idx + )) + })?; + let note_tail = note_tail.tail().as_cell().map_err(|e| { + NounDecodeError::Custom(format!( + "decode_outputs_v1: output {} note tail (note-data+) not cell: {e:?}", + idx + )) + })?; + let _note_data = note_tail.head(); // z-map @tas * - skip + let assets = u64::from_noun(¬e_tail.tail()).map_err(|e| { + NounDecodeError::Custom(format!( + "decode_outputs_v1: output {} assets not atom: {e:?}", + idx + )) + })?; + + outputs.push(TxV1Output { + lock_root: name.first, // For v1, name.first is the lock root hash + assets, + }); + } + + Ok(outputs) +} + +fn decode_outputs_v0(noun: &Noun) -> Result, NounDecodeError> { + if let Ok(atom) = noun.as_atom() { + if atom.as_u64()? == 0 { + return Ok(Vec::new()); + } + } + + let mut outputs = Vec::new(); + for (idx, entry) in HoonMapIter::from(*noun).enumerate() { + if !entry.is_cell() { + return Err(NounDecodeError::Custom(format!( + "decode_outputs_v0: entry {} is not a cell (expected z-map [lock note] pair)", + idx + ))); + } let [key, value] = entry.uncell().map_err(|_| NounDecodeError::ExpectedCell)?; - let lock = Lock::from_noun(&key)?; - let value_cell = value.as_cell().map_err(|_| NounDecodeError::ExpectedCell)?; - let note = NoteV0::from_noun(&value_cell.head())?; - outputs.push(TxOutput { lock, note }); + let lock = Lock::from_noun(&key).map_err(|e| { + NounDecodeError::Custom(format!( + "decode_outputs_v0: output {} lock parse failed: {}", + idx, e + )) + })?; + let value_cell = value.as_cell().map_err(|_| { + NounDecodeError::Custom(format!("decode_outputs_v0: output {} value not cell", idx)) + })?; + let note = NoteV0::from_noun(&value_cell.head()).map_err(|e| { + NounDecodeError::Custom(format!( + "decode_outputs_v0: output {} note parse failed: {}", + idx, e + )) + })?; + outputs.push(TxOutputV0 { lock, note }); } Ok(outputs) @@ -977,10 +1854,20 @@ fn decode_outputs(noun: &Noun) -> Result, NounDecodeError> { fn build_transaction_details( metadata: &BlockMetadata, tx_hash: &Hash, - tx: TxV0, + tx: DecodedTx, +) -> TransactionDetails { + match tx { + DecodedTx::V0(tx_data) => build_transaction_details_v0(metadata, tx_hash, tx_data), + DecodedTx::V1(tx_data) => build_transaction_details_v1(metadata, tx_hash, tx_data), + } +} + +fn build_transaction_details_v0( + metadata: &BlockMetadata, + tx_hash: &Hash, + tx: TxV0Data, ) -> TransactionDetails { - let TxV0 { - version, + let TxV0Data { raw_tx, total_size, outputs, @@ -992,7 +1879,7 @@ fn build_transaction_details( let amount = input.note.tail.assets.0 as u64; total_input += amount; inputs.push(TransactionInput { - note_name_b58: note_name_to_b58(&name), + note_name_b58: note_name_to_b58(name), amount: Some(pb_common::Nicks { value: amount }), source_tx_id: input.note.tail.source.hash.to_base58(), coinbase: input.note.tail.source.is_coinbase, @@ -1006,7 +1893,9 @@ fn build_transaction_details( total_output += amount; outputs_proto.push(TransactionOutput { note_name_b58: note_name_to_b58(&output.note.tail.name), - amount: Some(pb_common::Nicks { value: amount }), + amount_required: Some(transaction_output::AmountRequired::Amount( + pb_common::Nicks { value: amount }, + )), lock_summary: lock_summary(&output.lock), }); } @@ -1017,15 +1906,78 @@ fn build_transaction_details( parent: Some(hash_to_proto(&metadata.parent_id)), height: metadata.height, timestamp: metadata.timestamp, - version, + version: 0, size_bytes: total_size, total_input: Some(pb_common::Nicks { value: total_input }), - total_output: Some(pb_common::Nicks { - value: total_output, - }), - fee: Some(pb_common::Nicks { + total_output_required: Some(transaction_details::TotalOutputRequired::TotalOutput( + pb_common::Nicks { + value: total_output, + }, + )), + fee_required: Some(transaction_details::FeeRequired::Fee(pb_common::Nicks { value: raw_tx.total_fees.0 as u64, - }), + })), + inputs, + outputs: outputs_proto, + } +} + +fn build_transaction_details_v1( + metadata: &BlockMetadata, + tx_hash: &Hash, + tx: TxV1Data, +) -> TransactionDetails { + let TxV1Data { + total_size, + total_fee, + inputs: tx_inputs, + outputs: tx_outputs, + } = tx; + + // For v1 inputs, we don't have direct access to input amounts + // (those come from the notes being spent, which aren't in the tx data) + let mut inputs = Vec::new(); + for input in tx_inputs { + inputs.push(TransactionInput { + note_name_b58: input.name_first.to_base58(), + amount: None, // Amount not available in v1 spend data + source_tx_id: String::new(), // Not directly available + coinbase: false, // Would need to check the note being spent + }); + } + + let mut total_output = 0u64; + let mut outputs_proto = Vec::new(); + for output in tx_outputs { + total_output += output.assets; + outputs_proto.push(TransactionOutput { + note_name_b58: output.lock_root.to_base58(), + amount_required: Some(transaction_output::AmountRequired::Amount( + pb_common::Nicks { + value: output.assets, + }, + )), + lock_summary: format!("lock:{}", &output.lock_root.to_base58()[..8]), + }); + } + + TransactionDetails { + tx_id: tx_hash.to_base58(), + block_id: Some(hash_to_proto(&metadata.block_id)), + parent: Some(hash_to_proto(&metadata.parent_id)), + height: metadata.height, + timestamp: metadata.timestamp, + version: 1, + size_bytes: total_size, + total_input: None, // Not directly available for v1 + total_output_required: Some(transaction_details::TotalOutputRequired::TotalOutput( + pb_common::Nicks { + value: total_output, + }, + )), + fee_required: Some(transaction_details::FeeRequired::Fee(pb_common::Nicks { + value: total_fee, + })), inputs, outputs: outputs_proto, } @@ -1063,6 +2015,550 @@ fn lock_summary(lock: &Lock) -> String { } } +// ============================================================================ +// Full Page Details - Noun Decoding Types +// ============================================================================ + +/// Noun decoder for full page entry +#[derive(Debug, Clone, NounDecode)] +struct FullPageEntryNoun { + height: BlockHeight, + tail: FullPageEntryTail, +} + +#[derive(Debug, Clone, NounDecode)] +struct FullPageEntryTail { + block_id: Hash, + tail: FullPageData, +} + +#[derive(Debug, Clone, NounDecode)] +struct FullPageData { + page: FullPageNoun, + txs: Noun, +} + +/// Full page structure matching Hoon's page type +#[derive(Debug, Clone)] +struct FullPageNoun { + /// For v1: version tag (%1), for v0: this is actually digest + version_or_digest: Noun, + /// Remaining page fields + rest: Noun, +} + +impl NounDecode for FullPageNoun { + fn from_noun(noun: &Noun) -> Result { + let cell = noun.as_cell()?; + Ok(Self { + version_or_digest: cell.head(), + rest: cell.tail(), + }) + } +} + +impl TryFrom for FullPageDetails { + type Error = NounDecodeError; + + fn try_from(raw: FullPageEntryNoun) -> Result { + let height = raw.height.0 .0; + let block_id = raw.tail.block_id; + let page = raw.tail.tail.page; + let txs_noun = raw.tail.tail.txs; + + // Determine if v0 or v1 page: + // - v0 page: [digest pow parent ...] where digest is a Hash (cell of 5 Belts) + // - v1 page: [%1 digest pow parent ...] where %1 is an atom + // So if head is an atom, it's v1; if head is a cell (Hash), it's v0 + let head_is_atom = page.version_or_digest.is_atom(); + let head_is_cell = page.version_or_digest.is_cell(); + let head_as_u64 = page + .version_or_digest + .as_atom() + .ok() + .and_then(|a| a.as_u64().ok()); + let head_as_bytes = page.version_or_digest.as_atom().ok().map(|a| { + let bytes = a.as_ne_bytes(); + format!("{:?} (len={})", bytes, bytes.len()) + }); + tracing::info!( + height, + head_is_atom, + head_is_cell, + head_as_u64 = ?head_as_u64, + head_as_bytes = ?head_as_bytes, + "Detecting page version from noun head" + ); + + if head_is_atom { + // v1 page - head is the version tag + let version = page + .version_or_digest + .as_atom() + .map_err(|_| NounDecodeError::Custom("Expected atom for version".into()))? + .as_u64() + .map_err(|_| NounDecodeError::Custom("Version too large".into()))?; + + if version != 1 { + return Err(NounDecodeError::Custom(format!( + "Unknown page version: {}", + version + ))); + } + decode_v1_page(height, block_id, page.rest, txs_noun) + } else { + // v0 page - head is the digest + decode_v0_page( + height, block_id, page.version_or_digest, page.rest, txs_noun, + ) + } + } +} + +fn decode_v0_page( + height: u64, + block_id: Hash, + digest_noun: Noun, + rest: Noun, + txs_noun: Noun, +) -> Result { + // v0 page: [digest pow parent tx-ids coinbase timestamp epoch-counter target accumulated-work height msg] + let _digest = Hash::from_noun(&digest_noun) + .map_err(|e| NounDecodeError::Custom(format!("v0 page digest: {}", e)))?; + + let cell = rest + .as_cell() + .map_err(|_| NounDecodeError::Custom("v0 page: rest should be cell after digest".into()))?; + let (pow_present, pow_raw) = decode_pow(&cell.head()) + .map_err(|e| NounDecodeError::Custom(format!("v0 page pow: {}", e)))?; + + let cell = cell + .tail() + .as_cell() + .map_err(|_| NounDecodeError::Custom("v0 page: expected cell after pow".into()))?; + let parent = Hash::from_noun(&cell.head()) + .map_err(|e| NounDecodeError::Custom(format!("v0 page parent: {}", e)))?; + + let cell = cell + .tail() + .as_cell() + .map_err(|_| NounDecodeError::Custom("v0 page: expected cell after parent".into()))?; + let _page_tx_ids = &cell.head(); + + let cell = cell + .tail() + .as_cell() + .map_err(|_| NounDecodeError::Custom("v0 page: expected cell after tx-ids".into()))?; + let coinbase = decode_coinbase(&cell.head()) + .map_err(|e| NounDecodeError::Custom(format!("v0 page coinbase: {}", e)))?; + + let cell = cell + .tail() + .as_cell() + .map_err(|_| NounDecodeError::Custom("v0 page: expected cell after coinbase".into()))?; + let timestamp = cell + .head() + .as_atom() + .map_err(|_| NounDecodeError::Custom("v0 page timestamp: expected atom".into()))? + .as_u64() + .map_err(|_| NounDecodeError::Custom("v0 page timestamp: too large".into()))?; + + let cell = cell + .tail() + .as_cell() + .map_err(|_| NounDecodeError::Custom("v0 page: expected cell after timestamp".into()))?; + let epoch_counter = cell + .head() + .as_atom() + .map_err(|_| NounDecodeError::Custom("v0 page epoch_counter: expected atom".into()))? + .as_u64() + .map_err(|_| NounDecodeError::Custom("v0 page epoch_counter: too large".into()))?; + + let cell = cell.tail().as_cell().map_err(|_| { + NounDecodeError::Custom("v0 page: expected cell after epoch_counter".into()) + })?; + let target = decode_bignum(&cell.head()) + .map_err(|e| NounDecodeError::Custom(format!("v0 page target: {}", e)))?; + + let cell = cell + .tail() + .as_cell() + .map_err(|_| NounDecodeError::Custom("v0 page: expected cell after target".into()))?; + let accumulated_work = decode_bignum(&cell.head()) + .map_err(|e| NounDecodeError::Custom(format!("v0 page accumulated_work: {}", e)))?; + + let cell = cell.tail().as_cell().map_err(|_| { + NounDecodeError::Custom("v0 page: expected cell after accumulated_work".into()) + })?; + let _page_height = cell + .head() + .as_atom() + .map_err(|_| NounDecodeError::Custom("v0 page height: expected atom".into()))? + .as_u64() + .map_err(|_| NounDecodeError::Custom("v0 page height: too large".into()))?; + + let msg = decode_page_msg(&cell.tail()) + .map_err(|e| NounDecodeError::Custom(format!("v0 page msg: {}", e)))?; + let tx_ids = extract_tx_ids_from_map(&txs_noun) + .map_err(|e| NounDecodeError::Custom(format!("v0 page tx_ids: {}", e)))?; + + Ok(FullPageDetails { + height, + block_id, + parent_id: parent, + version: 0, + pow_present, + pow_raw, + timestamp, + epoch_counter, + target, + accumulated_work, + tx_ids, + coinbase, + msg, + }) +} + +fn decode_v1_page( + height: u64, + block_id: Hash, + rest: Noun, + txs_noun: Noun, +) -> Result { + // v1 page (after version tag): [digest pow parent tx-ids coinbase timestamp epoch-counter target accumulated-work height msg] + let cell = rest + .as_cell() + .map_err(|_| NounDecodeError::Custom("v1 page: rest should be cell".into()))?; + let _digest = Hash::from_noun(&cell.head()) + .map_err(|e| NounDecodeError::Custom(format!("v1 page digest: {}", e)))?; + + let cell = cell + .tail() + .as_cell() + .map_err(|_| NounDecodeError::Custom("v1 page: expected cell after digest".into()))?; + let (pow_present, pow_raw) = decode_pow(&cell.head()) + .map_err(|e| NounDecodeError::Custom(format!("v1 page pow: {}", e)))?; + + let cell = cell + .tail() + .as_cell() + .map_err(|_| NounDecodeError::Custom("v1 page: expected cell after pow".into()))?; + let parent = Hash::from_noun(&cell.head()) + .map_err(|e| NounDecodeError::Custom(format!("v1 page parent: {}", e)))?; + + let cell = cell + .tail() + .as_cell() + .map_err(|_| NounDecodeError::Custom("v1 page: expected cell after parent".into()))?; + let _page_tx_ids = &cell.head(); + + let cell = cell + .tail() + .as_cell() + .map_err(|_| NounDecodeError::Custom("v1 page: expected cell after tx-ids".into()))?; + let coinbase = decode_coinbase(&cell.head()) + .map_err(|e| NounDecodeError::Custom(format!("v1 page coinbase: {}", e)))?; + + let cell = cell + .tail() + .as_cell() + .map_err(|_| NounDecodeError::Custom("v1 page: expected cell after coinbase".into()))?; + let timestamp = cell + .head() + .as_atom() + .map_err(|_| NounDecodeError::Custom("v1 page timestamp: expected atom".into()))? + .as_u64() + .map_err(|_| NounDecodeError::Custom("v1 page timestamp: too large".into()))?; + + let cell = cell + .tail() + .as_cell() + .map_err(|_| NounDecodeError::Custom("v1 page: expected cell after timestamp".into()))?; + let epoch_counter = cell + .head() + .as_atom() + .map_err(|_| NounDecodeError::Custom("v1 page epoch_counter: expected atom".into()))? + .as_u64() + .map_err(|_| NounDecodeError::Custom("v1 page epoch_counter: too large".into()))?; + + let cell = cell.tail().as_cell().map_err(|_| { + NounDecodeError::Custom("v1 page: expected cell after epoch_counter".into()) + })?; + let target = decode_bignum(&cell.head()) + .map_err(|e| NounDecodeError::Custom(format!("v1 page target: {}", e)))?; + + let cell = cell + .tail() + .as_cell() + .map_err(|_| NounDecodeError::Custom("v1 page: expected cell after target".into()))?; + let accumulated_work = decode_bignum(&cell.head()) + .map_err(|e| NounDecodeError::Custom(format!("v1 page accumulated_work: {}", e)))?; + + let cell = cell.tail().as_cell().map_err(|_| { + NounDecodeError::Custom("v1 page: expected cell after accumulated_work".into()) + })?; + let _page_height = cell + .head() + .as_atom() + .map_err(|_| NounDecodeError::Custom("v1 page height: expected atom".into()))? + .as_u64() + .map_err(|_| NounDecodeError::Custom("v1 page height: too large".into()))?; + + let msg = decode_page_msg(&cell.tail()) + .map_err(|e| NounDecodeError::Custom(format!("v1 page msg: {}", e)))?; + let tx_ids = extract_tx_ids_from_map(&txs_noun) + .map_err(|e| NounDecodeError::Custom(format!("v1 page tx_ids: {}", e)))?; + + Ok(FullPageDetails { + height, + block_id, + parent_id: parent, + version: 1, + pow_present, + pow_raw, + timestamp, + epoch_counter, + target, + accumulated_work, + tx_ids, + coinbase, + msg, + }) +} + +fn decode_pow(noun: &Noun) -> Result<(bool, Option>), NounDecodeError> { + if noun.is_atom() { + let atom = noun.as_atom()?; + if atom.as_u64()? == 0 { + return Ok((false, None)); + } + Ok((true, Some(atom.as_ne_bytes().to_vec()))) + } else { + // [~ proof] - proof present + Ok((true, Some(vec![]))) + } +} + +fn decode_bignum(noun: &Noun) -> Result { + // Bignum can be [%bn list-of-u32] or just a raw atom + if let Ok(cell) = noun.as_cell() { + if let Ok(tag) = cell.head().as_atom() { + let tag_val = tag.as_u64().unwrap_or(u64::MAX); + // %bn = 28258 ('bn' as cord) + if tag_val == 28258 { + let mut chunks: Vec = Vec::new(); + let mut current = cell.tail(); + while let Ok(list_cell) = current.as_cell() { + let chunk = list_cell.head().as_atom()?.as_u64()? as u32; + chunks.push(chunk); + current = list_cell.tail(); + } + // Reconstruct bytes from u32 chunks (LSB first) + let mut bytes = Vec::new(); + for chunk in chunks { + bytes.extend_from_slice(&chunk.to_le_bytes()); + } + // Trim trailing zeros + while bytes.last() == Some(&0) && bytes.len() > 1 { + bytes.pop(); + } + return Ok(BigNumValue { raw_bytes: bytes }); + } + } + } + // Fallback: raw atom + let atom = noun.as_atom()?; + let bytes = atom.as_ne_bytes().to_vec(); + Ok(BigNumValue { raw_bytes: bytes }) +} + +fn decode_coinbase(noun: &Noun) -> Result { + // Coinbase in pages can be: + // - Atom 0: empty map (genesis or no coinbase) + // - v0 raw format: (z-map sig coins) where sig is a complex structure + // - v1 tagged format: [%0 ...] or [%1 ...] + // - v1 raw format: (z-map hash coins) where hash is a Hash + + // Atom 0 = empty map + if let Ok(atom) = noun.as_atom() { + if atom.as_u64()? == 0 { + return Ok(CoinbaseSplitValue::V0(vec![])); + } + // Non-zero atom - shouldn't happen but handle gracefully + return Ok(CoinbaseSplitValue::V0(atom.as_ne_bytes().to_vec())); + } + + // Cell - could be tagged or raw map + let cell = noun.as_cell()?; + + // Check for tag + if let Ok(tag) = cell.head().as_atom() { + let tag_val = tag.as_u64().unwrap_or(u64::MAX); + if tag_val == 0 { + // V0 tagged: [%0 data] + return Ok(CoinbaseSplitValue::V0(vec![])); + } + if tag_val == 1 { + // V1 tagged: [%1 z-map] + let entries = decode_coinbase_v1_map(&cell.tail())?; + return Ok(CoinbaseSplitValue::V1(entries)); + } + } + + // Untagged map - try v1 first (hash keys), fall back to v0 (sig keys) + match decode_coinbase_v1_map(noun) { + Ok(entries) => Ok(CoinbaseSplitValue::V1(entries)), + Err(_) => { + // v0 coinbase: (z-map sig coins) - just note it exists + // We can't decode sigs as hashes, so just mark as v0 with raw bytes + Ok(CoinbaseSplitValue::V0Raw(count_map_entries(noun))) + } + } +} + +fn decode_coinbase_v1_map(noun: &Noun) -> Result, NounDecodeError> { + if let Ok(atom) = noun.as_atom() { + if atom.as_u64()? == 0 { + return Ok(Vec::new()); + } + } + + let mut entries = Vec::new(); + for entry in HoonMapIter::from(*noun) { + if !entry.is_cell() { + continue; + } + let [key, value] = entry.uncell().map_err(|_| NounDecodeError::ExpectedCell)?; + let hash = Hash::from_noun(&key)?; + let coins = value.as_atom()?.as_u64()?; + entries.push((hash, coins)); + } + Ok(entries) +} + +fn count_map_entries(noun: &Noun) -> usize { + let mut count = 0; + for entry in HoonMapIter::from(*noun) { + if entry.is_cell() { + count += 1; + } + } + count +} + +fn decode_page_msg(noun: &Noun) -> Result { + // PageMsg is a list of u32, we'll just get the raw bytes + if let Ok(atom) = noun.as_atom() { + if atom.as_u64()? == 0 { + return Ok(PageMsgValue { raw: vec![] }); + } + return Ok(PageMsgValue { + raw: atom.as_ne_bytes().to_vec(), + }); + } + // List of u32 + let mut bytes = Vec::new(); + let mut current = *noun; + while let Ok(cell) = current.as_cell() { + if let Ok(val) = cell.head().as_atom() { + let v = val.as_u64().unwrap_or(0) as u32; + bytes.extend_from_slice(&v.to_le_bytes()); + } + current = cell.tail(); + } + Ok(PageMsgValue { raw: bytes }) +} + +// ============================================================================ +// Proto Conversion +// ============================================================================ + +impl FullPageDetails { + pub fn to_proto(&self) -> BlockDetails { + BlockDetails { + block_id: Some(hash_to_proto(&self.block_id)), + height: self.height, + parent: Some(hash_to_proto(&self.parent_id)), + pow: Some(ProofOfWork { + present: self.pow_present, + raw_proof: self.pow_raw.clone(), + }), + timestamp: self.timestamp, + epoch_counter: self.epoch_counter, + target: Some(ProtoBigNum { + display: self.target.to_display(), + raw_bytes: self.target.raw_bytes.clone(), + hex: self.target.to_hex(), + }), + accumulated_work: Some(ProtoBigNum { + display: self.accumulated_work.to_display(), + raw_bytes: self.accumulated_work.raw_bytes.clone(), + hex: self.accumulated_work.to_hex(), + }), + tx_ids: self + .tx_ids + .iter() + .map(|id| pb_common::Base58Hash { + hash: id.to_base58(), + }) + .collect(), + coinbase: Some(self.coinbase_to_proto()), + msg: Some(self.msg_to_proto()), + tx_count: self.tx_ids.len() as u32, + has_pow: self.pow_present, + version: self.version, + } + } + + fn coinbase_to_proto(&self) -> ProtoCoinbaseSplit { + use crate::pb::public::v2::coinbase_split::Version as CbVersion; + + match &self.coinbase { + CoinbaseSplitValue::V0(_bytes) => { + // V0 coinbase - legacy format with bytes + ProtoCoinbaseSplit { + version: Some(CbVersion::V0(ProtoCoinbaseSplitV0 { + entries: vec![], + entry_count: 0, + note: "V0 legacy coinbase".into(), + })), + } + } + CoinbaseSplitValue::V0Raw(count) => { + // V0 coinbase - we only know the entry count + ProtoCoinbaseSplit { + version: Some(CbVersion::V0(ProtoCoinbaseSplitV0 { + entries: vec![], + entry_count: *count as u32, + note: "V0 coinbase (sig-based)".into(), + })), + } + } + CoinbaseSplitValue::V1(entries) => ProtoCoinbaseSplit { + version: Some(CbVersion::V1(ProtoCoinbaseSplitV1 { + entries: entries + .iter() + .map(|(hash, amount)| CoinbaseSplitV1Entry { + lock_hash: Some(pb_common::Base58Hash { + hash: hash.to_base58(), + }), + amount: Some(pb_common::Nicks { value: *amount }), + }) + .collect(), + })), + }, + } + } + + fn msg_to_proto(&self) -> ProtoPageMsg { + let decoded = String::from_utf8(self.msg.raw.clone()).unwrap_or_default(); + ProtoPageMsg { + raw: self.msg.raw.clone(), + decoded, + } + } +} + #[cfg(test)] mod tests { use nockchain_math::belt::Belt; diff --git a/crates/nockapp-grpc/src/services/public_nockchain/v2/cache.rs b/crates/nockapp-grpc/src/services/public_nockchain/v2/cache.rs index 213dbc8be..57da35a2e 100644 --- a/crates/nockapp-grpc/src/services/public_nockchain/v2/cache.rs +++ b/crates/nockapp-grpc/src/services/public_nockchain/v2/cache.rs @@ -16,7 +16,7 @@ use crate::v2::pagination::{ }; pub const MAX_PAGE_BYTES: u64 = 3 * 1024 * 1024; -pub const MAX_PAGE_SIZE: usize = 1000; +pub const MAX_PAGE_SIZE: usize = 1_024; pub const DEFAULT_PAGE_BYTES: u64 = 1024 * 1024; pub const DEFAULT_PAGE_SIZE: usize = 600; const PER_ENTRY_OVERHEAD: usize = 8; diff --git a/crates/nockapp-grpc/src/services/public_nockchain/v2/metrics.rs b/crates/nockapp-grpc/src/services/public_nockchain/v2/metrics.rs index 9fa1f32de..5c816f71d 100644 --- a/crates/nockapp-grpc/src/services/public_nockchain/v2/metrics.rs +++ b/crates/nockapp-grpc/src/services/public_nockchain/v2/metrics.rs @@ -184,6 +184,91 @@ metrics_struct![ ( block_explorer_get_transaction_details_invalid_request, "nockchain_public_grpc.block_explorer.get_transaction_details.invalid_request", Count + ), + ( + block_explorer_get_block_details_success, + "nockchain_public_grpc.block_explorer.get_block_details.success", TimingCount + ), + ( + block_explorer_get_block_details_error, + "nockchain_public_grpc.block_explorer.get_block_details.error", TimingCount + ), + ( + block_explorer_get_block_details_not_found, + "nockchain_public_grpc.block_explorer.get_block_details.not_found", Count + ), + ( + block_explorer_get_block_details_invalid_request, + "nockchain_public_grpc.block_explorer.get_block_details.invalid_request", Count + ), + (block_explorer_cache_height, "nockchain_public_grpc.block_explorer.cache_height", Gauge), + ( + block_explorer_heaviest_height, "nockchain_public_grpc.block_explorer.heaviest_height", + Gauge + ), + ( + block_explorer_cache_age_seconds, "nockchain_public_grpc.block_explorer.cache_age_seconds", + Gauge + ), + ( + block_explorer_cache_lowest_height, + "nockchain_public_grpc.block_explorer.cache_lowest_height", Gauge + ), + (block_explorer_cache_span, "nockchain_public_grpc.block_explorer.cache_span", Gauge), + ( + block_explorer_cache_coverage_ratio, + "nockchain_public_grpc.block_explorer.cache_coverage_ratio", Gauge + ), + ( + block_explorer_backfill_resume_height, + "nockchain_public_grpc.block_explorer.backfill_resume_height", Gauge + ), + (block_explorer_seed_ready, "nockchain_public_grpc.block_explorer.seed_ready", Gauge), + ( + block_explorer_seed_time_seconds, "nockchain_public_grpc.block_explorer.seed_time_seconds", + Gauge + ), + ( + block_explorer_refresh_success, "nockchain_public_grpc.block_explorer.refresh_success", + Count + ), + (block_explorer_refresh_error, "nockchain_public_grpc.block_explorer.refresh_error", Count), + ( + block_explorer_refresh_age_seconds, + "nockchain_public_grpc.block_explorer.refresh_age_seconds", Gauge + ), + ( + block_explorer_backfill_success, "nockchain_public_grpc.block_explorer.backfill_success", + Count + ), + (block_explorer_backfill_error, "nockchain_public_grpc.block_explorer.backfill_error", Count), + ( + block_explorer_backfill_age_seconds, + "nockchain_public_grpc.block_explorer.backfill_age_seconds", Gauge + ), + ( + block_explorer_get_blocks_p50_ms, + "nockchain_public_grpc.block_explorer.get_blocks.latency_p50_ms", Gauge + ), + ( + block_explorer_get_blocks_p90_ms, + "nockchain_public_grpc.block_explorer.get_blocks.latency_p90_ms", Gauge + ), + ( + block_explorer_get_blocks_p99_ms, + "nockchain_public_grpc.block_explorer.get_blocks.latency_p99_ms", Gauge + ), + ( + block_explorer_get_block_details_p50_ms, + "nockchain_public_grpc.block_explorer.get_block_details.latency_p50_ms", Gauge + ), + ( + block_explorer_get_block_details_p90_ms, + "nockchain_public_grpc.block_explorer.get_block_details.latency_p90_ms", Gauge + ), + ( + block_explorer_get_block_details_p99_ms, + "nockchain_public_grpc.block_explorer.get_block_details.latency_p99_ms", Gauge ) ]; diff --git a/crates/nockapp-grpc/src/services/public_nockchain/v2/server.rs b/crates/nockapp-grpc/src/services/public_nockchain/v2/server.rs index 72d4f0551..1b55e9cbf 100644 --- a/crates/nockapp-grpc/src/services/public_nockchain/v2/server.rs +++ b/crates/nockapp-grpc/src/services/public_nockchain/v2/server.rs @@ -5,6 +5,7 @@ use std::time::Instant; use async_trait::async_trait; use gnort::instrument::TimingCount; use nockapp::driver::{NockAppHandle, PokeResult}; +use nockapp::nockapp::NockAppExit; use nockapp::noun::slab::NounSlab; use nockapp::wire::WireRepr; use nockchain_types::tx_engine::{v0, v1}; @@ -15,7 +16,7 @@ use tokio::time::{self, Duration}; use tonic::transport::Server; use tonic::{Request, Response, Status}; use tonic_reflection::server::Builder as ReflectionBuilder; -use tracing::{debug, info, warn}; +use tracing::{debug, error, info, warn}; use super::block_explorer::BlockExplorerCache; use super::cache::{ @@ -27,6 +28,9 @@ use crate::pb::common::v1::{Acknowledged, ErrorCode, ErrorStatus}; use crate::pb::public::v2::nockchain_block_service_server::{ NockchainBlockService, NockchainBlockServiceServer, }; +use crate::pb::public::v2::nockchain_metrics_service_server::{ + NockchainMetricsService, NockchainMetricsServiceServer, +}; use crate::pb::public::v2::nockchain_service_server::{NockchainService, NockchainServiceServer}; use crate::pb::public::v2::*; use crate::public_nockchain::v2::cache::{ @@ -78,6 +82,7 @@ impl BalanceHandle for NockAppBalanceHandle { #[derive(Clone)] pub struct PublicNockchainGrpcServer { handle: Arc, + exit: Option, cache_by_address: AddressBalanceCache, cache_by_first_name: FirstNameBalanceCache, block_explorer_cache: Arc, @@ -96,8 +101,10 @@ impl PublicNockchainGrpcServer { pub fn new(handle: NockAppHandle) -> Self { let metrics = init_metrics(); let block_explorer_cache = Arc::new(BlockExplorerCache::new(metrics.clone())); + let exit = handle.exit.clone(); Self { handle: Arc::new(NockAppBalanceHandle(handle)), + exit: Some(exit), cache_by_address: AddressBalanceCache::new(), cache_by_first_name: FirstNameBalanceCache::new(), block_explorer_cache, @@ -112,6 +119,7 @@ impl PublicNockchainGrpcServer { let block_explorer_cache = Arc::new(BlockExplorerCache::new(metrics.clone())); Self { handle, + exit: None, cache_by_address: AddressBalanceCache::new(), cache_by_first_name: FirstNameBalanceCache::new(), block_explorer_cache, @@ -161,12 +169,18 @@ impl PublicNockchainGrpcServer { self.block_explorer_cache.clone(), self.metrics.clone(), )); + let metrics_api = NockchainMetricsServiceServer::new(NockchainMetricsServer::new( + self.handle.clone(), + self.block_explorer_cache.clone(), + self.metrics.clone(), + )); Server::builder() .add_service(health_service) .add_service(reflection_service_v1) .add_service(nockchain_api) .add_service(block_explorer_api) + .add_service(metrics_api) .serve(addr) .await .map_err(NockAppGrpcError::Transport)?; @@ -243,52 +257,111 @@ impl PublicNockchainGrpcServer { health_reporter .set_not_serving::>() .await; - // Initialize on first run + let cache = server.block_explorer_cache.clone(); let handle = server.handle.clone(); - info!("Block explorer init worker starting"); - if let Err(err) = cache.clone().initialize(handle.clone()).await { - warn!("Failed to initialize block explorer cache: {}", err); - // Continue anyway, will retry on next refresh - health_reporter - .set_not_serving::>() - .await; - return; - } else { - info!("Block explorer cache initialized successfully"); - health_reporter - .set_serving::>() - .await; - } + let exit = server.exit.clone(); + + // Helper to handle fatal decode errors + let handle_fatal_error = |err: &NockAppGrpcError, exit: &Option<_>, context: &str| { + if matches!(err, NockAppGrpcError::NounDecode(_)) { + error!( + "Fatal decode error during {}: {}. Signaling server shutdown.", + context, err + ); + true + } else { + false + } + }; + + info!("Block explorer refresh worker starting"); + let mut interval = time::interval(Duration::from_secs(15)); + let mut initialized = false; + let mut backfill_started = false; + + loop { + interval.tick().await; - let refresh_cache = cache.clone(); - let refresh_handle = handle.clone(); - tokio::spawn(async move { - info!("Block explorer refresh worker starting"); - let mut interval = time::interval(Duration::from_secs(15)); - loop { - interval.tick().await; - if let Err(err) = refresh_cache.refresh(&refresh_handle).await { - warn!("Failed to refresh block explorer cache: {}", err); + // Attempt initialization if not yet successful + if !initialized { + info!("Block explorer attempting initialization"); + match cache.clone().initialize(handle.clone()).await { + Ok(()) => { + info!("Block explorer cache initialized successfully"); + initialized = true; + health_reporter + .set_serving::>() + .await; + } + Err(err) => { + if handle_fatal_error(&err, &exit, "block explorer initialization") { + if let Some(ref exit_handle) = exit { + if let Err(exit_err) = exit_handle.exit(1).await { + error!("Failed to signal exit: {:?}", exit_err); + } + } + return; + } + warn!( + "Failed to initialize block explorer cache: {}, will retry", + err + ); + } } + continue; // Skip refresh on init attempts } - }); - - if let Some(resume_height) = cache.take_backfill_resume().await { - let backfill_cache = cache.clone(); - let backfill_handle = handle.clone(); - tokio::spawn(async move { - info!(resume_height, "Block explorer backfill worker starting"); - match backfill_cache - .backfill_older(backfill_handle, resume_height) - .await - { - Ok(_) => info!("Block explorer backfill worker finished"), - Err(err) => warn!("Block explorer backfill failed: {}", err), + + // Normal refresh cycle (only after successful init) + if let Err(err) = cache.refresh(&handle).await { + if handle_fatal_error(&err, &exit, "block explorer refresh") { + if let Some(ref exit_handle) = exit { + if let Err(exit_err) = exit_handle.exit(1).await { + error!("Failed to signal exit: {:?}", exit_err); + } + } + return; + } + warn!("Failed to refresh block explorer cache: {}", err); + } + + // Start backfill worker once we have a resume height + if !backfill_started { + if let Some(resume_height) = cache.take_backfill_resume().await { + backfill_started = true; + let backfill_cache = cache.clone(); + let backfill_handle = handle.clone(); + let backfill_exit = exit.clone(); + tokio::spawn(async move { + info!( + resume_height, + "Block explorer backfill worker starting (lazy)" + ); + match backfill_cache + .backfill_older(backfill_handle, resume_height) + .await + { + Ok(_) => info!("Block explorer backfill worker finished"), + Err(err) => { + if matches!(&err, NockAppGrpcError::NounDecode(_)) { + error!( + "Fatal decode error during block explorer backfill: {}. \ + Signaling server shutdown.", + err + ); + if let Some(ref exit) = backfill_exit { + if let Err(exit_err) = exit.exit(1).await { + error!("Failed to signal exit: {:?}", exit_err); + } + } + return; + } + warn!("Block explorer backfill failed: {}", err); + } + } + }); } - }); - } else { - info!("Block explorer backfill worker not required"); + } } }); } @@ -353,6 +426,13 @@ pub struct NockchainBlockServer { metrics: Arc, } +#[derive(Clone)] +pub struct NockchainMetricsServer { + handle: Arc, + block_explorer_cache: Arc, + metrics: Arc, +} + impl NockchainBlockServer { pub fn new( handle: Arc, @@ -367,6 +447,117 @@ impl NockchainBlockServer { } } +impl NockchainMetricsServer { + pub fn new( + handle: Arc, + cache: Arc, + metrics: Arc, + ) -> Self { + Self { + handle, + block_explorer_cache: cache, + metrics, + } + } +} + +#[tonic::async_trait] +impl NockchainMetricsService for NockchainMetricsServer { + async fn get_explorer_metrics( + &self, + _request: Request, + ) -> std::result::Result, Status> { + // Fast path: metrics_snapshot no longer does kernel peeks, just reads atomics/cached values + let snapshot = self.block_explorer_cache.metrics_snapshot().await; + { + self.metrics + .block_explorer_cache_height + .swap(snapshot.cache_height as f64); + self.metrics + .block_explorer_heaviest_height + .swap(snapshot.heaviest_height as f64); + self.metrics + .block_explorer_seed_ready + .swap(if snapshot.seed_ready { 1.0 } else { 0.0 }); + self.metrics + .block_explorer_cache_lowest_height + .swap(snapshot.cache_lowest_height as f64); + self.metrics + .block_explorer_cache_span + .swap(snapshot.cache_span as f64); + self.metrics + .block_explorer_cache_coverage_ratio + .swap(snapshot.cache_coverage_ratio); + self.metrics + .block_explorer_backfill_resume_height + .swap(snapshot.backfill_resume_height.unwrap_or(u64::MAX) as f64); + self.metrics + .block_explorer_cache_age_seconds + .swap(snapshot.cache_age_seconds); + self.metrics + .block_explorer_refresh_age_seconds + .swap(snapshot.refresh_age_seconds); + self.metrics + .block_explorer_backfill_age_seconds + .swap(snapshot.backfill_age_seconds.unwrap_or(-1.0)); + self.metrics + .block_explorer_seed_time_seconds + .swap(snapshot.seed_time_seconds); + self.metrics + .block_explorer_get_blocks_p50_ms + .swap(snapshot.get_blocks_p50_ms); + self.metrics + .block_explorer_get_blocks_p90_ms + .swap(snapshot.get_blocks_p90_ms); + self.metrics + .block_explorer_get_blocks_p99_ms + .swap(snapshot.get_blocks_p99_ms); + self.metrics + .block_explorer_get_block_details_p50_ms + .swap(snapshot.get_block_details_p50_ms); + self.metrics + .block_explorer_get_block_details_p90_ms + .swap(snapshot.get_block_details_p90_ms); + self.metrics + .block_explorer_get_block_details_p99_ms + .swap(snapshot.get_block_details_p99_ms); + + let backfill_height = snapshot + .backfill_resume_height + .map(|h| h as i64) + .unwrap_or(-1); + let resp = GetExplorerMetricsResponse { + result: Some(get_explorer_metrics_response::Result::Metrics( + ExplorerMetrics { + cache_height: snapshot.cache_height, + heaviest_height: snapshot.heaviest_height, + cache_lowest_height: snapshot.cache_lowest_height, + cache_span: snapshot.cache_span, + cache_coverage_ratio: snapshot.cache_coverage_ratio, + refresh_age_seconds: snapshot.refresh_age_seconds, + backfill_age_seconds: snapshot.backfill_age_seconds.unwrap_or(-1.0), + seed_ready: snapshot.seed_ready, + seed_time_seconds: snapshot.seed_time_seconds, + backfill_resume_height: backfill_height, + cache_age_seconds: snapshot.cache_age_seconds, + refresh_success_count: snapshot.refresh_success_count, + refresh_error_count: snapshot.refresh_error_count, + backfill_success_count: snapshot.backfill_success_count, + backfill_error_count: snapshot.backfill_error_count, + get_blocks_p50_ms: snapshot.get_blocks_p50_ms, + get_blocks_p90_ms: snapshot.get_blocks_p90_ms, + get_blocks_p99_ms: snapshot.get_blocks_p99_ms, + get_block_details_p50_ms: snapshot.get_block_details_p50_ms, + get_block_details_p90_ms: snapshot.get_block_details_p90_ms, + get_block_details_p99_ms: snapshot.get_block_details_p99_ms, + }, + )), + }; + Ok(Response::new(resp)) + } + } +} + fn timed_return(metric: &TimingCount, started: Instant, value: T) -> T { metric.add_timing(&started.elapsed()); value @@ -1193,9 +1384,9 @@ impl NockchainBlockService for NockchainBlockServer { metrics .block_explorer_get_blocks_error_invalid_request .increment(); - metrics - .block_explorer_get_blocks_error - .add_timing(&request_start.elapsed()); + let elapsed = request_start.elapsed(); + self.block_explorer_cache.record_get_blocks_latency(elapsed); + metrics.block_explorer_get_blocks_error.add_timing(&elapsed); return Err(Status::invalid_argument("page is required")); } }; @@ -1222,9 +1413,9 @@ impl NockchainBlockService for NockchainBlockServer { metrics .block_explorer_get_blocks_error_invalid_request .increment(); - metrics - .block_explorer_get_blocks_error - .add_timing(&request_start.elapsed()); + let elapsed = request_start.elapsed(); + self.block_explorer_cache.record_get_blocks_latency(elapsed); + metrics.block_explorer_get_blocks_error.add_timing(&elapsed); return Err(Status::invalid_argument("invalid page token")); } } @@ -1312,13 +1503,118 @@ impl NockchainBlockService for NockchainBlockServer { "Responding to GetBlocks request" ); - timed_return( - &metrics.block_explorer_get_blocks_success, - request_start, - Ok(Response::new(GetBlocksResponse { - result: Some(get_blocks_response::Result::Blocks(response)), - })), + let elapsed = request_start.elapsed(); + self.block_explorer_cache.record_get_blocks_latency(elapsed); + metrics + .block_explorer_get_blocks_success + .add_timing(&elapsed); + Ok(Response::new(GetBlocksResponse { + result: Some(get_blocks_response::Result::Blocks(response)), + })) + } + + #[tracing::instrument( + name = "grpc.block_explorer.get_block_details", + skip(self, request), + fields( + height = tracing::field::Empty, + block_id = tracing::field::Empty ) + )] + async fn get_block_details( + &self, + request: Request, + ) -> std::result::Result, Status> { + let span = tracing::Span::current(); + let req = request.into_inner(); + let metrics = &self.metrics; + let request_start = Instant::now(); + + let result = match req.selector { + Some(get_block_details_request::Selector::Height(height)) => { + span.record("height", &tracing::field::display(height)); + info!(height, "Serving GetBlockDetails by height"); + self.block_explorer_cache + .load_full_page_by_height(&self.handle, height) + .await + } + Some(get_block_details_request::Selector::BlockId(ref block_id_b58)) => { + span.record("block_id", &tracing::field::display(&block_id_b58.hash)); + info!( + block_id = block_id_b58.hash.as_str(), + "Serving GetBlockDetails by block_id" + ); + self.block_explorer_cache + .load_full_page_by_id(&self.handle, &block_id_b58.hash) + .await + } + None => { + metrics + .block_explorer_get_block_details_invalid_request + .increment(); + metrics + .block_explorer_get_block_details_error + .add_timing(&request_start.elapsed()); + return Err(Status::invalid_argument( + "selector is required (height or block_id)", + )); + } + }; + + match result { + Ok(details) => { + info!( + height = details.height, + block_id = details.block_id.to_base58().as_str(), + tx_count = details.tx_ids.len(), + "Responding to GetBlockDetails request" + ); + let elapsed = request_start.elapsed(); + self.block_explorer_cache + .record_get_block_details_latency(elapsed); + metrics + .block_explorer_get_block_details_success + .add_timing(&elapsed); + Ok(Response::new(GetBlockDetailsResponse { + result: Some(get_block_details_response::Result::Details( + details.to_proto(), + )), + })) + } + Err(NockAppGrpcError::NotFound) => { + metrics + .block_explorer_get_block_details_not_found + .increment(); + let elapsed = request_start.elapsed(); + self.block_explorer_cache + .record_get_block_details_latency(elapsed); + metrics + .block_explorer_get_block_details_error + .add_timing(&elapsed); + Err(Status::not_found("Block not found")) + } + Err(NockAppGrpcError::InvalidRequest(msg)) => { + metrics + .block_explorer_get_block_details_invalid_request + .increment(); + let elapsed = request_start.elapsed(); + self.block_explorer_cache + .record_get_block_details_latency(elapsed); + metrics + .block_explorer_get_block_details_error + .add_timing(&elapsed); + Err(Status::invalid_argument(msg)) + } + Err(err) => { + let elapsed = request_start.elapsed(); + self.block_explorer_cache + .record_get_block_details_latency(elapsed); + metrics + .block_explorer_get_block_details_error + .add_timing(&elapsed); + Err(Status::internal(err.to_string())) + } + } } #[tracing::instrument( diff --git a/crates/nockchain-api/Cargo.toml b/crates/nockchain-api/Cargo.toml index 0633ee84f..94a23f56d 100644 --- a/crates/nockchain-api/Cargo.toml +++ b/crates/nockchain-api/Cargo.toml @@ -21,3 +21,15 @@ tikv-jemallocator = { workspace = true, features = [ ] } tokio.workspace = true tracy-client = { workspace = true, optional = true } + +[dev-dependencies] +nockchain-types = { workspace = true } +noun-serde = { workspace = true } +tokio-test = "0.4" +tempfile = { workspace = true } +nockapp-grpc-proto = { workspace = true } +tonic = { workspace = true } + +[[bench]] +name = "peek_refresh" +harness = false diff --git a/crates/nockchain-api/benches/peek_refresh.rs b/crates/nockchain-api/benches/peek_refresh.rs new file mode 100644 index 000000000..6cbd508bf --- /dev/null +++ b/crates/nockchain-api/benches/peek_refresh.rs @@ -0,0 +1,284 @@ +#![allow(clippy::print_stdout)] + +use std::error::Error; +use std::time::Instant; + +use nockapp_grpc_proto::pb::common::v1::{Base58Hash, PageRequest}; +use nockapp_grpc_proto::pb::public::v2::nockchain_block_service_client::NockchainBlockServiceClient; +use nockapp_grpc_proto::pb::public::v2::{ + get_block_details_request, get_block_details_response, get_blocks_response, + get_transaction_details_response, GetBlockDetailsRequest, GetBlocksRequest, + GetTransactionDetailsRequest, +}; +use tonic::transport::Channel; + +#[tokio::main(flavor = "multi_thread")] +async fn main() -> Result<(), Box> { + let addr = std::env::var("NOCKCHAIN_BENCH_SERVER") + .unwrap_or_else(|_| "http://127.0.0.1:50051".to_string()); + + // Check if we should test a specific block + if let Ok(height_str) = std::env::var("TEST_BLOCK_HEIGHT") { + let height: u64 = height_str.parse()?; + return test_specific_block(&addr, height).await; + } + + run_grpc_mode(&addr).await +} + +async fn run_grpc_mode(addr: &str) -> Result<(), Box> { + println!("Using existing gRPC server at {}", addr); + let channel = Channel::from_shared(addr.to_string())?.connect().await?; + let mut client = NockchainBlockServiceClient::new(channel); + + let tip_height = fetch_tip_height(&mut client).await?; + println!("tip height: {}\n", tip_height); + + let sizes = [2_u64, 8, 64, 256, 1024]; + + // Benchmark 1: GetBlocks only (metadata from cache) + println!("=== Benchmark: GetBlocks only (cached metadata) ==="); + run_get_blocks_benchmark(&mut client, tip_height, &sizes).await?; + + // Benchmark 2: GetBlocks + GetBlockDetails for each block + println!("\n=== Benchmark: GetBlocks + GetBlockDetails (kernel peek per block) ==="); + run_get_blocks_with_details_benchmark(&mut client, tip_height, &sizes).await?; + + Ok(()) +} + +async fn run_get_blocks_benchmark( + client: &mut NockchainBlockServiceClient, + tip_height: u64, + sizes: &[u64], +) -> Result<(), Box> { + let mut next_end = tip_height; + for &size in sizes { + let start = next_end.saturating_sub(size.saturating_sub(1)); + if start == 0 && next_end == 0 { + println!(" size={:>4} skipped (no heights available)", size); + continue; + } + next_end = start.saturating_sub(1); + + let page_token = format!("{:x}", start); + let req = GetBlocksRequest { + page: Some(PageRequest { + page_token, + client_page_items_limit: size as u32, + max_bytes: 0, + }), + }; + + let started = Instant::now(); + let resp = client.get_blocks(req).await?; + let elapsed = started.elapsed(); + + if let Some(get_blocks_response::Result::Blocks(data)) = resp.into_inner().result { + let returned = data.blocks.len(); + let per_block_us = if returned > 0 { + elapsed.as_micros() as f64 / returned as f64 + } else { + 0.0 + }; + println!( + " size={:>4} returned={:>4} total={:>10.2?} per_block={:>8.1}us", + size, returned, elapsed, per_block_us + ); + } else { + println!(" size={:>4} returned no blocks (took {:?})", size, elapsed); + } + } + Ok(()) +} + +async fn run_get_blocks_with_details_benchmark( + client: &mut NockchainBlockServiceClient, + tip_height: u64, + sizes: &[u64], +) -> Result<(), Box> { + let mut next_end = tip_height; + for &size in sizes { + let start = next_end.saturating_sub(size.saturating_sub(1)); + if start == 0 && next_end == 0 { + println!(" size={:>4} skipped (no heights available)", size); + continue; + } + next_end = start.saturating_sub(1); + + let page_token = format!("{:x}", start); + let req = GetBlocksRequest { + page: Some(PageRequest { + page_token, + client_page_items_limit: size as u32, + max_bytes: 0, + }), + }; + + let total_started = Instant::now(); + + // Phase 1: GetBlocks + let get_blocks_started = Instant::now(); + let resp = client.get_blocks(req).await?; + let get_blocks_elapsed = get_blocks_started.elapsed(); + + let blocks = match resp.into_inner().result { + Some(get_blocks_response::Result::Blocks(data)) => data.blocks, + _ => { + println!(" size={:>4} GetBlocks returned no blocks", size); + continue; + } + }; + + // Phase 2: GetBlockDetails for each block + let details_started = Instant::now(); + let mut details_count = 0; + for block in &blocks { + if let Err(e) = fetch_block_details(client, block.height).await { + println!( + " Warning: GetBlockDetails failed for height {}: {}", + block.height, e + ); + } else { + details_count += 1; + } + } + let details_elapsed = details_started.elapsed(); + + let total_elapsed = total_started.elapsed(); + let per_detail_us = if details_count > 0 { + details_elapsed.as_micros() as f64 / details_count as f64 + } else { + 0.0 + }; + + println!( + " size={:>4} returned={:>4} get_blocks={:>10.2?} details={:>10.2?} total={:>10.2?} per_detail={:>10.1}us", + size, blocks.len(), get_blocks_elapsed, details_elapsed, total_elapsed, per_detail_us + ); + } + Ok(()) +} + +async fn fetch_block_details( + client: &mut NockchainBlockServiceClient, + height: u64, +) -> Result<(), Box> { + let request = GetBlockDetailsRequest { + selector: Some(get_block_details_request::Selector::Height(height)), + }; + + let resp = client.get_block_details(request).await?; + match resp.into_inner().result { + Some(get_block_details_response::Result::Details(_)) => Ok(()), + Some(get_block_details_response::Result::Error(e)) => { + Err(format!("GetBlockDetails error: {}", e.message).into()) + } + None => Err("GetBlockDetails returned no result".into()), + } +} + +async fn fetch_tip_height( + client: &mut NockchainBlockServiceClient, +) -> Result> { + let req = GetBlocksRequest { + page: Some(PageRequest { + page_token: "".into(), + client_page_items_limit: 1, + max_bytes: 0, + }), + }; + let resp = client.get_blocks(req).await?; + if let Some(get_blocks_response::Result::Blocks(data)) = resp.into_inner().result { + Ok(data.current_height) + } else { + Err("GetBlocks returned no data".into()) + } +} + +/// Test a specific block and all its transactions for decoding errors. +/// Run with: TEST_BLOCK_HEIGHT=43099 cargo run --bin peek_refresh +async fn test_specific_block(addr: &str, height: u64) -> Result<(), Box> { + println!("=== Testing specific block at height {} ===\n", height); + + let channel = Channel::from_shared(addr.to_string())?.connect().await?; + let mut client = NockchainBlockServiceClient::new(channel); + + // Step 1: Get block details + println!("Step 1: Fetching block details for height {}...", height); + let request = GetBlockDetailsRequest { + selector: Some(get_block_details_request::Selector::Height(height)), + }; + + let resp = client.get_block_details(request).await; + match resp { + Ok(response) => { + let inner = response.into_inner(); + match inner.result { + Some(get_block_details_response::Result::Details(details)) => { + println!(" Block ID: {:?}", details.block_id); + println!(" Version: {}", details.version); + println!(" Tx count: {}", details.tx_ids.len()); + println!(" Block details fetched OK!\n"); + + // Step 2: Try to fetch each transaction's details + println!("Step 2: Fetching transaction details..."); + for (i, tx_id) in details.tx_ids.iter().enumerate() { + println!( + " [{}/{}] Fetching tx: {}", + i + 1, + details.tx_ids.len(), + tx_id.hash + ); + + let tx_request = GetTransactionDetailsRequest { + tx_id: Some(Base58Hash { + hash: tx_id.hash.clone(), + }), + }; + + match client.get_transaction_details(tx_request).await { + Ok(tx_resp) => { + let tx_inner = tx_resp.into_inner(); + match tx_inner.result { + Some(get_transaction_details_response::Result::Details( + tx_details, + )) => { + println!(" Version: {}", tx_details.version); + println!(" Inputs: {}", tx_details.inputs.len()); + println!(" Outputs: {}", tx_details.outputs.len()); + println!(" OK!"); + } + Some(get_transaction_details_response::Result::Pending(_)) => { + println!(" Status: Pending"); + } + Some(get_transaction_details_response::Result::Error(e)) => { + println!(" ERROR in response: {}", e.message); + } + None => { + println!(" ERROR: Empty response"); + } + } + } + Err(e) => { + println!(" gRPC ERROR: {:#?}", e); + } + } + } + } + Some(get_block_details_response::Result::Error(e)) => { + println!(" ERROR in response: {}", e.message); + } + None => { + println!(" ERROR: Empty response"); + } + } + } + Err(e) => { + println!(" gRPC ERROR: {:#?}", e); + } + } + + println!("\n=== Test complete ==="); + Ok(()) +} diff --git a/crates/nockchain-explorer-tui/src/main.rs b/crates/nockchain-explorer-tui/src/main.rs index 1145a71d3..7b88d6c19 100644 --- a/crates/nockchain-explorer-tui/src/main.rs +++ b/crates/nockchain-explorer-tui/src/main.rs @@ -1,5 +1,7 @@ use std::cmp::Ordering; -use std::collections::{BTreeMap, HashMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; +use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering}; +use std::sync::Arc; use std::time::{Duration, Instant}; use std::{env, io}; @@ -16,9 +18,13 @@ use crossterm::terminal::{ }; use nockapp_grpc_proto::pb::common::v1::{self as pb_common, Base58Hash, PageRequest}; use nockapp_grpc_proto::pb::public::v2::nockchain_block_service_client::NockchainBlockServiceClient; +use nockapp_grpc_proto::pb::public::v2::nockchain_metrics_service_client::NockchainMetricsServiceClient; use nockapp_grpc_proto::pb::public::v2::{ - get_blocks_response, get_transaction_block_response, get_transaction_details_response, - BlockEntry, GetBlocksRequest, GetTransactionBlockRequest, GetTransactionDetailsRequest, + get_block_details_request, get_block_details_response, get_blocks_response, + get_explorer_metrics_response, get_transaction_block_response, + get_transaction_details_response, transaction_details, transaction_output, BlockDetails, + BlockEntry, ExplorerMetrics, GetBlockDetailsRequest, GetBlocksRequest, + GetExplorerMetricsRequest, GetTransactionBlockRequest, GetTransactionDetailsRequest, TransactionBlockData, TransactionDetails as RpcTransactionDetails, }; use nockchain_math::belt::Belt; @@ -27,12 +33,12 @@ use ratatui::backend::CrosstermBackend; use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph, Tabs, Wrap}; +use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Tabs, Wrap}; use ratatui::{Frame, Terminal}; use tokio::sync::mpsc::error::TryRecvError; use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; use tonic::Request; -use tracing::{info, warn}; +use tracing::{debug, info, trace, warn}; use tracing_subscriber::prelude::*; use tracing_subscriber::{fmt, EnvFilter}; use tracing_tracy::TracyLayer; @@ -55,6 +61,7 @@ enum View { BlocksList, TransactionsList, WalletsList, + Metrics, BlockDetails(usize), // index in blocks list TransactionDetails { block_idx: usize, tx_idx: usize }, TransactionSearch, @@ -70,11 +77,19 @@ enum ConnectionStatus { } const PAGE_JUMP: usize = 20; -const AUTO_REFRESH_IDLE_GRACE: Duration = Duration::from_secs(3); const EMPTY_CACHE_BACKOFF: Duration = Duration::from_secs(30); const ERROR_REFRESH_BACKOFF: Duration = Duration::from_secs(5); const WALLET_INDEX_CHUNK: usize = 64; const NICKS_PER_NOCK: u64 = 65_536; +const SPINNER_FRAMES: [&str; 4] = ["◴", "◷", "◶", "◵"]; +const SPINNER_COLORS: [Color; 6] = [ + Color::Red, + Color::Yellow, + Color::Green, + Color::Cyan, + Color::Blue, + Color::Magenta, +]; struct App { client: Option>, @@ -91,7 +106,6 @@ struct App { clear_status_on_input: bool, last_refresh: Instant, next_allowed_refresh: Instant, - auto_refresh_enabled: bool, tx_search_input: String, tx_search_result: Option, server_uri: String, @@ -115,10 +129,18 @@ struct App { wallet_index_highest_synced: u64, clipboard: Option, block_focus: BlockDetailsFocus, + full_block_details: HashMap, + loading_block_details: Option, + metrics_client: Option>, + metrics_data: Option, + metrics_error: Option, last_user_action: Instant, help_scroll: u16, help_max_scroll: u16, active_tab: usize, + busy: bool, + spinner_index: usize, + shutdown_flag: Arc, // Connection state connection_status: ConnectionStatus, @@ -126,6 +148,17 @@ struct App { last_connection_attempt: Instant, last_connection_error: Option, fail_fast: bool, + + // Prefetch state - background loading of block details to avoid loading screens + prefetch_queue: VecDeque, + prefetch_in_progress: bool, + + // Priority prefetch queue - for blocks user navigates to (processed first) + priority_prefetch_queue: VecDeque, + // Deferred page load request (non-blocking) + request_next_page: bool, + // Whether scroll-triggered preloading is enabled (env var EXPLORER_SCROLL_PRELOAD) + scroll_preload_enabled: bool, } #[derive(Debug, Clone)] @@ -260,6 +293,11 @@ impl App { (None, ConnectionStatus::NeverConnected, Some(e.to_string())) } }; + let metrics_client = match NockchainMetricsServiceClient::connect(server_uri.clone()).await + { + Ok(client) => Some(client), + Err(_) => None, + }; let (wallet_cmd_tx, wallet_cmd_rx) = mpsc::unbounded_channel(); let (wallet_res_tx, wallet_res_rx) = mpsc::unbounded_channel(); @@ -283,7 +321,6 @@ impl App { clear_status_on_input: false, last_refresh: Instant::now(), next_allowed_refresh: Instant::now(), - auto_refresh_enabled: true, tx_search_input: String::new(), tx_search_result: None, server_uri, @@ -307,10 +344,18 @@ impl App { wallet_index_highest_synced: 0, clipboard: Clipboard::new().ok(), block_focus: BlockDetailsFocus::Block, + full_block_details: HashMap::new(), + loading_block_details: None, + metrics_client, + metrics_data: None, + metrics_error: None, last_user_action: Instant::now(), help_scroll: 0, help_max_scroll: 0, active_tab: 0, + busy: false, + spinner_index: 0, + shutdown_flag: Arc::new(AtomicBool::new(false)), // Connection state connection_status, @@ -322,6 +367,16 @@ impl App { last_connection_attempt: Instant::now(), last_connection_error: connection_error, fail_fast, + + // Prefetch state + prefetch_queue: VecDeque::new(), + prefetch_in_progress: false, + + // Priority prefetch queue + priority_prefetch_queue: VecDeque::new(), + request_next_page: false, + // Scroll-triggered preloading is opt-in via env var + scroll_preload_enabled: env::var("EXPLORER_SCROLL_PRELOAD").is_ok(), }; // Only try to load blocks if connected @@ -337,6 +392,7 @@ impl App { View::BlocksList | View::BlockDetails(_) => 0, View::TransactionsList | View::TransactionDetails { .. } | View::TransactionSearch => 1, View::WalletsList => 2, + View::Metrics => 3, View::Help => self.active_tab, }; self.active_tab = tab; @@ -344,7 +400,7 @@ impl App { } fn activate_tab(&mut self, tab: usize) { - match tab % 3 { + match tab % 4 { 0 => self.set_view(View::BlocksList), 1 => { self.set_view(View::TransactionsList); @@ -361,12 +417,15 @@ impl App { self.clear_status_on_input = true; } } + 3 => { + self.set_view(View::Metrics); + } _ => {} } } fn cycle_tabs(&mut self, delta: i32) { - let total_tabs = 3; + let total_tabs = 4; let idx = (self.active_tab as i32 + delta).rem_euclid(total_tabs as i32) as usize; self.activate_tab(idx); } @@ -458,6 +517,51 @@ impl App { Ok(()) } + #[tracing::instrument(name = "tui.block_explorer.load_metrics", skip(self))] + async fn load_metrics(&mut self) -> Result<()> { + // Ensure we have a metrics client + if self.metrics_client.is_none() { + match NockchainMetricsServiceClient::connect(self.server_uri.clone()).await { + Ok(client) => self.metrics_client = Some(client), + Err(e) => { + self.metrics_error = Some(format!("Metrics connect error: {}", e)); + return Ok(()); + } + } + } + + let Some(ref mut client) = self.metrics_client else { + return Ok(()); + }; + + match client + .get_explorer_metrics(Request::new(GetExplorerMetricsRequest {})) + .await + { + Ok(response) => { + let resp = response.into_inner(); + match resp.result { + Some(get_explorer_metrics_response::Result::Metrics(metrics)) => { + self.metrics_data = Some(metrics); + self.metrics_error = None; + } + Some(get_explorer_metrics_response::Result::Error(e)) => { + self.metrics_error = Some(format!("Metrics error: {}", e.message)); + } + None => { + self.metrics_error = Some("Empty metrics response".into()); + } + } + } + Err(e) => { + self.metrics_error = Some(format!("Metrics gRPC error: {}", e)); + self.metrics_client = None; + } + } + + Ok(()) + } + #[tracing::instrument(name = "tui.block_explorer.load_next_page", skip(self))] async fn load_next_page(&mut self) -> Result<()> { if let Some(token) = self.next_page_token.clone() { @@ -471,6 +575,60 @@ impl App { self.load_blocks(None).await } + #[tracing::instrument( + name = "tui.block_explorer.load_full_block_details", + skip(self), + fields(height = tracing::field::Empty) + )] + async fn load_full_block_details(&mut self, height: u64) -> Result<()> { + tracing::Span::current().record("height", &tracing::field::display(height)); + + if self.full_block_details.contains_key(&height) { + return Ok(()); + } + + let Some(ref mut client) = self.client else { + return Err(anyhow!("Not connected to server")); + }; + + self.loading_block_details = Some(height); + + let request = GetBlockDetailsRequest { + selector: Some(get_block_details_request::Selector::Height(height)), + }; + + match client.get_block_details(Request::new(request)).await { + Ok(response) => { + if self.connection_status != ConnectionStatus::Connected { + self.connection_status = ConnectionStatus::Connected; + self.last_successful_connection = Some(Instant::now()); + } + + let resp = response.into_inner(); + match resp.result { + Some(get_block_details_response::Result::Details(details)) => { + self.full_block_details.insert(height, details); + } + Some(get_block_details_response::Result::Error(e)) => { + self.error_message = Some(format!("Error: {}", e.message)); + } + None => { + self.error_message = Some("No response from server".into()); + } + } + } + Err(e) => { + crash_happy_check(&format!("load_full_block_details(height={})", height), &e); + self.connection_status = ConnectionStatus::Disconnected; + self.last_connection_error = Some(e.to_string()); + self.error_message = Some(format!("gRPC Error: {}", e)); + } + } + + self.loading_block_details = None; + Ok(()) + } + #[tracing::instrument(name = "tui.block_explorer.attempt_reconnect", skip(self))] async fn attempt_reconnect(&mut self) -> Result<()> { self.last_connection_attempt = Instant::now(); @@ -479,11 +637,15 @@ impl App { match NockchainBlockServiceClient::connect(self.server_uri.clone()).await { Ok(client) => { self.client = Some(client); + self.metrics_client = + NockchainMetricsServiceClient::connect(self.server_uri.clone()) + .await + .ok(); self.connection_status = ConnectionStatus::Connected; self.last_successful_connection = Some(Instant::now()); self.last_connection_error = None; self.status_message = Some("Connected to server!".into()); - info!("Reconnected to server at {}", self.server_uri); + trace!("Reconnected to server at {}", self.server_uri); // Try to load initial blocks let _ = self.load_blocks(None).await; @@ -617,6 +779,7 @@ impl App { self.set_transaction_overview_selection(block_idx, tx_idx); } Err(e) => { + crash_happy_check(&format!("open_transaction_detail(tx_id={})", tx_id), &e); // Mark as disconnected on error self.connection_status = ConnectionStatus::Disconnected; self.last_connection_error = Some(e.to_string()); @@ -716,6 +879,49 @@ impl App { } self.sync_tx_list_selection(); self.rebuild_transactions_list(); + self.queue_prefetch_visible_blocks(); + } + + /// Queue visible blocks for background prefetch of full block details. + /// This eliminates loading screens when navigating to block details. + /// Only active when EXPLORER_SCROLL_PRELOAD env var is set. + fn queue_prefetch_visible_blocks(&mut self) { + if !self.scroll_preload_enabled { + return; + } + // Queue first 15 blocks for prefetch (approximately what fits on screen) + for block in self.blocks.iter().take(15) { + if !self.full_block_details.contains_key(&block.height) + && !self.prefetch_queue.contains(&block.height) + { + self.prefetch_queue.push_back(block.height); + } + } + } + + /// Queue adjacent blocks for predictive prefetching when navigating BlockDetails. + /// Only active when EXPLORER_SCROLL_PRELOAD env var is set. + fn queue_adjacent_prefetch(&mut self, current_idx: usize) { + if !self.scroll_preload_enabled { + return; + } + // Prefetch next 2 and previous 2 blocks for smooth navigation + for offset in [1i32, 2, -1, -2] { + let adj_idx = if offset < 0 { + current_idx.checked_sub(offset.unsigned_abs() as usize) + } else { + Some(current_idx + offset as usize) + }; + if let Some(idx) = adj_idx { + if let Some(block) = self.blocks.get(idx) { + if !self.full_block_details.contains_key(&block.height) + && !self.priority_prefetch_queue.contains(&block.height) + { + self.priority_prefetch_queue.push_back(block.height); + } + } + } + } } fn update_pagination_tokens(&mut self) { @@ -1217,26 +1423,6 @@ impl App { } } - fn viewing_tip(&self) -> bool { - if self.blocks.is_empty() { - true - } else { - self.list_state - .selected() - .map(|idx| idx == 0) - .unwrap_or(true) - } - } - - fn should_auto_refresh_blocks(&self, interval: Duration) -> bool { - self.auto_refresh_enabled - && Instant::now() >= self.next_allowed_refresh - && matches!(self.view, View::BlocksList) - && self.viewing_tip() - && self.last_refresh.elapsed() >= interval - && self.can_auto_refresh(AUTO_REFRESH_IDLE_GRACE) - } - fn is_at_bottom(&self) -> bool { match self.list_state.selected() { Some(idx) if !self.blocks.is_empty() => idx == self.blocks.len() - 1, @@ -1245,7 +1431,7 @@ impl App { } fn should_auto_fetch_more(&self) -> bool { - self.has_more_pages && !self.loading && self.is_at_bottom() + self.scroll_preload_enabled && self.has_more_pages && !self.loading && self.is_at_bottom() } fn page_down(&mut self) -> Option { @@ -1416,6 +1602,23 @@ impl App { self.last_user_action = Instant::now(); } + fn start_busy(&mut self) { + self.busy = true; + self.spinner_index = 0; + } + + fn stop_busy(&mut self) { + self.busy = false; + } + + fn is_busy(&self) -> bool { + self.busy + } + + fn request_shutdown(&self) { + self.shutdown_flag.store(true, AtomicOrdering::Release); + } + fn record_refresh(&mut self) { let now = Instant::now(); self.last_refresh = now; @@ -1426,9 +1629,6 @@ impl App { self.next_allowed_refresh = Instant::now() + delay; } - fn can_auto_refresh(&self, interval: Duration) -> bool { - self.last_user_action.elapsed() >= interval - } fn open_help(&mut self) { if !matches!(self.view, View::Help) { self.previous_view = Some(self.view.clone()); @@ -1465,6 +1665,7 @@ fn ui(f: &mut Frame, app: &mut App) { View::BlocksList => render_blocks_list(f, chunks[2], app), View::TransactionsList => render_transactions_list(f, chunks[2], app), View::WalletsList => render_wallets_list(f, chunks[2], app), + View::Metrics => render_metrics_view(f, chunks[2], app), View::BlockDetails(idx) => render_block_details(f, chunks[2], app, *idx), View::TransactionDetails { block_idx, tx_idx } => { render_transaction_details(f, chunks[2], app, *block_idx, *tx_idx) @@ -1533,7 +1734,7 @@ fn render_header(f: &mut Frame, area: Rect, app: &App) { } fn render_tabs(f: &mut Frame, area: Rect, app: &App) { - let titles = ["Blocks", "Transactions", "Wallets"] + let titles = ["Blocks", "Transactions", "Wallets", "Metrics"] .iter() .map(|title| Line::from(Span::styled(*title, Style::default().fg(Color::Cyan)))) .collect::>(); @@ -1715,8 +1916,156 @@ fn render_wallets_list(f: &mut Frame, area: Rect, app: &mut App) { f.render_stateful_widget(list, chunks[1], &mut app.wallet_list_state); } +fn render_metrics_view(f: &mut Frame, area: Rect, app: &mut App) { + let mut lines = Vec::new(); + if let Some(metrics) = &app.metrics_data { + lines.push(Line::from(vec![ + Span::styled( + "Cache height: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(metrics.cache_height.to_string()), + ])); + lines.push(Line::from(vec![ + Span::styled( + "Cache lowest height: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(metrics.cache_lowest_height.to_string()), + ])); + lines.push(Line::from(vec![ + Span::styled( + "Cache span: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(metrics.cache_span.to_string()), + ])); + lines.push(Line::from(vec![ + Span::styled( + "Coverage ratio: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!("{:.3}", metrics.cache_coverage_ratio)), + ])); + lines.push(Line::from(vec![ + Span::styled( + "Heaviest height: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(metrics.heaviest_height.to_string()), + ])); + lines.push(Line::from(vec![ + Span::styled( + "Seed ready: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(if metrics.seed_ready { "yes" } else { "no" }), + ])); + lines.push(Line::from(vec![ + Span::styled( + "Backfill resume height: ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(if metrics.backfill_resume_height >= 0 { + metrics.backfill_resume_height.to_string() + } else { + "none".to_string() + }), + ])); + lines.push(Line::from(vec![ + Span::styled( + "Cache age (s): ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!("{:.3}", metrics.cache_age_seconds)), + ])); + lines.push(Line::from(vec![ + Span::styled( + "Last refresh age (s): ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!("{:.3}", metrics.refresh_age_seconds)), + ])); + lines.push(Line::from(vec![ + Span::styled( + "Last backfill age (s): ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(if metrics.backfill_age_seconds >= 0.0 { + format!("{:.3}", metrics.backfill_age_seconds) + } else { + "n/a".to_string() + }), + ])); + lines.push(Line::from(vec![ + Span::styled( + "Seed time (s): ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!("{:.3}", metrics.seed_time_seconds)), + ])); + lines.push(Line::from(vec![ + Span::styled( + "Refresh counts (ok/err): ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!( + "{}/{}", + metrics.refresh_success_count, metrics.refresh_error_count + )), + ])); + lines.push(Line::from(vec![ + Span::styled( + "Backfill counts (ok/err): ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!( + "{}/{}", + metrics.backfill_success_count, metrics.backfill_error_count + )), + ])); + lines.push(Line::from(vec![ + Span::styled( + "GetBlocks latency p50/p90/p99 (ms): ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!( + "{:.3}/{:.3}/{:.3}", + metrics.get_blocks_p50_ms, metrics.get_blocks_p90_ms, metrics.get_blocks_p99_ms + )), + ])); + lines.push(Line::from(vec![ + Span::styled( + "BlockDetails latency p50/p90/p99 (ms): ", + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!( + "{:.3}/{:.3}/{:.3}", + metrics.get_block_details_p50_ms, + metrics.get_block_details_p90_ms, + metrics.get_block_details_p99_ms + )), + ])); + } else if let Some(err) = &app.metrics_error { + lines.push(Line::from(Span::styled( + format!("Metrics unavailable: {}", err), + Style::default().fg(Color::Red), + ))); + } else { + lines.push(Line::from("Metrics not loaded yet")); + } + + let paragraph = Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .title("Explorer Metrics") + .border_style(Style::default().fg(Color::Cyan)), + ); + f.render_widget(paragraph, area); +} + fn render_block_details(f: &mut Frame, area: Rect, app: &mut App, idx: usize) { - let block = match app.blocks.get(idx) { + let block = match app.blocks.get(idx).cloned() { Some(b) => b, None => { let text = Paragraph::new("Block not found").block( @@ -1729,71 +2078,326 @@ fn render_block_details(f: &mut Frame, area: Rect, app: &mut App, idx: usize) { } }; - let mut lines = vec![ - Line::from(vec![ - Span::styled("Height: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(block.height.to_string()), - ]), - Line::from(""), - Line::from(vec![Span::styled( - "Block ID: ", - Style::default().add_modifier(Modifier::BOLD), - )]), - Line::from(Span::styled( - hash_full_display(&block.block_id), - Style::default().fg(Color::Green), - )), - Line::from(""), - Line::from(vec![Span::styled( - "Parent ID: ", - Style::default().add_modifier(Modifier::BOLD), - )]), - Line::from(Span::styled( - hash_full_display(&block.parent), - Style::default().fg(Color::Yellow), - )), - Line::from(""), - Line::from(vec![ - Span::styled("Timestamp: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(format_timestamp(block.timestamp)), - ]), - Line::from(""), - Line::from(vec![Span::styled( - format!("Transactions ({}): ", block.tx_ids.len()), - Style::default().add_modifier(Modifier::BOLD), - )]), - Line::from(""), - ]; + let full_details = app.full_block_details.get(&block.height).cloned(); + let loading_details = app.loading_block_details == Some(block.height); + let block_focus = app.block_focus; - if block.tx_ids.is_empty() { - lines.push(Line::from(Span::styled( - " (no transactions)", - Style::default().fg(Color::DarkGray), - ))); - let paragraph = Paragraph::new(lines) - .block( - Block::default() - .borders(Borders::ALL) - .title("Block Details (ESC to go back)"), - ) - .wrap(Wrap { trim: false }); - f.render_widget(paragraph, area); - return; + // Split into left (block info) and right (transactions) panes + // Block details get 75%, transactions get 25% + let main_layout = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(75), Constraint::Percentage(25)]) + .split(area); + + // Left pane: Block details + render_block_info_pane( + f, + main_layout[0], + &block, + full_details.as_ref(), + loading_details, + block_focus, + ); + + // Right pane: Transactions list + render_block_transactions_pane(f, main_layout[1], &block, app); +} + +fn render_block_info_pane( + f: &mut Frame, + area: Rect, + block: &BlockEntry, + full_details: Option<&BlockDetails>, + loading: bool, + block_focus: BlockDetailsFocus, +) { + let label_style = Style::default() + .add_modifier(Modifier::BOLD) + .fg(Color::Cyan); + let value_style = Style::default().fg(Color::White); + let hash_style = Style::default().fg(Color::Green); + let dim_style = Style::default().fg(Color::DarkGray); + let accent_style = Style::default().fg(Color::Yellow); + + let mut lines = Vec::new(); + + // Header section + lines.push(Line::from(vec![Span::styled( + "═══ IDENTITY ═══", + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + )])); + lines.push(Line::from("")); + + // Height and Version + let version_str = if let Some(details) = full_details { + format!("v{}", details.version) + } else { + "".to_string() + }; + lines.push(Line::from(vec![ + Span::styled(" Height ", label_style), + Span::styled( + format!("{}", block.height), + accent_style.add_modifier(Modifier::BOLD), + ), + Span::raw(" "), + Span::styled( + version_str, + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + ), + ])); + + // Block ID + lines.push(Line::from(vec![Span::styled( + " Block ID ", label_style, + )])); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(hash_full_display(&block.block_id), hash_style), + ])); + + // Parent ID + lines.push(Line::from(vec![Span::styled( + " Parent ", label_style, + )])); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(hash_full_display(&block.parent), dim_style), + ])); + + lines.push(Line::from("")); + + // Consensus section + lines.push(Line::from(vec![Span::styled( + "═══ CONSENSUS ═══", + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + )])); + lines.push(Line::from("")); + + // Timestamp + lines.push(Line::from(vec![ + Span::styled(" Timestamp ", label_style), + Span::styled(format_timestamp(block.timestamp), value_style), + ])); + + if let Some(details) = full_details { + // Epoch Counter + lines.push(Line::from(vec![ + Span::styled(" Epoch ", label_style), + Span::styled(format!("{}", details.epoch_counter), value_style), + ])); + + // Proof of Work + let pow_display = if details.has_pow { + "✓ Present" + } else { + "✗ Missing" + }; + let pow_color = if details.has_pow { + Color::Green + } else { + Color::Red + }; + lines.push(Line::from(vec![ + Span::styled(" PoW ", label_style), + Span::styled(pow_display, Style::default().fg(pow_color)), + ])); + + // Target + if let Some(ref target) = details.target { + lines.push(Line::from(vec![Span::styled( + " Target ", label_style, + )])); + let target_display = truncate_bignum_display(&target.display, 40); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(target_display, dim_style), + ])); + } + + // Accumulated Work + if let Some(ref work) = details.accumulated_work { + lines.push(Line::from(vec![Span::styled( + " Acc. Work ", label_style, + )])); + let work_display = truncate_bignum_display(&work.display, 40); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(work_display, dim_style), + ])); + } + } else if loading { + lines.push(Line::from(vec![ + Span::styled(" ", dim_style), + Span::styled("Loading details...", Style::default().fg(Color::Yellow)), + ])); + } else { + lines.push(Line::from(vec![ + Span::styled(" ", dim_style), + Span::styled("(press Enter to load full details)", dim_style), + ])); } - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(lines.len() as u16), Constraint::Min(5)]) - .split(area); + lines.push(Line::from("")); + + // Content section + lines.push(Line::from(vec![Span::styled( + "═══ CONTENT ═══", + Style::default() + .fg(Color::Magenta) + .add_modifier(Modifier::BOLD), + )])); + lines.push(Line::from("")); + + // Transaction count + lines.push(Line::from(vec![ + Span::styled(" Tx Count ", label_style), + Span::styled(format!("{}", block.tx_ids.len()), accent_style), + ])); + + // Coinbase section (if we have full details) + if let Some(details) = full_details { + if let Some(ref coinbase) = details.coinbase { + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + " Coinbase Rewards:", label_style, + )])); + render_coinbase_lines(&mut lines, coinbase); + } + + // Message (if present) + if let Some(ref msg) = details.msg { + if !msg.decoded.is_empty() || !msg.raw.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + " Message ", label_style, + )])); + let msg_text = if !msg.decoded.is_empty() { + msg.decoded.clone() + } else if !msg.raw.is_empty() { + format!("(raw {} bytes)", msg.raw.len()) + } else { + "(empty)".to_string() + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(msg_text, dim_style), + ])); + } + } + } + + let is_focused = block_focus == BlockDetailsFocus::Block; + let border_style = if is_focused { + Style::default().fg(Color::Cyan) + } else { + Style::default().fg(Color::DarkGray) + }; let paragraph = Paragraph::new(lines) .block( Block::default() .borders(Borders::ALL) - .title("Block Details (ESC to go back)"), + .border_style(border_style) + .title(Span::styled( + " Block Details ", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )), ) .wrap(Wrap { trim: false }); - f.render_widget(paragraph, layout[0]); + f.render_widget(paragraph, area); +} + +fn render_coinbase_lines( + lines: &mut Vec, + coinbase: &nockapp_grpc_proto::pb::public::v2::CoinbaseSplit, +) { + use nockapp_grpc_proto::pb::public::v2::coinbase_split::Version; + let dim_style = Style::default().fg(Color::DarkGray); + let value_style = Style::default().fg(Color::Green); + + match &coinbase.version { + Some(Version::V0(v0)) => { + if v0.entry_count > 0 { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(format!("{} recipient(s) ", v0.entry_count), value_style), + Span::styled(v0.note.clone(), dim_style), + ])); + } else if !v0.note.is_empty() { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(v0.note.clone(), dim_style), + ])); + } else { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("(v0 legacy format)", dim_style), + ])); + } + } + Some(Version::V1(v1)) => { + if v1.entries.is_empty() { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("(no entries)", dim_style), + ])); + } else { + for entry in &v1.entries { + let lock_hash = entry + .lock_hash + .as_ref() + .map(|h| truncate_str(&h.hash, 16)) + .unwrap_or_else(|| "???".to_string()); + let amount = entry + .amount + .as_ref() + .map(|a| format_nicks(a.value)) + .unwrap_or_else(|| "0".to_string()); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(lock_hash, dim_style), + Span::raw(" → "), + Span::styled(amount, value_style), + ])); + } + } + } + None => { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("(unknown format)", dim_style), + ])); + } + } +} + +fn render_block_transactions_pane(f: &mut Frame, area: Rect, block: &BlockEntry, app: &mut App) { + if block.tx_ids.is_empty() { + let text = Paragraph::new(vec![ + Line::from(""), + Line::from(Span::styled( + " No transactions in this block", + Style::default().fg(Color::DarkGray), + )), + ]) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::DarkGray)) + .title(" Transactions "), + ); + f.render_widget(text, area); + return; + } let tx_items: Vec = block .tx_ids @@ -1802,28 +2406,71 @@ fn render_block_details(f: &mut Frame, area: Rect, app: &mut App, idx: usize) { .map(|(i, tx)| { ListItem::new(Line::from(vec![ Span::styled( - format!("[{:3}] ", i + 1), + format!(" {:3} ", i + 1), Style::default().fg(Color::DarkGray), ), - Span::styled(&tx.hash, Style::default().fg(Color::Cyan)), + Span::styled(truncate_str(&tx.hash, 44), Style::default().fg(Color::Cyan)), ])) }) .collect(); - let title = match app.block_focus { - BlockDetailsFocus::Transactions => "Transactions (Enter to inspect, c copies tx id)", - BlockDetailsFocus::Block => "Transactions (Tab to focus, Enter opens details)", + let is_focused = app.block_focus == BlockDetailsFocus::Transactions; + let border_style = if is_focused { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::DarkGray) + }; + + let title = if is_focused { + format!( + " Transactions ({}) [Enter: details, c: copy] ", + block.tx_ids.len() + ) + } else { + format!(" Transactions ({}) [Tab to focus] ", block.tx_ids.len()) }; let list = List::new(tx_items) - .block(Block::default().borders(Borders::ALL).title(title)) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(border_style) + .title(Span::styled( + title, + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )), + ) .highlight_style( Style::default() .bg(Color::DarkGray) + .fg(Color::White) .add_modifier(Modifier::BOLD), ) - .highlight_symbol(">> "); - f.render_stateful_widget(list, layout[1], &mut app.tx_list_state); + .highlight_symbol("▶ "); + f.render_stateful_widget(list, area, &mut app.tx_list_state); +} + +fn truncate_bignum_display(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + s.to_string() + } else { + format!("{}...", &s[..max_len.saturating_sub(3)]) + } +} + +fn truncate_str(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + s.to_string() + } else { + format!("{}…", &s[..max_len.saturating_sub(1)]) + } +} + +fn format_nicks(nicks: u64) -> String { + let nock = nicks as f64 / NICKS_PER_NOCK as f64; + format!("{} NOCK", format_nock_value(nock)) } fn render_transaction_details( @@ -1967,14 +2614,39 @@ fn build_tx_header_lines( Span::raw(" Size: "), Span::raw(format!("{} bytes", format_number(details.size_bytes))), ])); + // Totals section - multi-line format + lines.push(Line::from(Span::styled( + "Totals:", + Style::default().add_modifier(Modifier::BOLD), + ))); + lines.push(Line::from(vec![ + Span::raw(" in: "), + Span::raw(format_amount(details.total_input.as_ref())), + ])); lines.push(Line::from(vec![ - Span::styled("Totals: ", Style::default().add_modifier(Modifier::BOLD)), - Span::raw(format!( - "in {} | out {} | fee {}", - format_amount(details.total_input.as_ref()), - format_amount(details.total_output.as_ref()), - format_amount(details.fee.as_ref()) - )), + Span::raw(" total out: "), + Span::raw(format_amount(get_total_output_nicks( + &details.total_output_required, + ))), + ])); + // Calculate net sent = total_out - fee + let total_out_nicks = get_total_output_nicks(&details.total_output_required) + .map(|n| n.value) + .unwrap_or(0); + let fee_nicks = get_fee_nicks(&details.fee_required) + .map(|n| n.value) + .unwrap_or(0); + let net_sent_nicks = total_out_nicks.saturating_sub(fee_nicks); + let net_sent = pb_common::Nicks { + value: net_sent_nicks, + }; + lines.push(Line::from(vec![ + Span::raw(" net sent: "), + Span::raw(format_amount(Some(&net_sent))), + ])); + lines.push(Line::from(vec![ + Span::raw(" fee: "), + Span::raw(format_amount(get_fee_nicks(&details.fee_required))), ])); lines.push(Line::from("")); lines.push(Line::from(vec![ @@ -2089,7 +2761,10 @@ fn build_tx_output_lines(details: &RpcTransactionDetails) -> Vec> Style::default().fg(Color::DarkGray), ), Span::styled( - format!("{} ", format_amount(output.amount.as_ref())), + format!( + "{} ", + format_amount(get_output_amount_nicks(&output.amount_required)) + ), Style::default().fg(Color::LightGreen), ), Span::styled( @@ -2168,6 +2843,34 @@ fn format_amount(amount: Option<&pb_common::Nicks>) -> String { ) } +/// Extract Nicks from total_output oneof wrapper +fn get_total_output_nicks( + oneof: &Option, +) -> Option<&pb_common::Nicks> { + match oneof { + Some(transaction_details::TotalOutputRequired::TotalOutput(nicks)) => Some(nicks), + None => None, + } +} + +/// Extract Nicks from fee oneof wrapper +fn get_fee_nicks(oneof: &Option) -> Option<&pb_common::Nicks> { + match oneof { + Some(transaction_details::FeeRequired::Fee(nicks)) => Some(nicks), + None => None, + } +} + +/// Extract Nicks from output amount oneof wrapper +fn get_output_amount_nicks( + oneof: &Option, +) -> Option<&pb_common::Nicks> { + match oneof { + Some(transaction_output::AmountRequired::Amount(nicks)) => Some(nicks), + None => None, + } +} + fn format_wallet_nocks(value: u64) -> String { let nock = value as f64 / NICKS_PER_NOCK as f64; format!("{} nock", format_nock_value(nock)) @@ -2373,23 +3076,7 @@ fn render_status_bar(f: &mut Frame, area: Rect, app: &App) { ])] } else { let age = app.last_refresh.elapsed().as_secs(); - let refresh_status = if app.auto_refresh_enabled { - if matches!(app.view, View::BlocksList) && app.viewing_tip() { - format!("Auto-refresh: ON (last: {}s ago)", age) - } else if matches!(app.view, View::BlocksList) { - format!( - "Auto-refresh: PAUSED (scroll to newest block to resume, last: {}s ago)", - age - ) - } else { - format!( - "Auto-refresh: PAUSED (viewing other tab, last: {}s ago)", - age - ) - } - } else { - format!("Auto-refresh: OFF (last: {}s ago)", age) - }; + let refresh_status = format!("Last refresh: {}s ago (manual)", age); let mut spans = vec![ Span::styled("Ready", Style::default().fg(Color::Green)), Span::raw(" | "), @@ -2415,6 +3102,10 @@ fn render_status_bar(f: &mut Frame, area: Rect, app: &App) { let status = Paragraph::new(status_text).block(Block::default().borders(Borders::ALL).title("Status")); f.render_widget(status, area); + + if app.is_busy() { + render_busy_overlay(f, app); + } } fn format_duration(duration: Duration) -> String { @@ -2431,7 +3122,7 @@ fn format_duration(duration: Duration) -> String { fn render_help(f: &mut Frame, area: Rect, app: &App) { let help_text = match &app.view { View::BlocksList => { - "↑: Up | ↓: Down (auto-loads older blocks) | PgUp/PgDn: Jump | Enter: View block | c: Copy block id | t: Search TX | r: Refresh | a: Auto-refresh | n: Next Page | s: Sync all pages | Tab/Shift+Tab: Switch tabs | ?: Help | q: Quit" + "↑: Up | ↓: Down (auto-loads older blocks) | PgUp/PgDn: Jump | Enter: View block | c: Copy block id | t: Search TX | r: Refresh | n: Next Page | s: Sync all pages | Tab/Shift+Tab: Switch tabs | ?: Help | q: Quit" } View::TransactionsList => { "↑: Up | ↓: Down | PgUp/PgDn: Jump | Home/End: First/last | Enter: View tx | Esc: Back to blocks | Tab/Shift+Tab: Switch tabs | n/p: Next/Prev tx | s: Sync all pages | ?: Help | q: Quit" @@ -2440,12 +3131,15 @@ fn render_help(f: &mut Frame, area: Rect, app: &App) { "↑: Up | ↓: Down | PgUp/PgDn: Jump | Home/End: First/last | b/r/e/t: Sort balance/recv/sent/tx | o: Toggle order | Tab/Shift+Tab: Switch tabs | s: Sync all pages | ?: Help | q: Quit" } View::BlockDetails(_) => { - "ESC: Back | PgUp/PgDn: Prev/next block | Tab: Toggle tx focus | ↑/↓ (tx focus): Move selection | Enter: TX details | c: Copy tx id (tx focus) | n/p: Next/Prev tx | ?: Help | q: Quit" + "ESC: Back | ↑↓/PgUp/PgDn: Navigate blocks | Tab: Toggle focus | Enter: TX details | c: Copy tx | n/p: Next/Prev tx | ?: Help | q: Quit" } View::TransactionDetails { .. } => "ESC: Back | Tab: Switch pane | ↑/↓/PgUp/PgDn/Home/End: Scroll pane | n/p: Next/Prev tx | c: Copy tx id | ?: Help | q: Quit", View::TransactionSearch => { "ESC: Back | Enter: Search (prefix ok) | Ctrl+V/Ctrl+Shift+V: Paste | Ctrl+C: Clear | ?: Help | q: Quit" } + View::Metrics => { + "ESC: Back | r: Refresh metrics | Tab/Shift+Tab: Switch tabs | ?: Help | q: Quit" + } View::Help => "ESC/q/?: Close help", }; @@ -2590,17 +3284,72 @@ impl Drop for TerminalGuard { } } +fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(area); + let horizontal = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(vertical[1]); + horizontal[1] +} + +fn render_busy_overlay(f: &mut Frame, app: &App) { + if !app.is_busy() { + return; + } + let area = f.area(); + let popup_area = centered_rect(50, 20, area); + let frame = SPINNER_FRAMES[app.spinner_index % SPINNER_FRAMES.len()]; + let color = SPINNER_COLORS[app.spinner_index % SPINNER_COLORS.len()]; + let lines = vec![ + Line::from(vec![Span::styled( + "Working…", + Style::default().add_modifier(Modifier::BOLD), + )]), + Line::from(vec![ + Span::styled( + frame, + Style::default().fg(color).add_modifier(Modifier::BOLD), + ), + Span::raw(" Please wait while the request completes…"), + ]), + ]; + f.render_widget(Clear, popup_area); + f.render_widget( + Paragraph::new(lines).alignment(Alignment::Center).block( + Block::default() + .borders(Borders::ALL) + .title("Loading") + .border_style(Style::default().fg(Color::Cyan)), + ), + popup_area, + ); +} + #[tracing::instrument(name = "tui.run_app", skip(terminal, app))] async fn run_app( terminal: &mut Terminal>, mut app: App, ) -> Result<()> { let tick_rate = Duration::from_millis(250); - let auto_refresh_interval = Duration::from_secs(10); let mut last_tick = Instant::now(); loop { terminal.draw(|f| ui(f, &mut app))?; + if app.shutdown_flag.load(AtomicOrdering::Acquire) { + return Ok(()); + } app.poll_wallet_worker(); let timeout = tick_rate @@ -2614,11 +3363,14 @@ async fn run_app( app.note_user_action(); match &app.view { View::BlocksList => match key.code { - KeyCode::Char('q') => return Ok(()), + KeyCode::Char('q') => { + app.request_shutdown(); + return Ok(()); + } KeyCode::Down => { app.move_selection_down(); if app.should_auto_fetch_more() { - app.load_next_page().await?; + app.request_next_page = true; } } KeyCode::Up => { @@ -2627,7 +3379,7 @@ async fn run_app( KeyCode::PageDown => { app.page_down(); if app.should_auto_fetch_more() { - app.load_next_page().await?; + app.request_next_page = true; } } KeyCode::PageUp => { @@ -2657,6 +3409,14 @@ async fn run_app( BlockDetailsFocus::Transactions }; app.sync_tx_list_selection(); + // Load full block details + if let Some(block) = app.blocks.get(idx) { + let height = block.height; + app.start_busy(); + terminal.draw(|f| ui(f, &mut app))?; + let _ = app.load_full_block_details(height).await; + app.stop_busy(); + } } } KeyCode::Char('t') => { @@ -2665,25 +3425,37 @@ async fn run_app( app.tx_search_result = None; } KeyCode::Char('r') => { - app.refresh().await?; - } - KeyCode::Char('a') => { - app.auto_refresh_enabled = !app.auto_refresh_enabled; + app.start_busy(); + terminal.draw(|f| ui(f, &mut app))?; + let res = app.refresh().await; + app.stop_busy(); + res?; } KeyCode::Char('n') => { if app.has_more_pages { - app.load_next_page().await?; + app.start_busy(); + terminal.draw(|f| ui(f, &mut app))?; + let res = app.load_next_page().await; + app.stop_busy(); + res?; } } KeyCode::Char('s') => { - app.sync_all_blocks().await?; + app.start_busy(); + terminal.draw(|f| ui(f, &mut app))?; + let res = app.sync_all_blocks().await; + app.stop_busy(); + res?; } KeyCode::Tab => app.cycle_tabs(1), KeyCode::BackTab => app.cycle_tabs(-1), _ => {} }, View::TransactionsList => match key.code { - KeyCode::Char('q') => return Ok(()), + KeyCode::Char('q') => { + app.request_shutdown(); + return Ok(()); + } KeyCode::Esc => { app.set_view(View::BlocksList); } @@ -2709,23 +3481,42 @@ async fn run_app( } KeyCode::Enter => { if let Some(idx) = app.tx_overview_state.selected() { - app.open_transaction_from_global_index(idx).await?; + app.start_busy(); + terminal.draw(|f| ui(f, &mut app))?; + let res = app.open_transaction_from_global_index(idx).await; + app.stop_busy(); + res?; } } KeyCode::Char('n') => { - app.navigate_transaction_delta(1).await?; + app.start_busy(); + terminal.draw(|f| ui(f, &mut app))?; + let res = app.navigate_transaction_delta(1).await; + app.stop_busy(); + res?; } KeyCode::Char('p') => { - app.navigate_transaction_delta(-1).await?; + app.start_busy(); + terminal.draw(|f| ui(f, &mut app))?; + let res = app.navigate_transaction_delta(-1).await; + app.stop_busy(); + res?; } KeyCode::Char('s') => { - app.sync_all_blocks().await?; + app.start_busy(); + terminal.draw(|f| ui(f, &mut app))?; + let res = app.sync_all_blocks().await; + app.stop_busy(); + res?; } KeyCode::Char('?') => app.open_help(), _ => {} }, View::WalletsList => match key.code { - KeyCode::Char('q') => return Ok(()), + KeyCode::Char('q') => { + app.request_shutdown(); + return Ok(()); + } KeyCode::Esc => app.set_view(View::BlocksList), KeyCode::Tab => app.cycle_tabs(1), KeyCode::BackTab => app.cycle_tabs(-1), @@ -2747,13 +3538,38 @@ async fn run_app( KeyCode::Char('t') => app.set_wallet_sort_key(WalletSortKey::TxCount), KeyCode::Char('o') => app.toggle_wallet_sort_order(), KeyCode::Char('s') => { - app.sync_all_blocks().await?; + app.start_busy(); + terminal.draw(|f| ui(f, &mut app))?; + let res = app.sync_all_blocks().await; + app.stop_busy(); + res?; + } + KeyCode::Char('?') => app.open_help(), + _ => {} + }, + View::Metrics => match key.code { + KeyCode::Char('q') => { + app.request_shutdown(); + return Ok(()); + } + KeyCode::Esc => app.set_view(View::BlocksList), + KeyCode::Tab => app.cycle_tabs(1), + KeyCode::BackTab => app.cycle_tabs(-1), + KeyCode::Char('r') => { + app.metrics_error = None; + app.start_busy(); + terminal.draw(|f| ui(f, &mut app))?; + let _ = app.load_metrics().await; + app.stop_busy(); } KeyCode::Char('?') => app.open_help(), _ => {} }, View::BlockDetails(_) => match key.code { - KeyCode::Char('q') => return Ok(()), + KeyCode::Char('q') => { + app.request_shutdown(); + return Ok(()); + } KeyCode::Esc => { app.set_view(View::BlocksList); app.block_focus = BlockDetailsFocus::Block; @@ -2773,6 +3589,13 @@ async fn run_app( } else if let Some(idx) = app.select_first_block() { app.set_view(View::BlockDetails(idx)); app.sync_tx_list_selection(); + if let Some(block) = app.blocks.get(idx) { + let height = block.height; + app.start_busy(); + terminal.draw(|f| ui(f, &mut app))?; + let _ = app.load_full_block_details(height).await; + app.stop_busy(); + } } } KeyCode::End => { @@ -2781,13 +3604,28 @@ async fn run_app( } else if let Some(idx) = app.select_last_block() { app.set_view(View::BlockDetails(idx)); app.sync_tx_list_selection(); + if let Some(block) = app.blocks.get(idx) { + let height = block.height; + app.start_busy(); + terminal.draw(|f| ui(f, &mut app))?; + let _ = app.load_full_block_details(height).await; + app.stop_busy(); + } } } KeyCode::Char('n') => { - app.navigate_transaction_delta(1).await?; + app.start_busy(); + terminal.draw(|f| ui(f, &mut app))?; + let res = app.navigate_transaction_delta(1).await; + app.stop_busy(); + res?; } KeyCode::Char('p') => { - app.navigate_transaction_delta(-1).await?; + app.start_busy(); + terminal.draw(|f| ui(f, &mut app))?; + let res = app.navigate_transaction_delta(-1).await; + app.stop_busy(); + res?; } KeyCode::Char('c') => { if let Some(tx_id) = app.selected_tx_id() { @@ -2800,7 +3638,12 @@ async fn run_app( if let (Some(block_idx), Some(tx_idx)) = (app.list_state.selected(), app.selected_tx_index()) { - app.open_transaction_detail(block_idx, tx_idx).await?; + app.start_busy(); + terminal.draw(|f| ui(f, &mut app))?; + let res = + app.open_transaction_detail(block_idx, tx_idx).await; + app.stop_busy(); + res?; } } } @@ -2810,8 +3653,17 @@ async fn run_app( } else if let Some(idx) = app.move_selection_down() { app.set_view(View::BlockDetails(idx)); app.sync_tx_list_selection(); + if let Some(block) = app.blocks.get(idx) { + let height = block.height; + // Queue fetch if not cached (non-blocking) + if !app.full_block_details.contains_key(&height) { + app.priority_prefetch_queue.push_front(height); + } + // Queue adjacent blocks for predictive prefetch + app.queue_adjacent_prefetch(idx); + } if app.should_auto_fetch_more() { - app.load_next_page().await?; + app.request_next_page = true; } } } @@ -2821,14 +3673,32 @@ async fn run_app( } else if let Some(idx) = app.move_selection_up() { app.set_view(View::BlockDetails(idx)); app.sync_tx_list_selection(); + if let Some(block) = app.blocks.get(idx) { + let height = block.height; + // Queue fetch if not cached (non-blocking) + if !app.full_block_details.contains_key(&height) { + app.priority_prefetch_queue.push_front(height); + } + // Queue adjacent blocks for predictive prefetch + app.queue_adjacent_prefetch(idx); + } } } KeyCode::PageDown => { if let Some(idx) = app.page_down() { app.set_view(View::BlockDetails(idx)); app.sync_tx_list_selection(); + if let Some(block) = app.blocks.get(idx) { + let height = block.height; + // Queue fetch if not cached (non-blocking) + if !app.full_block_details.contains_key(&height) { + app.priority_prefetch_queue.push_front(height); + } + // Queue adjacent blocks for predictive prefetch + app.queue_adjacent_prefetch(idx); + } if app.should_auto_fetch_more() { - app.load_next_page().await?; + app.request_next_page = true; } } } @@ -2836,12 +3706,24 @@ async fn run_app( if let Some(idx) = app.page_up() { app.set_view(View::BlockDetails(idx)); app.sync_tx_list_selection(); + if let Some(block) = app.blocks.get(idx) { + let height = block.height; + // Queue fetch if not cached (non-blocking) + if !app.full_block_details.contains_key(&height) { + app.priority_prefetch_queue.push_front(height); + } + // Queue adjacent blocks for predictive prefetch + app.queue_adjacent_prefetch(idx); + } } } _ => {} }, View::TransactionDetails { block_idx, .. } => match key.code { - KeyCode::Char('q') => return Ok(()), + KeyCode::Char('q') => { + app.request_shutdown(); + return Ok(()); + } KeyCode::Esc => { app.set_view(View::BlockDetails(*block_idx)); app.block_focus = BlockDetailsFocus::Transactions; @@ -2875,15 +3757,26 @@ async fn run_app( app.end_tx_pane(); } KeyCode::Char('n') => { - app.navigate_transaction_delta(1).await?; + app.start_busy(); + terminal.draw(|f| ui(f, &mut app))?; + let res = app.navigate_transaction_delta(1).await; + app.stop_busy(); + res?; } KeyCode::Char('p') => { - app.navigate_transaction_delta(-1).await?; + app.start_busy(); + terminal.draw(|f| ui(f, &mut app))?; + let res = app.navigate_transaction_delta(-1).await; + app.stop_busy(); + res?; } _ => {} }, View::TransactionSearch => match key.code { - KeyCode::Char('q') => return Ok(()), + KeyCode::Char('q') => { + app.request_shutdown(); + return Ok(()); + } KeyCode::Esc => app.set_view(View::BlocksList), KeyCode::Char('?') => app.open_help(), KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -2898,7 +3791,11 @@ async fn run_app( KeyCode::Enter => { if !app.tx_search_input.is_empty() { let search_input = app.tx_search_input.clone(); - app.search_transaction(&search_input).await?; + app.start_busy(); + terminal.draw(|f| ui(f, &mut app))?; + let res = app.search_transaction(&search_input).await; + app.stop_busy(); + res?; } } KeyCode::Backspace => { @@ -2935,20 +3832,76 @@ async fn run_app( } if last_tick.elapsed() >= tick_rate { - // Auto-reconnect if disconnected - if app.should_retry_connection() { + // Check for pending user input - skip slow operations if user is interacting + let has_pending_input = crossterm::event::poll(Duration::ZERO).unwrap_or(false); + + // Auto-reconnect if disconnected (skip if user has pending input) + if !has_pending_input && app.should_retry_connection() { let _ = app.attempt_reconnect().await; // Don't fail on reconnect error } - // Auto-refresh blocks if connected - if app.should_auto_refresh_blocks(auto_refresh_interval) { - let _ = app.refresh().await; + // Process priority prefetch first (user-navigated blocks) + // Skip if user has pending input - prioritize UI responsiveness + if !has_pending_input + && !app.prefetch_in_progress + && !app.priority_prefetch_queue.is_empty() + && app.connection_status == ConnectionStatus::Connected + { + if let Some(height) = app.priority_prefetch_queue.pop_front() { + if !app.full_block_details.contains_key(&height) { + app.prefetch_in_progress = true; + let _ = app.load_full_block_details(height).await; + app.prefetch_in_progress = false; + } + } + } + + // Process background prefetch of block details (one at a time to avoid blocking UI) + // Skip if user has pending input - prioritize UI responsiveness + if !has_pending_input + && !app.prefetch_in_progress + && !app.prefetch_queue.is_empty() + && app.connection_status == ConnectionStatus::Connected + { + if let Some(height) = app.prefetch_queue.pop_front() { + if !app.full_block_details.contains_key(&height) { + app.prefetch_in_progress = true; + let _ = app.load_full_block_details(height).await; + app.prefetch_in_progress = false; + } + } + } + + // Handle deferred page load request (from non-blocking navigation) + // Skip if user has pending input - prioritize UI responsiveness + if !has_pending_input + && app.request_next_page + && !app.prefetch_in_progress + && app.connection_status == ConnectionStatus::Connected + { + app.request_next_page = false; + let _ = app.load_next_page().await; } + last_tick = Instant::now(); } } } +/// Check if CRASH_HAPPY env var is set. If so, panic with detailed error info. +/// This is useful for debugging deserialization errors during development. +fn crash_happy_check(context: &str, error: &impl std::fmt::Debug) { + if env::var("CRASH_HAPPY").is_ok() { + panic!( + "\n\n=== CRASH_HAPPY TRIGGERED ===\n\ + Context: {}\n\ + Error: {:#?}\n\ + =============================\n", + context, error + ); + } +} + fn init_tracing() -> Result<()> { let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")); let fmt_layer = fmt::layer().with_target(false); @@ -3112,9 +4065,11 @@ fn accumulate_wallet_delta( for output in &details.outputs { if let Some(address) = normalize_wallet_label(&output.note_name_b58) { let entry = map.entry(address.clone()).or_default(); - entry.total_received = entry - .total_received - .saturating_add(output.amount.as_ref().map(|n| n.value).unwrap_or(0)); + entry.total_received = entry.total_received.saturating_add( + get_output_amount_nicks(&output.amount_required) + .map(|n| n.value) + .unwrap_or(0), + ); touched.insert(address); } } @@ -3154,9 +4109,8 @@ fn render_help_menu(f: &mut Frame, area: Rect, app: &mut App) { "PgDn Jump down by 20 blocks (auto-loads older)", "Enter View selected block", "c Copy selected block id", "Tab/Shift+Tab Switch between tabs", "t Open transaction search", - "r Refresh newest page", "a Toggle auto-refresh", - "n Fetch next page now", "s Sync all pages", - "? Show this help", "q Quit", + "r Refresh newest page", "n Fetch next page now", + "s Sync all pages", "? Show this help", "q Quit", ], ), ( @@ -3178,6 +4132,13 @@ fn render_help_menu(f: &mut Frame, area: Rect, app: &mut App) { "s Sync all pages", "? Show this help", ], ), + ( + "Metrics", + vec![ + "r Refresh explorer metrics", "Tab/Shift+Tab Switch tabs", + "ESC/q/? Close help", + ], + ), ( "Block Details", vec![ diff --git a/crates/nockchain-types/src/tx_engine/common/mod.rs b/crates/nockchain-types/src/tx_engine/common/mod.rs index 08e9a561e..b6c0e6373 100644 --- a/crates/nockchain-types/src/tx_engine/common/mod.rs +++ b/crates/nockchain-types/src/tx_engine/common/mod.rs @@ -1,3 +1,5 @@ +pub mod page; + use anyhow::Result; use ibig::{ubig, UBig}; use nockchain_math::belt::{Belt, PRIME}; @@ -7,6 +9,7 @@ use nockchain_math::zoon::common::DefaultTipHasher; use nockchain_math::zoon::zmap; use nockvm::noun::{Noun, NounAllocator, D}; use noun_serde::{NounDecode, NounDecodeError, NounEncode}; +pub use page::{BigNum, BlockId, CoinbaseSplit, Page, PageMsg}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, PartialEq, Eq, Hash, NounDecode, NounEncode)] diff --git a/crates/nockchain-types/src/tx_engine/common/page.rs b/crates/nockchain-types/src/tx_engine/common/page.rs new file mode 100644 index 000000000..2f5c934c2 --- /dev/null +++ b/crates/nockchain-types/src/tx_engine/common/page.rs @@ -0,0 +1,380 @@ +use ibig::UBig; +use nockvm::noun::{Noun, NounAllocator}; +use noun_serde::{NounDecode, NounDecodeError, NounEncode}; + +use super::{Hash, TxId}; + +pub type BlockId = Hash; + +/// Decode a z-set into a Vec +/// z-set structure: either `~` (atom 0) for empty, or `[n=item l=tree r=tree]` +fn decode_zset(noun: &Noun) -> Result, NounDecodeError> { + let mut result = Vec::new(); + collect_zset_items(noun, &mut result)?; + Ok(result) +} + +fn collect_zset_items( + noun: &Noun, + result: &mut Vec, +) -> Result<(), NounDecodeError> { + // Empty set is atom 0 + if let Ok(atom) = noun.as_atom() { + if atom.as_u64() == Ok(0) { + return Ok(()); + } + // Non-zero atom - shouldn't happen in a valid z-set + return Err(NounDecodeError::Custom( + "z-set: unexpected non-zero atom".into(), + )); + } + + // Non-empty set: [n=item l=tree r=tree] + let cell = noun.as_cell()?; + let n = cell.head(); + let lr = cell.tail().as_cell()?; + let l = lr.head(); + let r = lr.tail(); + + // Recursively collect from left subtree + collect_zset_items(&l, result)?; + + // Add the node item + let item = T::from_noun(&n)?; + result.push(item); + + // Recursively collect from right subtree + collect_zset_items(&r, result)?; + + Ok(()) +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BigNum(pub UBig); + +impl BigNum { + pub fn from_u64(value: u64) -> Self { + BigNum(UBig::from(value)) + } +} + +impl NounEncode for BigNum { + fn to_noun(&self, allocator: &mut A) -> Noun { + nockvm::noun::Atom::from_ubig(allocator, &self.0).as_noun() + } +} + +impl NounDecode for BigNum { + fn from_noun(noun: &Noun) -> Result { + // Bignum in Hoon is [%bn p=(list u32)] - a tagged cell with u32 chunks + if let Ok(cell) = noun.as_cell() { + // Check for %bn tag + if let Ok(tag) = cell.head().as_atom() { + // %bn = 0x6e62 = 28258 in little-endian ('bn' as cord) + let tag_val = tag.as_u64().unwrap_or(u64::MAX); + if tag_val == 28258 { + // Decode tail as list of u32 chunks (LSB first) + let mut chunks: Vec = Vec::new(); + let mut current = cell.tail(); + while let Ok(list_cell) = current.as_cell() { + let chunk = list_cell + .head() + .as_atom() + .map_err(|_| NounDecodeError::Custom("BigNum: chunk not atom".into()))? + .as_u64() + .map_err(|_| { + NounDecodeError::Custom("BigNum: chunk too large for u64".into()) + })?; + chunks.push(chunk as u32); + current = list_cell.tail(); + } + // current should now be 0 (end of list) + if let Ok(end) = current.as_atom() { + if end.as_u64() == Ok(0) { + // Reconstruct the number: chunks are in LSB order, each is 32 bits + let mut result = UBig::from(0u32); + for (i, chunk) in chunks.iter().enumerate() { + let shift = i * 32; + result += UBig::from(*chunk) << shift; + } + return Ok(BigNum(result)); + } + } + return Err(NounDecodeError::Custom( + "BigNum: list not terminated with 0".into(), + )); + } + // Cell but tag not 'bn' (28258) - report what tag we got + return Err(NounDecodeError::Custom(format!( + "BigNum: cell with unknown tag val={} (expected 28258 for %bn)", + tag_val + ))); + } + // Cell but head not atom + return Err(NounDecodeError::Custom( + "BigNum: cell but head not atom (expected %bn tag)".into(), + )); + } + // Fallback: try as raw atom (for compatibility) + let atom = noun.as_atom().map_err(|_| { + NounDecodeError::Custom("BigNum: expected atom or [%bn list] cell".into()) + })?; + let bytes = atom.as_ne_bytes(); + let ubig = UBig::from_le_bytes(bytes); + Ok(BigNum(ubig)) + } +} + +pub type PageMsg = Vec; + +/// Coinbase split - v0 is a byte list, v1 is a tagged z-map +/// For now we store the version tag and skip detailed parsing of v1 maps +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CoinbaseSplit { + /// V0: list of bytes (legacy format) + V0(Vec), + /// V1: [%1 (z-map hash coins)] - we don't parse the map details for now + V1, +} + +impl NounEncode for CoinbaseSplit { + fn to_noun(&self, allocator: &mut A) -> Noun { + match self { + CoinbaseSplit::V0(bytes) => bytes.to_noun(allocator), + CoinbaseSplit::V1 => { + // Encode as [%1 ~] for v1 (empty map placeholder) + let tag = nockvm::noun::D(1); + let empty_map = nockvm::noun::D(0); + nockvm::noun::T(allocator, &[tag, empty_map]) + } + } + } +} + +impl NounDecode for CoinbaseSplit { + fn from_noun(noun: &Noun) -> Result { + // Check if it's a tagged cell [%0 data] or [%1 data] + if let Ok(cell) = noun.as_cell() { + if let Ok(tag_atom) = cell.head().as_atom() { + if let Ok(tag) = tag_atom.as_u64() { + match tag { + 0 => { + // V0: [%0 byte-list] + let bytes = Vec::::from_noun(&cell.tail())?; + return Ok(CoinbaseSplit::V0(bytes)); + } + 1 => { + // V1: [%1 z-map] - we skip parsing the map for now + return Ok(CoinbaseSplit::V1); + } + _ => {} + } + } + } + } + // Fallback: try to decode as raw byte list (untagged v0) + if let Ok(bytes) = Vec::::from_noun(noun) { + return Ok(CoinbaseSplit::V0(bytes)); + } + // If all else fails, treat as v1 (unknown structure) + Ok(CoinbaseSplit::V1) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Page { + pub digest: BlockId, + pub pow: Option>, + pub parent: BlockId, + pub tx_ids: Vec, + pub coinbase: CoinbaseSplit, + pub timestamp: u64, + pub epoch_counter: u64, + pub target: BigNum, + pub accumulated_work: BigNum, + pub height: u64, + pub msg: PageMsg, +} + +impl NounEncode for Page { + fn to_noun(&self, allocator: &mut A) -> Noun { + let digest = self.digest.to_noun(allocator); + + let pow = if let Some(ref pow_data) = self.pow { + let bytes_noun = pow_data.to_noun(allocator); + nockvm::noun::T(allocator, &[nockvm::noun::D(0), bytes_noun]) + } else { + nockvm::noun::D(0) + }; + + let parent = self.parent.to_noun(allocator); + let tx_ids = self.tx_ids.to_noun(allocator); + let coinbase = self.coinbase.to_noun(allocator); + let timestamp = nockvm::noun::Atom::new(allocator, self.timestamp).as_noun(); + let epoch_counter = nockvm::noun::Atom::new(allocator, self.epoch_counter).as_noun(); + let target = self.target.to_noun(allocator); + let accumulated_work = self.accumulated_work.to_noun(allocator); + let height = nockvm::noun::Atom::new(allocator, self.height).as_noun(); + let msg = self.msg.to_noun(allocator); + + nockvm::noun::T( + allocator, + &[ + digest, pow, parent, tx_ids, coinbase, timestamp, epoch_counter, target, + accumulated_work, height, msg, + ], + ) + } +} + +impl NounDecode for Page { + fn from_noun(noun: &Noun) -> Result { + let cell = noun + .as_cell() + .map_err(|_| NounDecodeError::Custom("Page: expected cell at root".into()))?; + + // Check if this is a v1 page (head is atom %1) or v0 page (head is cell/digest) + let (digest, rest_after_digest) = + if cell.head().is_atom() { + // v1 page: [%1 digest pow parent ...] + let version = cell + .head() + .as_atom() + .map_err(|_| NounDecodeError::Custom("Page: version tag not atom".into()))? + .as_u64() + .map_err(|_| NounDecodeError::Custom("Page: version tag too large".into()))?; + if version != 1 { + return Err(NounDecodeError::Custom(format!( + "Page: unknown version: {}", + version + ))); + } + // Skip version tag, get digest from tail + let rest = cell.tail().as_cell().map_err(|_| { + NounDecodeError::Custom("Page: expected cell after version".into()) + })?; + let digest = BlockId::from_noun(&rest.head()) + .map_err(|e| NounDecodeError::Custom(format!("Page.digest: {}", e)))?; + let rest_after = rest.tail().as_cell().map_err(|_| { + NounDecodeError::Custom("Page: expected cell after digest".into()) + })?; + (digest, rest_after) + } else { + // v0 page: [digest pow parent ...] + let digest = BlockId::from_noun(&cell.head()) + .map_err(|e| NounDecodeError::Custom(format!("Page.digest(v0): {}", e)))?; + let rest_after = cell.tail().as_cell().map_err(|_| { + NounDecodeError::Custom("Page: expected cell after digest(v0)".into()) + })?; + (digest, rest_after) + }; + + // POW: (unit proof) - we just detect presence, don't decode the proof structure + let pow_noun = rest_after_digest.head(); + let pow = if pow_noun.is_atom() { + let atom = pow_noun + .as_atom() + .map_err(|_| NounDecodeError::Custom("Page.pow: expected atom".into()))?; + if atom.as_u64() == Ok(0) { + None + } else { + // Non-zero atom - treat as raw proof bytes + Some(atom.as_ne_bytes().to_vec()) + } + } else { + // Cell means [~ proof] - proof is present but we don't decode it + Some(vec![]) + }; + + let rest = rest_after_digest + .tail() + .as_cell() + .map_err(|_| NounDecodeError::Custom("Page: expected cell after pow".into()))?; + + let parent = BlockId::from_noun(&rest.head()) + .map_err(|e| NounDecodeError::Custom(format!("Page.parent: {}", e)))?; + + let rest = rest + .tail() + .as_cell() + .map_err(|_| NounDecodeError::Custom("Page: expected cell after parent".into()))?; + + let tx_ids = decode_zset::(&rest.head()) + .map_err(|e| NounDecodeError::Custom(format!("Page.tx_ids: {}", e)))?; + + let rest = rest + .tail() + .as_cell() + .map_err(|_| NounDecodeError::Custom("Page: expected cell after tx_ids".into()))?; + + let coinbase = CoinbaseSplit::from_noun(&rest.head()) + .map_err(|e| NounDecodeError::Custom(format!("Page.coinbase: {}", e)))?; + + let rest = rest + .tail() + .as_cell() + .map_err(|_| NounDecodeError::Custom("Page: expected cell after coinbase".into()))?; + + let timestamp = rest + .head() + .as_atom() + .map_err(|_| NounDecodeError::Custom("Page.timestamp: expected atom".into()))? + .as_u64() + .map_err(|_| NounDecodeError::Custom("Page.timestamp: too large".into()))?; + + let rest = rest + .tail() + .as_cell() + .map_err(|_| NounDecodeError::Custom("Page: expected cell after timestamp".into()))?; + + let epoch_counter = rest + .head() + .as_atom() + .map_err(|_| NounDecodeError::Custom("Page.epoch_counter: expected atom".into()))? + .as_u64() + .map_err(|_| NounDecodeError::Custom("Page.epoch_counter: too large".into()))?; + + let rest = rest.tail().as_cell().map_err(|_| { + NounDecodeError::Custom("Page: expected cell after epoch_counter".into()) + })?; + + let target = BigNum::from_noun(&rest.head()) + .map_err(|e| NounDecodeError::Custom(format!("Page.target: {}", e)))?; + + let rest = rest + .tail() + .as_cell() + .map_err(|_| NounDecodeError::Custom("Page: expected cell after target".into()))?; + + let accumulated_work = BigNum::from_noun(&rest.head()) + .map_err(|e| NounDecodeError::Custom(format!("Page.accumulated_work: {}", e)))?; + + let rest = rest.tail().as_cell().map_err(|_| { + NounDecodeError::Custom("Page: expected cell after accumulated_work".into()) + })?; + + let height = rest + .head() + .as_atom() + .map_err(|_| NounDecodeError::Custom("Page.height: expected atom".into()))? + .as_u64() + .map_err(|_| NounDecodeError::Custom("Page.height: too large".into()))?; + + let msg = PageMsg::from_noun(&rest.tail()) + .map_err(|e| NounDecodeError::Custom(format!("Page.msg: {}", e)))?; + + Ok(Page { + digest, + pow, + parent, + tx_ids, + coinbase, + timestamp, + epoch_counter, + target, + accumulated_work, + height, + msg, + }) + } +} From a19ad4dc66c81ec4d97134dd11a7425bc88b4d7b Mon Sep 17 00:00:00 2001 From: Sigilante <57601680+sigilante@users.noreply.github.com> Date: Fri, 5 Dec 2025 16:30:57 -0600 Subject: [PATCH 33/99] Post hotfix (#81) --- Cargo.lock | 1272 +++++++++++++++++++++++++--------------------------- Cargo.toml | 3 +- 2 files changed, 603 insertions(+), 672 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 482d27fb5..e7ec877af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,15 +2,6 @@ # It is not intended for manual editing. version = 3 -[[package]] -name = "addr2line" -version = "0.24.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" -dependencies = [ - "gimli", -] - [[package]] name = "adler2" version = "2.0.1" @@ -56,9 +47,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" dependencies = [ "memchr", ] @@ -86,9 +77,9 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[package]] name = "anstream" -version = "0.6.20" +version = "0.6.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae563653d1938f79b1ab1b5e668c87c76a9930414574a6583a7b7e11a8e6192" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" dependencies = [ "anstyle", "anstyle-parse", @@ -101,9 +92,9 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.11" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "862ed96ca487e809f1c8e5a8447f6ee2cf102f846893800b20cebdf541fc6bbd" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" [[package]] name = "anstyle-parse" @@ -116,22 +107,22 @@ dependencies = [ [[package]] name = "anstyle-query" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "anstyle-wincon" -version = "3.0.10" +version = "3.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -147,7 +138,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" dependencies = [ "clipboard-win", - "image 0.25.8", + "image 0.25.9", "log", "objc2", "objc2-app-kit", @@ -208,7 +199,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.16", + "thiserror 2.0.17", "time", ] @@ -220,7 +211,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", "synstructure", ] @@ -232,7 +223,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -265,7 +256,7 @@ dependencies = [ "polling", "rustix 1.1.2", "slab", - "windows-sys 0.61.0", + "windows-sys 0.61.2", ] [[package]] @@ -276,7 +267,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -298,7 +289,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -309,7 +300,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -360,9 +351,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.14.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94b8ff6c09cd57b16da53641caa860168b88c172a5ee163b0288d3d6eea12786" +checksum = "6b5ce75405893cd713f9ab8e297d8e438f624dde7d706108285f7e17a25a180f" dependencies = [ "aws-lc-sys", "zeroize", @@ -370,11 +361,10 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.31.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e44d16778acaf6a9ec9899b92cebd65580b83f685446bf2e1f5d3d732f99dcd" +checksum = "179c3777a8b5e70e90ea426114ffc565b2c1a9f82f6c4a0c5a34aa6ef5e781b6" dependencies = [ - "bindgen 0.72.1", "cc", "cmake", "dunce", @@ -383,9 +373,9 @@ dependencies = [ [[package]] name = "axum" -version = "0.8.4" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "021e862c184ae977658b36c4500f7feac3221ca5da43e3f25bd04ab6c79a29b5" +checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425" dependencies = [ "axum-core", "bytes", @@ -402,8 +392,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rustversion", - "serde", + "serde_core", "serde_json", "serde_path_to_error", "serde_urlencoded", @@ -417,9 +406,9 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.2" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68464cd0412f486726fb3373129ef5d2993f90c34bc2bc1c1e9943b2f4fc7ca6" +checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" dependencies = [ "bytes", "futures-core", @@ -428,7 +417,6 @@ dependencies = [ "http-body-util", "mime", "pin-project-lite", - "rustversion", "sync_wrapper", "tower-layer", "tower-service", @@ -437,9 +425,9 @@ dependencies = [ [[package]] name = "axum-server" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "495c05f60d6df0093e8fb6e74aa5846a0ad06abaf96d76166283720bf740f8ab" +checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9" dependencies = [ "arc-swap", "bytes", @@ -457,21 +445,6 @@ dependencies = [ "tower-service", ] -[[package]] -name = "backtrace" -version = "0.3.75" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6806a6321ec58106fea15becdad98371e28d92ccbc7c8f1b3b6dd724fe8f1002" -dependencies = [ - "addr2line", - "cfg-if", - "libc", - "miniz_oxide", - "object", - "rustc-demangle", - "windows-targets 0.52.6", -] - [[package]] name = "bardecoder" version = "0.5.0" @@ -491,6 +464,16 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" +[[package]] +name = "base256emoji" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e9430d9a245a77c92176e649af6e275f20839a48389859d1661e9a128d077c" +dependencies = [ + "const-str", + "match-lookup", +] + [[package]] name = "base58ck" version = "0.1.0" @@ -507,12 +490,6 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - [[package]] name = "base64" version = "0.22.1" @@ -527,9 +504,9 @@ checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" [[package]] name = "bech32" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d965446196e3b7decd44aa7ee49e31d630118f90ef12f97900f262eb915c951d" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" [[package]] name = "bincode" @@ -557,7 +534,7 @@ version = "0.69.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271383c67ccabffb7381723dea0672a673f292304fcb45c01cc648c7a8d58088" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "cexpr", "clang-sys", "itertools 0.12.1", @@ -570,30 +547,10 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn", + "syn 2.0.111", "which 4.4.2", ] -[[package]] -name = "bindgen" -version = "0.72.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" -dependencies = [ - "bitflags 2.9.4", - "cexpr", - "clang-sys", - "itertools 0.13.0", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 2.1.1", - "shlex", - "syn", -] - [[package]] name = "bit-set" version = "0.8.0" @@ -644,9 +601,9 @@ dependencies = [ [[package]] name = "bitcoin-io" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b47c4ab7a93edb0c7198c5535ed9b52b63095f4e9b45279c6736cec4b856baf" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" [[package]] name = "bitcoin-units" @@ -660,9 +617,9 @@ dependencies = [ [[package]] name = "bitcoin_hashes" -version = "0.14.0" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb18c03d0db0247e147a21a6faafd5a7eb851c743db062de72018b6b7e8e4d16" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" dependencies = [ "bitcoin-io", "hex-conservative", @@ -701,11 +658,11 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.4" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" +checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -780,9 +737,9 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "bytecheck" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50690fb3370fb9fe3550372746084c46f2ac8c9685c583d2be10eefd89d3d1a3" +checksum = "0caa33a2c0edca0419d15ac723dff03f1956f7978329b1e3b5fdaaaed9d3ca8b" dependencies = [ "bytecheck_derive", "ptr_meta", @@ -792,20 +749,20 @@ dependencies = [ [[package]] name = "bytecheck_derive" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efb7846e0cb180355c2dec69e721edafa36919850f1a9f52ffba4ebc0393cb71" +checksum = "89385e82b5d1821d2219e0b095efa2cc1f246cbf99080f3be46a1a85c0d392d9" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] name = "bytemuck" -version = "1.23.2" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3995eaeebcdf32f91f980d360f78732ddc061097ab4e39991ae7a6ace9194677" +checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" [[package]] name = "byteorder" @@ -821,18 +778,18 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" dependencies = [ "serde", ] [[package]] name = "camino" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1de8bc0aa9e9385ceb3bf0c152e3a9b9544f6c4a912c8ae504e80c1f0368603" +checksum = "276a59bf2b2c967788139340c9f0c5b12d7fd6630315c15c217e559de85d2609" dependencies = [ "serde_core", ] @@ -892,18 +849,18 @@ dependencies = [ [[package]] name = "cbor4ii" -version = "1.1.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db05e2e8488783316fbab37891db4ab5b31124dab16bb2161da145c03dd0de70" +checksum = "faed1a83001dc2c9201451030cc317e35bef36c84d3781d7c5bb9f343c397da8" dependencies = [ "serde", ] [[package]] name = "cc" -version = "1.2.38" +version = "1.2.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80f41ae168f955c12fb8960b057d70d0ca153fb83182b57d86380443527be7e9" +checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a" dependencies = [ "find-msvc-tools", "jobserver", @@ -922,9 +879,9 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" @@ -943,7 +900,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link 0.2.0", + "windows-link 0.2.1", ] [[package]] @@ -970,7 +927,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" dependencies = [ "ciborium-io", - "half 2.6.0", + "half 2.7.1", ] [[package]] @@ -996,9 +953,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.48" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2134bb3ea021b78629caa971416385309e0131b351b25e01dc16fb54e1b5fae" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" dependencies = [ "clap_builder", "clap_derive", @@ -1006,9 +963,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.48" +version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" dependencies = [ "anstream", "anstyle", @@ -1018,21 +975,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.47" +version = "4.5.49" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbfd7eae0b0f1a6e63d4b13c9c478de77c2eb546fba158ad50b4203dc24b9f9c" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] name = "clap_lex" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "clipboard-win" @@ -1110,9 +1067,9 @@ dependencies = [ [[package]] name = "config" -version = "0.15.17" +version = "0.15.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "680d3ac2fe066c43300ec831c978871e50113a708d58ab13d231bd92deca5adb" +checksum = "b30fa8254caad766fc03cb0ccae691e14bf3bd72bfff27f72802ce729551b3d6" dependencies = [ "async-trait", "convert_case 0.6.0", @@ -1154,6 +1111,12 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "const-str" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" + [[package]] name = "constant_time_eq" version = "0.3.1" @@ -1171,9 +1134,9 @@ dependencies = [ [[package]] name = "convert_case" -version = "0.7.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb402b8d4c85569410425650ce3eddc7d698ed96d39a73f941b08fb63082f1e7" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" dependencies = [ "unicode-segmentation", ] @@ -1297,7 +1260,7 @@ dependencies = [ "proc-macro2", "quote", "strict", - "syn", + "syn 2.0.111", ] [[package]] @@ -1362,9 +1325,9 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "crossterm_winapi", - "mio 1.0.4", + "mio 1.1.1", "parking_lot", "rustix 0.38.44", "signal-hook", @@ -1378,11 +1341,11 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "crossterm_winapi", "derive_more", "document-features", - "mio 1.0.4", + "mio 1.1.1", "parking_lot", "rustix 1.1.2", "signal-hook", @@ -1407,9 +1370,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-common" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", "rand_core 0.6.4", @@ -1449,7 +1412,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -1473,7 +1436,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.111", ] [[package]] @@ -1484,7 +1447,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -1524,7 +1487,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" dependencies = [ "data-encoding", - "syn", + "syn 2.0.111", ] [[package]] @@ -1562,9 +1525,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a41953f86f8a05768a6cda24def994fd2f424b04ec5c719cf89989779f199071" +checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", ] @@ -1587,7 +1550,7 @@ dependencies = [ "darling", "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -1597,28 +1560,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn", + "syn 2.0.111", ] [[package]] name = "derive_more" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +checksum = "10b768e943bed7bf2cab53df09f4bc34bfd217cdb57d971e769874c9a6710618" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +checksum = "6d286bfdaf75e988b4a78e013ecd79c581e06399ab53fbacd2d916c2f904f30b" dependencies = [ - "convert_case 0.7.1", + "convert_case 0.10.0", "proc-macro2", "quote", - "syn", + "rustc_version 0.4.1", + "syn 2.0.111", "unicode-xid", ] @@ -1657,7 +1621,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.61.0", + "windows-sys 0.61.2", ] [[package]] @@ -1666,7 +1630,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "objc2", ] @@ -1678,7 +1642,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -1692,9 +1656,9 @@ dependencies = [ [[package]] name = "document-features" -version = "0.2.11" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95249b50c6c185bee49034bcb378a49dc2b5dff0be90ff6616d31d64febab05d" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" dependencies = [ "litrs", ] @@ -1727,13 +1691,13 @@ version = "3.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f7d4c414c94bc830797115b8e5f434d58e7e80cb42ba88508c14bc6ea270625" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "byteorder", "lazy_static", "proc-macro-error2", "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -1796,7 +1760,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -1851,7 +1815,7 @@ dependencies = [ "arrayvec", "hashx", "num-traits", - "thiserror 2.0.16", + "thiserror 2.0.17", "visibility", ] @@ -1865,9 +1829,9 @@ dependencies = [ [[package]] name = "erased-serde" -version = "0.4.8" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "259d404d09818dec19332e31d94558aeb442fea04c817006456c24b5460bbd4b" +checksum = "89e8918065695684b2b0702da20382d5ae6065cf3327bc2d6436bd49a71ce9f3" dependencies = [ "serde", "serde_core", @@ -1881,7 +1845,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.0", + "windows-sys 0.61.2", ] [[package]] @@ -1892,12 +1856,12 @@ checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" [[package]] name = "exr" -version = "1.73.0" +version = "1.74.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f83197f59927b46c04a183a619b7c29df34e63e63c7869320862268c0ef687e0" +checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" dependencies = [ "bit_field", - "half 2.6.0", + "half 2.7.1", "lebe", "miniz_oxide", "rayon-core", @@ -1928,7 +1892,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -1960,9 +1924,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.2" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ced73b1dacfc750a6db6c0a0c3a3853c8b41997e2e2c563dc90804ae6867959" +checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" [[package]] name = "fixed-capacity-vec" @@ -2018,9 +1982,9 @@ dependencies = [ [[package]] name = "fs-err" -version = "3.1.2" +version = "3.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44f150ffc8782f35521cec2b23727707cb4045706ba3c854e86bef66b3a8cdbd" +checksum = "62d91fd049c123429b018c47887d3f75a265540dd3c30ba9cb7bae9197edb03a" dependencies = [ "autocfg", "tokio", @@ -2124,7 +2088,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -2200,12 +2164,12 @@ dependencies = [ [[package]] name = "gethostname" -version = "1.0.2" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc257fdb4038301ce4b9cd1b3b51704509692bb3ff716a410cbd07925d9dae55" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ "rustix 1.1.2", - "windows-targets 0.52.6", + "windows-link 0.2.1", ] [[package]] @@ -2217,21 +2181,21 @@ dependencies = [ "cfg-if", "js-sys", "libc", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi", - "wasi 0.14.7+wasi-0.2.4", + "wasip2", "wasm-bindgen", ] @@ -2245,12 +2209,6 @@ dependencies = [ "weezl", ] -[[package]] -name = "gimli" -version = "0.31.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" - [[package]] name = "glob" version = "0.3.3" @@ -2259,9 +2217,9 @@ checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" [[package]] name = "gnort" -version = "0.1.2" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1033ebf8ca0e0a249d8996a5979dcfd276d43b2818cb6045993a844250c49516" +checksum = "aef3660ec7d99e31b3d5481d5c2ea525934a38b18ca4d5a64bf53292cf9f1446" dependencies = [ "dashmap", "derive_more", @@ -2270,8 +2228,7 @@ dependencies = [ "maplit", "nonzero_ext", "once_cell", - "thiserror 2.0.16", - "tracing", + "thiserror 2.0.17", ] [[package]] @@ -2285,7 +2242,7 @@ dependencies = [ "futures-sink", "futures-timer", "futures-util", - "getrandom 0.3.3", + "getrandom 0.3.4", "no-std-compat", "nonzero_ext", "parking_lot", @@ -2324,12 +2281,13 @@ checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" [[package]] name = "half" -version = "2.6.0" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "459196ed295495a68f7d7fe1d84f6c4b7ff0e21fe3017b2f283c6fac3ad803c9" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ "cfg-if", "crunchy", + "zerocopy", ] [[package]] @@ -2345,7 +2303,7 @@ dependencies = [ "pest_derive", "serde", "serde_json", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] @@ -2367,9 +2325,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "hashlink" @@ -2392,7 +2350,7 @@ dependencies = [ "fixed-capacity-vec", "hex", "rand_core 0.9.3", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] @@ -2415,18 +2373,18 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hex-conservative" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5313b072ce3c597065a808dbf612c4c8e8590bdbf8b579508bf7a762c5eae6cd" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" dependencies = [ "arrayvec", ] [[package]] name = "hex-literal" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcaaec4551594c969335c98c903c1397853d4198408ea609190f420500f6be71" +checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" [[package]] name = "hex_lit" @@ -2453,7 +2411,7 @@ dependencies = [ "once_cell", "rand 0.9.2", "socket2 0.5.10", - "thiserror 2.0.16", + "thiserror 2.0.17", "tinyvec", "tokio", "tracing", @@ -2476,7 +2434,7 @@ dependencies = [ "rand 0.9.2", "resolv-conf", "smallvec", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tracing", ] @@ -2501,11 +2459,11 @@ dependencies = [ [[package]] name = "home" -version = "0.5.11" +version = "0.5.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589533453244b0995c858700322199b2becb13b627df2851f64a2775d024abcf" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2536,7 +2494,7 @@ dependencies = [ "nockvm", "nockvm_macros", "tempfile", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tracing", "tracing-subscriber", @@ -2546,12 +2504,11 @@ dependencies = [ [[package]] name = "http" -version = "1.3.1" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -2598,9 +2555,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "hyper" -version = "1.7.0" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3aa54a13a0dfe7fbe3a59e0c76093041720fdc77b110cc0fc260fafb4dc51e" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" dependencies = [ "atomic-waker", "bytes", @@ -2652,9 +2609,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.17" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6995591a8f1380fcb4ba966a252a4b29188d51d2b89e3a252f5305be65aea8" +checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ "base64 0.22.1", "bytes", @@ -2668,7 +2625,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.0", + "socket2 0.6.1", "tokio", "tower-service", "tracing", @@ -2686,7 +2643,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.0", + "windows-core 0.62.2", ] [[package]] @@ -2713,9 +2670,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" dependencies = [ "displaydoc", "potential_utf", @@ -2726,9 +2683,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" dependencies = [ "displaydoc", "litemap", @@ -2739,11 +2696,10 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" dependencies = [ - "displaydoc", "icu_collections", "icu_normalizer_data", "icu_properties", @@ -2754,42 +2710,38 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" dependencies = [ - "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", "icu_provider", - "potential_utf", "zerotrie", "zerovec", ] [[package]] name = "icu_properties_data" -version = "2.0.1" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" [[package]] name = "icu_provider" -version = "2.0.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" dependencies = [ "displaydoc", "icu_locale_core", - "stable_deref_trait", - "tinystr", "writeable", "yoke", "zerofrom", @@ -2898,9 +2850,9 @@ dependencies = [ [[package]] name = "image" -version = "0.25.8" +version = "0.25.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "529feb3e6769d234375c4cf1ee2ce713682b8e76538cb13f9fc23e1400a591e7" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" dependencies = [ "bytemuck", "byteorder-lite", @@ -2912,19 +2864,22 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.11.4" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" +checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", - "hashbrown 0.16.0", + "hashbrown 0.16.1", ] [[package]] name = "indoc" -version = "2.0.6" +version = "2.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] [[package]] name = "inotify" @@ -2957,15 +2912,15 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "435d80800b936787d62688c927b6490e887c7ef5ff9ce922c6c6050fca75eb9a" +checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c" dependencies = [ "darling", "indoc", "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -2992,20 +2947,9 @@ dependencies = [ [[package]] name = "intmap" -version = "3.1.2" +version = "3.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16dd999647b7a027fadf2b3041a4ea9c8ae21562823fe5cbdecd46537d535ae2" - -[[package]] -name = "io-uring" -version = "0.7.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046fa2d4d00aea763528b4950358d0ead425372445dc8ff86312b3c69ff7727b" -dependencies = [ - "bitflags 2.9.4", - "cfg-if", - "libc", -] +checksum = "a2e611826a1868311677fdcdfbec9e8621d104c732d080f546a854530232f0ee" [[package]] name = "ipconfig" @@ -3027,9 +2971,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iri-string" -version = "0.7.8" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" dependencies = [ "memchr", "serde", @@ -3037,20 +2981,20 @@ dependencies = [ [[package]] name = "is-terminal" -version = "0.4.16" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "is_terminal_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7943c866cc5cd64cbc25b2e01621d07fa8eb2a1a23160ee81ce38704e97b8ecf" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" @@ -3091,7 +3035,7 @@ version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", "libc", ] @@ -3106,9 +3050,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.81" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec48937a97411dcb524a265206ccd4c90bb711fca92b2792c407f268825b9305" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" dependencies = [ "once_cell", "wasm-bindgen", @@ -3169,9 +3113,9 @@ dependencies = [ [[package]] name = "lazy-regex" -version = "3.4.1" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60c7310b93682b36b98fa7ea4de998d3463ccbebd94d935d6b48ba5b6ffa7126" +checksum = "191898e17ddee19e60bccb3945aa02339e81edd4a8c50e21fd4d48cdecda7b29" dependencies = [ "lazy-regex-proc_macros", "once_cell", @@ -3180,14 +3124,14 @@ dependencies = [ [[package]] name = "lazy-regex-proc_macros" -version = "3.4.1" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ba01db5ef81e17eb10a5e0f2109d1b3a3e29bac3070fdbd7d156bf7dbd206a1" +checksum = "c35dc8b0da83d1a9507e12122c80dea71a9c7c613014347392483a83ea593e04" dependencies = [ "proc-macro2", "quote", "regex", - "syn", + "syn 2.0.111", ] [[package]] @@ -3210,9 +3154,9 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] name = "libc" -version = "0.2.176" +version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "libloading" @@ -3221,7 +3165,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link 0.2.0", + "windows-link 0.2.1", ] [[package]] @@ -3255,7 +3199,7 @@ dependencies = [ "multiaddr", "pin-project", "rw-stream-sink", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] @@ -3296,7 +3240,7 @@ dependencies = [ "quick-protobuf", "rand 0.8.5", "rw-stream-sink", - "thiserror 2.0.16", + "thiserror 2.0.17", "tracing", "unsigned-varint", "web-time", @@ -3333,7 +3277,7 @@ dependencies = [ "quick-protobuf", "quick-protobuf-codec", "smallvec", - "thiserror 2.0.16", + "thiserror 2.0.17", "tracing", ] @@ -3350,7 +3294,7 @@ dependencies = [ "quick-protobuf", "rand 0.8.5", "sha2", - "thiserror 2.0.16", + "thiserror 2.0.17", "tracing", "zeroize", ] @@ -3375,7 +3319,7 @@ dependencies = [ "rand 0.8.5", "sha2", "smallvec", - "thiserror 2.0.16", + "thiserror 2.0.17", "tracing", "uint", "web-time", @@ -3470,7 +3414,7 @@ dependencies = [ "ring", "rustls", "socket2 0.5.10", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tracing", ] @@ -3521,7 +3465,7 @@ source = "git+https://github.com/libp2p/rust-libp2p.git?rev=da0017ee887a868e231e dependencies = [ "heck", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -3552,7 +3496,7 @@ dependencies = [ "ring", "rustls", "rustls-webpki", - "thiserror 2.0.16", + "thiserror 2.0.17", "x509-parser", "yasna", ] @@ -3577,7 +3521,7 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "libc", "redox_syscall", ] @@ -3596,31 +3540,30 @@ checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" [[package]] name = "litemap" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" [[package]] name = "litrs" -version = "0.4.2" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5e54036fe321fd421e10d732f155734c4e4afd610dd556d9a82833ab3ee0bed" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "lock_api" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96936507f153605bddfcda068dd804796c84324ed2510809e5b2a624c81da765" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ - "autocfg", "scopeguard", ] [[package]] name = "log" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" [[package]] name = "loom" @@ -3656,6 +3599,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" +[[package]] +name = "match-lookup" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1265724d8cb29dbbc2b0f06fffb8bf1a8c0cf73a78eede9ba73a4a66c52a981e" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "matchers" version = "0.2.0" @@ -3673,15 +3627,15 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" -version = "2.7.5" +version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" [[package]] name = "memmap2" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843a98750cd611cc2965a8213b53b43e715f13c37a9e096c6408e69990961db7" +checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" dependencies = [ "libc", ] @@ -3755,20 +3709,20 @@ checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" dependencies = [ "libc", "log", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "windows-sys 0.48.0", ] [[package]] name = "mio" -version = "1.0.4" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78bed444cc8a2160f01cbcf811ef18cac863ad68ae8ca62092e8db51d51c761c" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" dependencies = [ "libc", "log", - "wasi 0.11.1+wasi-snapshot-preview1", - "windows-sys 0.59.0", + "wasi", + "windows-sys 0.61.2", ] [[package]] @@ -3791,9 +3745,9 @@ dependencies = [ [[package]] name = "moxcms" -version = "0.7.5" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd32fa8935aeadb8a8a6b6b351e40225570a37c43de67690383d87ef170cd08" +checksum = "80986bbbcf925ebd3be54c26613d861255284584501595cf418320c078945608" dependencies = [ "num-traits", "pxfm", @@ -3820,11 +3774,12 @@ dependencies = [ [[package]] name = "multibase" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b3539ec3c1f04ac9748a260728e855f261b4977f5c3406612c884564f329404" +checksum = "8694bb4835f452b0e3bb06dbebb1d6fc5385b6ca1caf2e55fd165c042390ec77" dependencies = [ "base-x", + "base256emoji", "data-encoding", "data-encoding-macro", ] @@ -3860,22 +3815,22 @@ dependencies = [ [[package]] name = "munge" -version = "0.4.6" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7feb0b48aa0a25f9fe0899482c6e1379ee7a11b24a53073eacdecb9adb6dc60" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" dependencies = [ "munge_macro", ] [[package]] name = "munge_macro" -version = "0.4.6" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2e3795a5d2da581a8b252fec6022eee01aea10161a4d1bf237d4cbe47f7e988" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -3890,7 +3845,7 @@ dependencies = [ name = "murmur3-sys" version = "0.1.0" dependencies = [ - "bindgen 0.69.5", + "bindgen", ] [[package]] @@ -3941,7 +3896,7 @@ dependencies = [ "log", "netlink-packet-core", "netlink-sys", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] @@ -4002,7 +3957,7 @@ dependencies = [ "dirs", "either", "futures", - "getrandom 0.3.3", + "getrandom 0.3.4", "gnort", "ibig", "instant-acme", @@ -4014,7 +3969,7 @@ dependencies = [ "opentelemetry-otlp", "opentelemetry_sdk", "rand 0.9.2", - "rcgen 0.14.4", + "rcgen 0.14.5", "rustls", "rustls-pemfile", "serde", @@ -4023,7 +3978,7 @@ dependencies = [ "signal-hook-tokio", "tempfile", "termimad", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tokio-rustls", "tokio-util", @@ -4051,6 +4006,7 @@ dependencies = [ "futures", "gnort", "hex", + "ibig", "nockapp", "nockapp-grpc-proto", "nockchain-math", @@ -4064,7 +4020,7 @@ dependencies = [ "prost-types", "serde", "tempfile", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tokio-stream", "tokio-test", @@ -4089,7 +4045,7 @@ dependencies = [ "prost 0.14.1", "prost-build", "prost-types", - "thiserror 2.0.16", + "thiserror 2.0.17", "tonic 0.14.2", "tonic-build", "tonic-health", @@ -4138,10 +4094,16 @@ dependencies = [ "clap", "kernels", "nockapp", + "nockapp-grpc-proto", "nockchain", + "nockchain-types", "nockvm", + "noun-serde", + "tempfile", "tikv-jemallocator", "tokio", + "tokio-test", + "tonic 0.14.2", "tracy-client", "zkvm-jetpack", ] @@ -4173,7 +4135,7 @@ version = "0.1.0" dependencies = [ "bs58", "bytes", - "cbor4ii 1.1.1", + "cbor4ii 1.2.2", "config", "dashmap", "either", @@ -4218,7 +4180,7 @@ dependencies = [ "quickcheck", "rkyv", "serde", - "thiserror 2.0.16", + "thiserror 2.0.17", "tracing", ] @@ -4253,7 +4215,7 @@ dependencies = [ "noun-serde", "quickcheck", "serde", - "thiserror 2.0.16", + "thiserror 2.0.17", "tracing", "tracing-subscriber", ] @@ -4267,7 +4229,7 @@ dependencies = [ "clap", "crossterm 0.29.0", "either", - "getrandom 0.3.3", + "getrandom 0.3.4", "http", "image 0.24.9", "kernels", @@ -4286,7 +4248,7 @@ dependencies = [ "tempfile", "termimad", "test-log", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tonic 0.14.2", "tracing", @@ -4318,7 +4280,7 @@ dependencies = [ "sha1", "tar", "tempfile", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "toml", "walkdir", @@ -4352,7 +4314,7 @@ dependencies = [ "signal-hook", "slotmap", "static_assertions", - "thiserror 2.0.16", + "thiserror 2.0.17", "tracing", "tracing-core", ] @@ -4376,7 +4338,7 @@ name = "nockvm_macros" version = "0.1.0" dependencies = [ "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -4427,7 +4389,7 @@ dependencies = [ "ibig", "nockvm", "noun-serde-derive", - "thiserror 2.0.16", + "thiserror 2.0.17", "tracing", ] @@ -4440,7 +4402,7 @@ dependencies = [ "noun-serde", "proc-macro2", "quote", - "syn", + "syn 2.0.111", "tracing", ] @@ -4455,11 +4417,11 @@ dependencies = [ [[package]] name = "nu-ansi-term" -version = "0.50.1" +version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4486,7 +4448,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -4556,7 +4518,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "objc2", "objc2-core-graphics", "objc2-foundation", @@ -4568,7 +4530,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "dispatch2", "objc2", ] @@ -4579,7 +4541,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "dispatch2", "objc2", "objc2-core-foundation", @@ -4598,7 +4560,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "objc2", "objc2-core-foundation", ] @@ -4609,20 +4571,11 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "objc2", "objc2-core-foundation", ] -[[package]] -name = "object" -version = "0.36.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87" -dependencies = [ - "memchr", -] - [[package]] name = "oid-registry" version = "0.8.1" @@ -4640,9 +4593,9 @@ checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" [[package]] name = "once_cell_polyfill" -version = "1.70.1" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "oorandom" @@ -4688,7 +4641,7 @@ dependencies = [ "futures-sink", "js-sys", "pin-project-lite", - "thiserror 2.0.16", + "thiserror 2.0.17", "tracing", ] @@ -4718,7 +4671,7 @@ dependencies = [ "opentelemetry_sdk", "prost 0.13.5", "reqwest", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tonic 0.13.1", "tracing", @@ -4749,7 +4702,7 @@ dependencies = [ "percent-encoding", "rand 0.9.2", "serde_json", - "thiserror 2.0.16", + "thiserror 2.0.17", "tokio", "tokio-stream", ] @@ -4778,9 +4731,9 @@ checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" [[package]] name = "parking_lot" -version = "0.12.4" +version = "0.12.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70d58bf43669b5795d1576d0641cfb6fbb2057bf629506267a92807158584a13" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" dependencies = [ "lock_api", "parking_lot_core", @@ -4788,15 +4741,15 @@ dependencies = [ [[package]] name = "parking_lot_core" -version = "0.9.11" +version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc838d2a56b5b1a6c25f55575dfc605fabb63bb2365f6c2353ef9159aa69e4a5" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", "redox_syscall", "smallvec", - "windows-targets 0.52.6", + "windows-link 0.2.1", ] [[package]] @@ -4824,12 +4777,12 @@ checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" [[package]] name = "pem" -version = "3.0.5" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38af38e8470ac9dee3ce1bae1af9c1671fffc44ddfd8bd1d0a3445bf349a8ef3" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ "base64 0.22.1", - "serde", + "serde_core", ] [[package]] @@ -4840,20 +4793,19 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.2" +version = "2.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e0a3a33733faeaf8651dfee72dd0f388f0c8e5ad496a3478fa5a922f49cfa8" +checksum = "cbcfd20a6d4eeba40179f05735784ad32bdaef05ce8e8af05f180d45bb3e7e22" dependencies = [ "memchr", - "thiserror 2.0.16", "ucd-trie", ] [[package]] name = "pest_derive" -version = "2.8.2" +version = "2.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc58706f770acb1dbd0973e6530a3cff4746fb721207feb3a8a6064cd0b6c663" +checksum = "51f72981ade67b1ca6adc26ec221be9f463f2b5839c7508998daa17c23d94d7f" dependencies = [ "pest", "pest_generator", @@ -4861,22 +4813,22 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.2" +version = "2.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d4f36811dfe07f7b8573462465d5cb8965fffc2e71ae377a33aecf14c2c9a2f" +checksum = "dee9efd8cdb50d719a80088b76f81aec7c41ed6d522ee750178f83883d271625" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] name = "pest_meta" -version = "2.8.2" +version = "2.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42919b05089acbd0a5dcd5405fb304d17d1053847b81163d09c4ad18ce8e8420" +checksum = "bf1d70880e76bdc13ba52eafa6239ce793d85c8e43896507e43dd8984ff05b82" dependencies = [ "pest", "sha2", @@ -4909,7 +4861,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -4987,7 +4939,7 @@ version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "crc32fast", "fdeflate", "flate2", @@ -5005,7 +4957,7 @@ dependencies = [ "hermit-abi", "pin-project-lite", "rustix 1.1.2", - "windows-sys 0.61.0", + "windows-sys 0.61.2", ] [[package]] @@ -5016,9 +4968,9 @@ checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" [[package]] name = "potential_utf" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" dependencies = [ "zerovec", ] @@ -5075,7 +5027,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.111", ] [[package]] @@ -5097,14 +5049,14 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] name = "proc-macro2" -version = "1.0.101" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" dependencies = [ "unicode-ident", ] @@ -5129,7 +5081,7 @@ checksum = "440f724eba9f6996b75d63681b0a92b06947f1457076d503a4d2e2c8f56442b8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -5140,7 +5092,7 @@ checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40" dependencies = [ "bit-set", "bit-vec", - "bitflags 2.9.4", + "bitflags 2.10.0", "num-traits", "rand 0.9.2", "rand_chacha 0.9.0", @@ -5189,7 +5141,7 @@ dependencies = [ "pulldown-cmark", "pulldown-cmark-to-cmark", "regex", - "syn", + "syn 2.0.111", "tempfile", ] @@ -5203,7 +5155,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -5216,7 +5168,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -5230,22 +5182,22 @@ dependencies = [ [[package]] name = "ptr_meta" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe9e76f66d3f9606f44e45598d155cb13ecf09f4a28199e48daf8c8fc937ea90" +checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79" dependencies = [ "ptr_meta_derive", ] [[package]] name = "ptr_meta_derive" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca414edb151b4c8d125c12566ab0d74dc9cdba36fb80eb7b848c15f495fd32d1" +checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -5254,25 +5206,25 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "memchr", "unicase", ] [[package]] name = "pulldown-cmark-to-cmark" -version = "21.0.0" +version = "21.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5b6a0769a491a08b31ea5c62494a8f144ee0987d86d670a8af4df1e1b7cde75" +checksum = "8246feae3db61428fd0bb94285c690b460e4517d83152377543ca802357785f1" dependencies = [ "pulldown-cmark", ] [[package]] name = "pxfm" -version = "0.1.24" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83f9b339b02259ada5c0f4a389b7fb472f933aa17ce176fd2ad98f28bb401fde" +checksum = "b3502d6155304a4173a5f2c34b52b7ed0dd085890326cb50fd625fdf39e86b3b" dependencies = [ "num-traits", ] @@ -5292,7 +5244,7 @@ version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" dependencies = [ - "image 0.25.8", + "image 0.25.9", ] [[package]] @@ -5305,7 +5257,7 @@ dependencies = [ "libc", "once_cell", "raw-cpuid", - "wasi 0.11.1+wasi-snapshot-preview1", + "wasi", "web-sys", "winapi", ] @@ -5339,7 +5291,7 @@ dependencies = [ "asynchronous-codec", "bytes", "quick-protobuf", - "thiserror 2.0.16", + "thiserror 2.0.17", "unsigned-varint", ] @@ -5368,8 +5320,8 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.6.0", - "thiserror 2.0.16", + "socket2 0.6.1", + "thiserror 2.0.17", "tokio", "tracing", "web-time", @@ -5382,7 +5334,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ "bytes", - "getrandom 0.3.3", + "getrandom 0.3.4", "lru-slab", "rand 0.9.2", "ring", @@ -5390,7 +5342,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.16", + "thiserror 2.0.17", "tinyvec", "tracing", "web-time", @@ -5405,16 +5357,16 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.0", + "socket2 0.6.1", "tracing", "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.40" +version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1885c039570dc00dcb4ff087a89e185fd56bae234ddc7f056a945bf36467248d" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" dependencies = [ "proc-macro2", ] @@ -5433,9 +5385,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rancor" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf5f7161924b9d1cea0e4cabc97c372cea92b5f927fc13c6bca67157a0ad947" +checksum = "a063ea72381527c2a0561da9c80000ef822bdd7c3241b1cc1b12100e3df081ee" dependencies = [ "ptr_meta", ] @@ -5496,7 +5448,7 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", ] [[package]] @@ -5514,7 +5466,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "cassowary", "compact_str", "crossterm 0.28.1", @@ -5535,7 +5487,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "cassowary", "compact_str", "crossterm 0.28.1", @@ -5556,7 +5508,7 @@ version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", ] [[package]] @@ -5608,9 +5560,9 @@ dependencies = [ [[package]] name = "rcgen" -version = "0.14.4" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c83367ba62b3f1dbd0f086ede4e5ebfb4713fb234dbbc5807772a31245ff46d" +checksum = "5fae430c6b28f1ad601274e78b7dffa0546de0b73b4cd32f46723c0c2a16f7a5" dependencies = [ "pem", "ring", @@ -5621,11 +5573,11 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.17" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5407465600fb0548f1442edf71dd20683c6ed326200ace4b1ef0763521bb3b77" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", ] [[package]] @@ -5636,14 +5588,14 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror 2.0.16", + "thiserror 2.0.17", ] [[package]] name = "regex" -version = "1.11.2" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", @@ -5653,9 +5605,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.10" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", @@ -5664,24 +5616,24 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" +checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" [[package]] name = "rend" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a35e8a6bf28cd121053a66aa2e6a2e3eaffad4a60012179f0e864aa5ffeff215" +checksum = "cadadef317c2f20755a64d7fdc48f9e7178ee6b0e1f7fce33fa60f1d68a276e6" dependencies = [ "bytecheck", ] [[package]] name = "reqwest" -version = "0.12.23" +version = "0.12.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" +checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" dependencies = [ "base64 0.22.1", "bytes", @@ -5722,9 +5674,9 @@ dependencies = [ [[package]] name = "resolv-conf" -version = "0.7.5" +version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b3789b30bd25ba102de4beabd95d21ac45b69b1be7d14522bab988c526d6799" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" [[package]] name = "retry" @@ -5751,9 +5703,9 @@ dependencies = [ [[package]] name = "rkyv" -version = "0.8.11" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f5c3e5da784cd8c69d32cdc84673f3204536ca56e1fa01be31a74b92c932ac" +checksum = "35a640b26f007713818e9a9b65d34da1cf58538207b052916a83d80e43f3ffa4" dependencies = [ "bytecheck", "bytes", @@ -5770,25 +5722,27 @@ dependencies = [ [[package]] name = "rkyv_derive" -version = "0.8.11" +version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4270433626cffc9c4c1d3707dd681f2a2718d3d7b09ad754bec137acecda8d22" +checksum = "bd83f5f173ff41e00337d97f6572e416d022ef8a19f371817259ae960324c482" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] name = "ron" -version = "0.8.1" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" +checksum = "fd490c5b18261893f14449cbd28cb9c0b637aebf161cd77900bfdedaff21ec32" dependencies = [ - "base64 0.21.7", - "bitflags 2.9.4", + "bitflags 2.10.0", + "once_cell", "serde", "serde_derive", + "typeid", + "unicode-ident", ] [[package]] @@ -5819,12 +5773,6 @@ dependencies = [ "ordered-multimap", ] -[[package]] -name = "rustc-demangle" -version = "0.1.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" - [[package]] name = "rustc-hash" version = "1.1.0" @@ -5870,7 +5818,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys 0.4.15", @@ -5883,18 +5831,18 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "errno", "libc", "linux-raw-sys 0.11.0", - "windows-sys 0.61.0", + "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.32" +version = "0.23.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd3c25631629d034ce7cd9940adc9d45762d46de2b0f57193c4443b92c6d4d40" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ "aws-lc-rs", "log", @@ -5908,9 +5856,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcff2dd52b58a8d98a70243663a0d234c4e2b79235637849d15913394a247d3" +checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -5929,9 +5877,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.12.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "229a4a4c221013e7e1f1a043678c5cc39fe5171437c88fb47151a21e6f5b5c79" +checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" dependencies = [ "web-time", "zeroize", @@ -5939,9 +5887,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.6" +version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8572f3c2cb9934231157b45499fc41e1f58c589fdfb81a844ba873265e80f8eb" +checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ "aws-lc-rs", "ring", @@ -5998,7 +5946,7 @@ version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" dependencies = [ - "windows-sys 0.61.0", + "windows-sys 0.61.2", ] [[package]] @@ -6036,11 +5984,11 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.5.0" +version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc198e42d9b7510827939c9a15f5062a0c913f3371d765977e586d2fe6c16f4a" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -6075,9 +6023,9 @@ dependencies = [ [[package]] name = "serde" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -6117,22 +6065,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -6238,20 +6186,20 @@ dependencies = [ [[package]] name = "signal-hook-mio" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34db1a06d485c9142248b7a054f034b349b212551f3dfd19c94d45a754a217cd" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" dependencies = [ "libc", - "mio 1.0.4", + "mio 1.1.1", "signal-hook", ] [[package]] name = "signal-hook-registry" -version = "1.4.6" +version = "1.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a4719bff48cee6b39d12c020eeb490953ad2443b7055bd0b21fca26bd8c28b" +checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" dependencies = [ "libc", ] @@ -6322,12 +6270,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233504af464074f9d066d7b5416c5f9b894a5862a6506e306f7b816cdd6f1807" +checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -6351,9 +6299,9 @@ dependencies = [ [[package]] name = "stable_deref_trait" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] name = "static_assertions" @@ -6401,7 +6349,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn", + "syn 2.0.111", ] [[package]] @@ -6413,7 +6361,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -6424,9 +6372,20 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.106" +version = "1.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" dependencies = [ "proc-macro2", "quote", @@ -6450,7 +6409,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -6501,7 +6460,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -6546,10 +6505,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" dependencies = [ "fastrand", - "getrandom 0.3.3", + "getrandom 0.3.4", "once_cell", "rustix 1.1.2", - "windows-sys 0.61.0", + "windows-sys 0.61.2", ] [[package]] @@ -6573,7 +6532,7 @@ dependencies = [ "lazy-regex", "minimad", "serde", - "thiserror 2.0.16", + "thiserror 2.0.17", "unicode-width 0.1.14", ] @@ -6585,9 +6544,9 @@ checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" [[package]] name = "test-log" -version = "0.2.18" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e33b98a582ea0be1168eba097538ee8dd4bbe0f2b01b22ac92ea30054e5be7b" +checksum = "37d53ac171c92a39e4769491c4b4dde7022c60042254b5fc044ae409d34a24d4" dependencies = [ "env_logger 0.11.8", "test-log-macros", @@ -6596,13 +6555,13 @@ dependencies = [ [[package]] name = "test-log-macros" -version = "0.2.18" +version = "0.2.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "451b374529930d7601b1eef8d32bc79ae870b6079b069401709c2a8bf9e75f36" +checksum = "be35209fd0781c5401458ab66e4f98accf63553e8fae7425503e92fdd319783b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -6616,11 +6575,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.16" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3467d614147380f2e4e374161426ff399c91084acd2363eaf549172b3d5e60c0" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl 2.0.16", + "thiserror-impl 2.0.17", ] [[package]] @@ -6631,18 +6590,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] name = "thiserror-impl" -version = "2.0.16" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c5e1be1c48b9172ee610da68fd9cd2770e7a4056cb3fc98710ee6906f0c7960" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -6673,7 +6632,7 @@ checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" dependencies = [ "fax", "flate2", - "half 2.6.0", + "half 2.7.1", "quick-error 2.0.1", "weezl", "zune-jpeg", @@ -6681,9 +6640,9 @@ dependencies = [ [[package]] name = "tikv-jemalloc-sys" -version = "0.6.0+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7" +version = "0.6.1+5.3.0-1-ge13ca993e8ccb9ba9847cc330696e02839f328f7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd3c60906412afa9c2b5b5a48ca6a5abe5736aec9eb48ad05037a677e52e4e2d" +checksum = "cd8aa5b2ab86a2cefa406d889139c162cbb230092f7d1d7cbc1716405d852a3b" dependencies = [ "cc", "libc", @@ -6691,9 +6650,9 @@ dependencies = [ [[package]] name = "tikv-jemallocator" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cec5ff18518d81584f477e9bfdf957f5bb0979b0bac3af4ca30b5b3ae2d2865" +checksum = "0359b4327f954e0567e69fb191cf1436617748813819c94b8cd4a431422d053a" dependencies = [ "libc", "tikv-jemalloc-sys", @@ -6743,9 +6702,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" dependencies = [ "displaydoc", "zerovec", @@ -6778,41 +6737,38 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.47.1" +version = "1.48.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89e49afdadebb872d3145a5638b59eb0691ea23e46ca484037cfab3b76b95038" +checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" dependencies = [ - "backtrace", "bytes", - "io-uring", "libc", - "mio 1.0.4", + "mio 1.1.1", "parking_lot", "pin-project-lite", "signal-hook-registry", - "slab", - "socket2 0.6.0", + "socket2 0.6.1", "tokio-macros", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.5.0" +version = "2.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e06d43f1345a3bcd39f6a56dbb7dcab2ba47e68e8ac134855e7e2bdbaf8cab8" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] name = "tokio-rustls" -version = "0.26.3" +version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f63835928ca123f1bef57abbcd23bb2ba0ac9ae1235f1e65bda0d06e7786bd" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ "rustls", "tokio", @@ -6845,9 +6801,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.16" +version = "0.7.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5" +checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" dependencies = [ "bytes", "futures-core", @@ -6941,7 +6897,7 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "socket2 0.6.0", + "socket2 0.6.1", "sync_wrapper", "tokio", "tokio-rustls", @@ -6962,7 +6918,7 @@ dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -7000,7 +6956,7 @@ dependencies = [ "prost-build", "prost-types", "quote", - "syn", + "syn 2.0.111", "tempfile", "tonic-build", ] @@ -7040,11 +6996,11 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.6" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" +checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456" dependencies = [ - "bitflags 2.9.4", + "bitflags 2.10.0", "bytes", "futures-core", "futures-util", @@ -7080,9 +7036,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.41" +version = "0.1.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "784e0ac535deb450455cbfa28a6f0df145ea1bb7ae51b821cf5e7927fdcfbdd0" +checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" dependencies = [ "log", "pin-project-lite", @@ -7092,20 +7048,20 @@ dependencies = [ [[package]] name = "tracing-attributes" -version = "0.1.30" +version = "0.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] name = "tracing-core" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d12581f227e93f094d3af2ae690a574abb8a2b9b7a96e7cfe9647b2b617678" +checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" dependencies = [ "once_cell", "valuable", @@ -7142,9 +7098,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.20" +version = "0.3.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" dependencies = [ "matchers", "nu-ansi-term", @@ -7176,7 +7132,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04659ddb06c87d233c566112c1c9c5b9e98256d9af50ec3bc9c8327f873a7568" dependencies = [ "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -7192,9 +7148,9 @@ dependencies = [ [[package]] name = "tracy-client" -version = "0.18.2" +version = "0.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef54005d3d760186fd662dad4b7bb27ecd5531cdef54d1573ebd3f20a9205ed7" +checksum = "91d722a05fe49b31fef971c4732a7d4aa6a18283d9ba46abddab35f484872947" dependencies = [ "loom", "once_cell", @@ -7203,9 +7159,9 @@ dependencies = [ [[package]] name = "tracy-client-sys" -version = "0.26.1" +version = "0.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "319c70195101a93f56db4c74733e272d720768e13471f400c78406a326b172b0" +checksum = "2fb391ac70462b3097a755618fbf9c8f95ecc1eb379a414f7b46f202ed10db1f" dependencies = [ "cc", "windows-targets 0.52.6", @@ -7225,9 +7181,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.18.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" [[package]] name = "ucd-trie" @@ -7261,9 +7217,9 @@ checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" [[package]] name = "unicode-ident" -version = "1.0.19" +version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "unicode-segmentation" @@ -7344,11 +7300,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.18.1" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f87b8aa10b915a06587d0dec516c282ff295b475d94abf425d62b57710070a2" +checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" dependencies = [ - "getrandom 0.3.3", + "getrandom 0.3.4", "js-sys", "wasm-bindgen", ] @@ -7401,7 +7357,7 @@ checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -7444,15 +7400,6 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasi" -version = "0.14.7+wasi-0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" -dependencies = [ - "wasip2", -] - [[package]] name = "wasip2" version = "1.0.1+wasi-0.2.4" @@ -7464,9 +7411,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.104" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1da10c01ae9f1ae40cbfac0bac3b1e724b320abfcf52229f80b547c0d250e2d" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" dependencies = [ "cfg-if", "once_cell", @@ -7475,25 +7422,11 @@ dependencies = [ "wasm-bindgen-shared", ] -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.104" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "671c9a5a66f49d8a47345ab942e2cb93c7d1d0339065d4f8139c486121b43b19" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn", - "wasm-bindgen-shared", -] - [[package]] name = "wasm-bindgen-futures" -version = "0.4.54" +version = "0.4.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e038d41e478cc73bae0ff9b36c60cff1c98b8f38f8d7e8061e79ee63608ac5c" +checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" dependencies = [ "cfg-if", "js-sys", @@ -7504,9 +7437,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.104" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca60477e4c59f5f2986c50191cd972e3a50d8a95603bc9434501cf156a9a119" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7514,31 +7447,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.104" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f07d2f20d4da7b26400c9f4a0511e6e0345b040694e8a75bd41d578fa4421d7" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn", - "wasm-bindgen-backend", + "syn 2.0.111", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.104" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad67dc8b2a1a6e5448428adec4c3e84c43e561d8c9ee8a9e5aabeb193ec41d1" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.81" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9367c417a924a74cae129e6a2ae3b47fabb1f8995595ab474029da749a8be120" +checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" dependencies = [ "js-sys", "wasm-bindgen", @@ -7556,18 +7489,18 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.2" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e8983c3ab33d6fb807cfcdad2491c4ea8cbc8ed839181c7dfd9c67c83e261b2" +checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" dependencies = [ "rustls-pki-types", ] [[package]] name = "weezl" -version = "0.1.10" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "which" @@ -7594,9 +7527,9 @@ dependencies = [ [[package]] name = "widestring" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd7cf3379ca1aac9eea11fba24fd7e315d621f8dfe35c8d7d2be8b793726e07d" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" [[package]] name = "winapi" @@ -7620,7 +7553,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.0", + "windows-sys 0.61.2", ] [[package]] @@ -7718,8 +7651,8 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" dependencies = [ - "windows-implement 0.60.0", - "windows-interface 0.59.1", + "windows-implement 0.60.2", + "windows-interface 0.59.3", "windows-link 0.1.3", "windows-result 0.3.4", "windows-strings 0.4.2", @@ -7727,15 +7660,15 @@ dependencies = [ [[package]] name = "windows-core" -version = "0.62.0" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57fe7168f7de578d2d8a05b07fd61870d2e73b4020e9f49aa00da8471723497c" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ - "windows-implement 0.60.0", - "windows-interface 0.59.1", - "windows-link 0.2.0", - "windows-result 0.4.0", - "windows-strings 0.5.0", + "windows-implement 0.60.2", + "windows-interface 0.59.3", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", ] [[package]] @@ -7757,18 +7690,18 @@ checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] name = "windows-implement" -version = "0.60.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a47fddd13af08290e67f4acabf4b459f647552718f683a7b415d290ac744a836" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -7779,18 +7712,18 @@ checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] name = "windows-interface" -version = "0.59.1" +version = "0.59.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd9211b69f8dcdfa817bfd14bf1c97c9188afa36f4750130fcdf3f400eca9fa8" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -7801,9 +7734,9 @@ checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" [[package]] name = "windows-link" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-numerics" @@ -7835,11 +7768,11 @@ dependencies = [ [[package]] name = "windows-result" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7084dcc306f89883455a206237404d3eaf961e5bd7e0f312f7c91f57eb44167f" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.0", + "windows-link 0.2.1", ] [[package]] @@ -7853,11 +7786,11 @@ dependencies = [ [[package]] name = "windows-strings" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7218c655a553b0bed4426cf54b20d7ba363ef543b52d515b3e48d7fd55318dda" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.0", + "windows-link 0.2.1", ] [[package]] @@ -7902,16 +7835,16 @@ version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets 0.53.3", + "windows-targets 0.53.5", ] [[package]] name = "windows-sys" -version = "0.61.0" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e201184e40b2ede64bc2ea34968b28e33622acdbbf37104f0e4a33f7abe657aa" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.0", + "windows-link 0.2.1", ] [[package]] @@ -7962,19 +7895,19 @@ dependencies = [ [[package]] name = "windows-targets" -version = "0.53.3" +version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link 0.1.3", - "windows_aarch64_gnullvm 0.53.0", - "windows_aarch64_msvc 0.53.0", - "windows_i686_gnu 0.53.0", - "windows_i686_gnullvm 0.53.0", - "windows_i686_msvc 0.53.0", - "windows_x86_64_gnu 0.53.0", - "windows_x86_64_gnullvm 0.53.0", - "windows_x86_64_msvc 0.53.0", + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] [[package]] @@ -8006,9 +7939,9 @@ checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" [[package]] name = "windows_aarch64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" [[package]] name = "windows_aarch64_msvc" @@ -8030,9 +7963,9 @@ checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" [[package]] name = "windows_aarch64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" [[package]] name = "windows_i686_gnu" @@ -8054,9 +7987,9 @@ checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" [[package]] name = "windows_i686_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" [[package]] name = "windows_i686_gnullvm" @@ -8066,9 +7999,9 @@ checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" [[package]] name = "windows_i686_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" [[package]] name = "windows_i686_msvc" @@ -8090,9 +8023,9 @@ checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" [[package]] name = "windows_i686_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" [[package]] name = "windows_x86_64_gnu" @@ -8114,9 +8047,9 @@ checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" [[package]] name = "windows_x86_64_gnu" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" [[package]] name = "windows_x86_64_gnullvm" @@ -8138,9 +8071,9 @@ checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" [[package]] name = "windows_x86_64_gnullvm" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" [[package]] name = "windows_x86_64_msvc" @@ -8162,15 +8095,15 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "windows_x86_64_msvc" -version = "0.53.0" +version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.13" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" dependencies = [ "memchr", ] @@ -8199,9 +8132,9 @@ checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" [[package]] name = "writeable" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" [[package]] name = "wyz" @@ -8252,7 +8185,7 @@ dependencies = [ "nom", "oid-registry", "rusticata-macros", - "thiserror 2.0.16", + "thiserror 2.0.17", "time", ] @@ -8268,9 +8201,9 @@ dependencies = [ [[package]] name = "xml-rs" -version = "0.8.27" +version = "0.8.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fd8403733700263c6eb89f192880191f1b83e332f7a20371ddcf421c4a337c7" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" [[package]] name = "xmltree" @@ -8318,11 +8251,10 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" dependencies = [ - "serde", "stable_deref_trait", "yoke-derive", "zerofrom", @@ -8330,34 +8262,34 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.27" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0894878a5fa3edfd6da3f88c4805f4c8558e2b996227a3d864f47fe11e38282c" +checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.27" +version = "0.8.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" +checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] @@ -8377,21 +8309,21 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.1" +version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" [[package]] name = "zerotrie" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" dependencies = [ "displaydoc", "yoke", @@ -8400,9 +8332,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.4" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" dependencies = [ "yoke", "zerofrom", @@ -8411,13 +8343,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.111", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 4bab8757a..158efab4b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,5 @@ -resolver = "2" - [workspace] +resolver = "2" members = ["crates/nockchain-api", "crates/nockchain-explorer-tui", "crates/nockchain-peek", "crates/equix-latency", "crates/hoon", "crates/hoonc", "crates/kernels", "crates/nockapp", "crates/nockapp-grpc", "crates/nockapp-grpc-proto", "crates/nockchain", "crates/nockchain-libp2p-io", "crates/nockchain-math", "crates/nockchain-types", "crates/nockchain-wallet", "crates/raw-tx-checker", "crates/nockvm/rust/ibig", "crates/nockvm/rust/murmur3", "crates/nockup", "crates/nockvm/rust/nockvm", "crates/nockvm/rust/nockvm_macros", "crates/noun-serde", "crates/noun-serde-derive", "crates/zkvm-jetpack"] [workspace.package] From c413814608fc3304f276977864d18059b27f3cdb Mon Sep 17 00:00:00 2001 From: tacryt-socryp <1377557+tacryt-socryp@users.noreply.github.com> Date: Fri, 19 Dec 2025 16:34:45 -0600 Subject: [PATCH 34/99] bridge launched, nockchain-wallet supports bridge --- Cargo.lock | 2465 ++++++++++++----- Cargo.toml | 38 +- Makefile | 9 +- crates/equix-latency/Cargo.toml | 1 + crates/equix-latency/src/main.rs | 4 +- crates/hoon/Cargo.toml | 7 +- crates/hoon/src/lib.rs | 36 +- crates/hoonc/Cargo.toml | 16 +- crates/hoonc/src/lib.rs | 7 +- crates/kernels/Cargo.toml | 6 +- crates/kernels/src/bridge.rs | 8 + crates/kernels/src/lib.rs | 3 + crates/nockapp-grpc-proto/Cargo.toml | 4 +- crates/nockapp-grpc-proto/build.rs | 1 + crates/nockapp-grpc-proto/src/common/mod.rs | 2 +- crates/nockapp-grpc-proto/src/lib.rs | 4 + crates/nockapp-grpc/Cargo.toml | 29 +- crates/nockapp-grpc/src/lib.rs | 18 + .../public_nockchain/v2/block_explorer.rs | 5 +- .../services/public_nockchain/v2/server.rs | 12 +- crates/nockapp/Cargo.toml | 19 +- crates/nockapp/src/drivers/http/acme.rs | 17 +- crates/nockapp/src/drivers/http/http.rs | 31 +- crates/nockapp/src/drivers/http/mod.rs | 1 + crates/nockapp/src/kernel/boot.rs | 59 +- crates/nockapp/src/kernel/form.rs | 1 + crates/nockapp/src/lib.rs | 2 + crates/nockapp/src/nockapp/error.rs | 1 - crates/nockapp/src/nockapp/mod.rs | 28 +- crates/nockapp/src/nockapp/save.rs | 80 +- crates/nockapp/src/noun/slab.rs | 7 +- crates/nockchain-api/Cargo.toml | 19 +- crates/nockchain-explorer-tui/Cargo.toml | 8 +- crates/nockchain-explorer-tui/src/main.rs | 23 +- crates/nockchain-libp2p-io/Cargo.toml | 14 +- crates/nockchain-libp2p-io/src/cbor_tests.rs | 45 +- crates/nockchain-libp2p-io/src/driver.rs | 44 +- .../nockchain-libp2p-io/src/key_fair_queue.rs | 2 +- crates/nockchain-libp2p-io/src/lib.rs | 6 + crates/nockchain-libp2p-io/src/messages.rs | 39 +- crates/nockchain-libp2p-io/src/p2p_state.rs | 14 +- crates/nockchain-libp2p-io/src/tip5_util.rs | 67 +- crates/nockchain-libp2p-io/src/traffic_cop.rs | 15 +- crates/nockchain-math/Cargo.toml | 6 +- crates/nockchain-math/src/belt.rs | 2 - crates/nockchain-math/src/bpoly.rs | 14 +- crates/nockchain-math/src/convert.rs | 12 +- crates/nockchain-math/src/crypto/cheetah.rs | 12 +- crates/nockchain-math/src/mary.rs | 9 +- crates/nockchain-math/src/tip5/hash.rs | 8 +- crates/nockchain-math/src/zoon/common.rs | 4 +- crates/nockchain-math/src/zoon/zmap.rs | 13 +- crates/nockchain-peek/Cargo.toml | 2 +- crates/nockchain-peek/src/lib.rs | 2 +- crates/nockchain-types/Cargo.toml | 13 +- crates/nockchain-types/src/eth.rs | 215 ++ crates/nockchain-types/src/lib.rs | 2 + .../src/tx_engine/common/mod.rs | 46 +- .../src/tx_engine/common/page.rs | 4 +- .../nockchain-types/src/tx_engine/v0/note.rs | 24 +- crates/nockchain-types/src/tx_engine/v0/tx.rs | 3 +- .../nockchain-types/src/tx_engine/v1/note.rs | 12 +- .../tests/balance_from_peek_v0.rs | 2 + .../tests/balance_from_peek_v1.rs | 2 + crates/nockchain-wallet/Cargo.toml | 24 +- crates/nockchain-wallet/README.md | 17 + crates/nockchain-wallet/src/command.rs | 7 +- crates/nockchain-wallet/src/connection.rs | 4 +- crates/nockchain-wallet/src/main.rs | 55 +- crates/nockchain-wallet/src/recipient.rs | 98 +- crates/nockchain/Cargo.toml | 29 +- crates/nockchain/src/config.rs | 14 +- crates/nockchain/src/lib.rs | 13 +- crates/nockchain/src/mining.rs | 8 +- crates/nockchain/src/setup.rs | 1 + crates/nockup/Cargo.toml | 50 +- crates/nockup/README.md | 196 +- crates/nockup/build.rs | 4 +- crates/nockup/install-replit.sh | 10 +- crates/nockup/install.sh | 6 +- .../example-manifest-with-libraries.toml | 31 - crates/nockup/manifests/example-manifest.toml | 12 - crates/nockup/manifests/example-nockapp.toml | 14 + crates/nockup/quick_start.sh | 527 ---- crates/nockup/rust-toolchain.toml | 3 - crates/nockup/scripts/scan-deps.py | 360 +++ crates/nockup/src/cache.rs | 362 +++ crates/nockup/src/cli.rs | 116 +- .../nockup/src/commands/{ => build}/build.rs | 133 +- crates/nockup/src/commands/build/init.rs | 150 + crates/nockup/src/commands/build/mod.rs | 19 + crates/nockup/src/commands/build/run.rs | 61 + crates/nockup/src/commands/channel/mod.rs | 13 + .../commands/{channel.rs => channel/set.rs} | 18 +- crates/nockup/src/commands/channel/show.rs | 24 + crates/nockup/src/commands/common.rs | 70 +- crates/nockup/src/commands/install.rs | 14 +- crates/nockup/src/commands/mod.rs | 3 +- crates/nockup/src/commands/package.rs | 29 + crates/nockup/src/commands/package/add.rs | 82 + crates/nockup/src/commands/package/init.rs | 56 + crates/nockup/src/commands/package/install.rs | 626 +++++ crates/nockup/src/commands/package/list.rs | 148 + crates/nockup/src/commands/package/purge.rs | 86 + crates/nockup/src/commands/package/remove.rs | 122 + crates/nockup/src/commands/package/update.rs | 194 ++ crates/nockup/src/commands/test_phase1.rs | 171 ++ crates/nockup/src/commands/update.rs | 118 +- crates/nockup/src/git_fetcher.rs | 326 +++ crates/nockup/src/lib.rs | 8 + crates/nockup/src/lib_manager.rs | 46 +- crates/nockup/src/main.rs | 47 +- crates/nockup/src/manifest.rs | 148 + crates/nockup/src/resolver/engine.rs | 407 +++ crates/nockup/src/resolver/mod.rs | 8 + crates/nockup/src/resolver/registry.rs | 315 +++ crates/nockup/src/resolver/spec_parser.rs | 340 +++ crates/nockup/src/resolver/types.rs | 93 + crates/nockup/src/version.rs | 16 +- crates/nockup/tests/cli_tests.rs | 107 +- crates/nockvm/rust/ibig/Cargo.toml | 37 +- crates/nockvm/rust/ibig/benches/benchmarks.rs | 2 + crates/nockvm/rust/ibig/src/buffer.rs | 7 +- .../nockvm/rust/ibig/src/fmt/non_power_two.rs | 2 +- crates/nockvm/rust/ibig/src/lib.rs | 2 + .../nockvm/rust/ibig/src/modular/convert.rs | 2 +- crates/nockvm/rust/ibig/src/mul/karatsuba.rs | 2 +- crates/nockvm/rust/ibig/src/mul/toom_3.rs | 2 +- crates/nockvm/rust/murmur3/Cargo.toml | 6 +- .../rust/murmur3/murmur3-sys/Cargo.toml | 2 + .../nockvm/rust/murmur3/murmur3-sys/build.rs | 41 +- .../nockvm/rust/murmur3/tests/quickcheck.rs | 1 + crates/nockvm/rust/nockvm/Cargo.toml | 55 +- crates/nockvm/rust/nockvm/src/hamt.rs | 2 +- crates/nockvm/rust/nockvm/src/interpreter.rs | 40 +- crates/nockvm/rust/nockvm/src/jets/set.rs | 14 +- crates/nockvm/rust/nockvm/src/lib.rs | 3 +- crates/nockvm/rust/nockvm/src/mem.rs | 57 +- crates/nockvm/rust/nockvm/src/noun.rs | 50 +- .../nockvm/rust/nockvm/src/serialization.rs | 2 +- crates/nockvm/rust/nockvm/src/trace/filter.rs | 2 +- crates/nockvm/rust/nockvm/src/trace/json.rs | 180 -- crates/nockvm/rust/nockvm/src/trace/mod.rs | 13 - .../rust/nockvm/src/trace/tracing_backend.rs | 8 +- .../rust/nockvm/src/unifying_equality.rs | 6 +- crates/nockvm/rust/nockvm_crypto/Cargo.toml | 1 + crates/nockvm/rust/nockvm_macros/Cargo.toml | 2 +- crates/noun-serde-derive/Cargo.toml | 3 +- crates/noun-serde-derive/src/lib.rs | 28 +- .../tests/struct_encoding_test.rs | 1 + .../tests/wallet_types_derived.rs | 3 +- crates/noun-serde/Cargo.toml | 5 +- crates/noun-serde/src/lib.rs | 8 +- crates/noun-serde/src/wallet.rs | 41 +- crates/noun-serde/tests/serde.rs | 2 + crates/raw-tx-checker/Cargo.toml | 9 +- crates/zkvm-jetpack/Cargo.toml | 2 +- crates/zkvm-jetpack/src/form/math/prover.rs | 1 + crates/zkvm-jetpack/src/jets/base_jets.rs | 4 +- crates/zkvm-jetpack/src/jets/bp_jets.rs | 4 +- crates/zkvm-jetpack/src/jets/cheetah_jets.rs | 33 +- .../src/jets/compute_table_jets_v2.rs | 2 +- crates/zkvm-jetpack/src/jets/mega_jets.rs | 2 +- .../zkvm-jetpack/src/jets/proof_gen_jets.rs | 55 +- crates/zkvm-jetpack/src/jets/tip5_jets.rs | 2 +- crates/zkvm-jetpack/src/jets/verifier_jets.rs | 34 +- crates/zkvm-jetpack/src/lib.rs | 2 + example-nockapp.toml | 28 + example-registry.toml | 650 +++++ hoon/apps/bridge/base.hoon | 253 ++ hoon/apps/bridge/bridge.hoon | 384 +++ hoon/apps/bridge/nock.hoon | 387 +++ hoon/apps/bridge/types.hoon | 849 ++++++ hoon/apps/wallet/lib/tx-builder.hoon | 86 +- hoon/apps/wallet/lib/types.hoon | 42 +- hoon/apps/wallet/lib/utils.hoon | 64 +- hoon/apps/wallet/wallet.hoon | 73 +- hoon/common/hoon.hoon | 1 + hoon/common/tx-engine.hoon | 2 + rust-toolchain.toml | 2 +- 180 files changed, 10567 insertions(+), 2619 deletions(-) create mode 100644 crates/kernels/src/bridge.rs create mode 100644 crates/nockchain-types/src/eth.rs delete mode 100644 crates/nockup/manifests/example-manifest-with-libraries.toml delete mode 100644 crates/nockup/manifests/example-manifest.toml create mode 100644 crates/nockup/manifests/example-nockapp.toml delete mode 100644 crates/nockup/quick_start.sh delete mode 100644 crates/nockup/rust-toolchain.toml create mode 100644 crates/nockup/scripts/scan-deps.py create mode 100644 crates/nockup/src/cache.rs rename crates/nockup/src/commands/{ => build}/build.rs (53%) create mode 100644 crates/nockup/src/commands/build/init.rs create mode 100644 crates/nockup/src/commands/build/mod.rs create mode 100644 crates/nockup/src/commands/build/run.rs create mode 100644 crates/nockup/src/commands/channel/mod.rs rename crates/nockup/src/commands/{channel.rs => channel/set.rs} (71%) create mode 100644 crates/nockup/src/commands/channel/show.rs create mode 100644 crates/nockup/src/commands/package.rs create mode 100644 crates/nockup/src/commands/package/add.rs create mode 100644 crates/nockup/src/commands/package/init.rs create mode 100644 crates/nockup/src/commands/package/install.rs create mode 100644 crates/nockup/src/commands/package/list.rs create mode 100644 crates/nockup/src/commands/package/purge.rs create mode 100644 crates/nockup/src/commands/package/remove.rs create mode 100644 crates/nockup/src/commands/package/update.rs create mode 100644 crates/nockup/src/commands/test_phase1.rs create mode 100644 crates/nockup/src/git_fetcher.rs create mode 100644 crates/nockup/src/lib.rs create mode 100644 crates/nockup/src/manifest.rs create mode 100644 crates/nockup/src/resolver/engine.rs create mode 100644 crates/nockup/src/resolver/mod.rs create mode 100644 crates/nockup/src/resolver/registry.rs create mode 100644 crates/nockup/src/resolver/spec_parser.rs create mode 100644 crates/nockup/src/resolver/types.rs delete mode 100644 crates/nockvm/rust/nockvm/src/trace/json.rs create mode 100644 example-nockapp.toml create mode 100644 example-registry.toml create mode 100644 hoon/apps/bridge/base.hoon create mode 100644 hoon/apps/bridge/bridge.hoon create mode 100644 hoon/apps/bridge/nock.hoon create mode 100644 hoon/apps/bridge/types.hoon create mode 120000 hoon/common/hoon.hoon diff --git a/Cargo.lock b/Cargo.lock index e7ec877af..fd44a7d46 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,6 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] name = "adler2" @@ -41,7 +41,7 @@ dependencies = [ "cmac", "ctr", "dbl", - "digest", + "digest 0.10.7", "zeroize", ] @@ -60,6 +60,611 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "alloy" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f07655fedc35188f3c50ff8fc6ee45703ae14ef1bc7ae7d80e23a747012184e3" +dependencies = [ + "alloy-consensus", + "alloy-contract", + "alloy-core", + "alloy-eips", + "alloy-genesis", + "alloy-network", + "alloy-provider", + "alloy-rpc-client", + "alloy-rpc-types", + "alloy-serde", + "alloy-signer", + "alloy-signer-local", + "alloy-transport", + "alloy-transport-http", + "alloy-trie", +] + +[[package]] +name = "alloy-chains" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35d744058a9daa51a8cf22a3009607498fcf82d3cf4c5444dd8056cdf651f471" +dependencies = [ + "alloy-primitives", + "num_enum", + "strum 0.27.2", +] + +[[package]] +name = "alloy-consensus" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e318e25fb719e747a7e8db1654170fc185024f3ed5b10f86c08d448a912f6e2" +dependencies = [ + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "alloy-trie", + "alloy-tx-macros", + "auto_impl", + "borsh", + "c-kzg", + "derive_more", + "either", + "k256", + "once_cell", + "rand 0.8.5", + "secp256k1", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.17", +] + +[[package]] +name = "alloy-consensus-any" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "364380a845193a317bcb7a5398fc86cdb66c47ebe010771dde05f6869bf9e64a" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "serde", +] + +[[package]] +name = "alloy-contract" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d39c80ffc806f27a76ed42f3351a455f3dc4f81d6ff92c8aad2cf36b7d3a34" +dependencies = [ + "alloy-consensus", + "alloy-dyn-abi", + "alloy-json-abi", + "alloy-network", + "alloy-network-primitives", + "alloy-primitives", + "alloy-provider", + "alloy-rpc-types-eth", + "alloy-sol-types", + "alloy-transport", + "futures", + "futures-util", + "serde_json", + "thiserror 2.0.17", +] + +[[package]] +name = "alloy-core" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a651e1d9e50e6d0a78bd23cd08facb70459a94501c4036c7799a093e569a310" +dependencies = [ + "alloy-dyn-abi", + "alloy-json-abi", + "alloy-primitives", + "alloy-rlp", + "alloy-sol-types", +] + +[[package]] +name = "alloy-dyn-abi" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d48a9101f4a67c22fae57489f1ddf3057b8ab4a368d8eac3be088b6e9d9c9d9" +dependencies = [ + "alloy-json-abi", + "alloy-primitives", + "alloy-sol-type-parser", + "alloy-sol-types", + "itoa", + "serde", + "serde_json", + "winnow", +] + +[[package]] +name = "alloy-eip2124" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "741bdd7499908b3aa0b159bba11e71c8cddd009a2c2eb7a06e825f1ec87900a5" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "crc", + "serde", + "thiserror 2.0.17", +] + +[[package]] +name = "alloy-eip2930" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9441120fa82df73e8959ae0e4ab8ade03de2aaae61be313fbf5746277847ce25" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "serde", +] + +[[package]] +name = "alloy-eip7702" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2919c5a56a1007492da313e7a3b6d45ef5edc5d33416fdec63c0d7a2702a0d20" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "serde", + "thiserror 2.0.17", +] + +[[package]] +name = "alloy-eips" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4c4d7c5839d9f3a467900c625416b24328450c65702eb3d8caff8813e4d1d33" +dependencies = [ + "alloy-eip2124", + "alloy-eip2930", + "alloy-eip7702", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "auto_impl", + "borsh", + "c-kzg", + "derive_more", + "either", + "serde", + "serde_with", + "sha2", + "thiserror 2.0.17", +] + +[[package]] +name = "alloy-genesis" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ba4b1be0988c11f0095a2380aa596e35533276b8fa6c9e06961bbfe0aebcac5" +dependencies = [ + "alloy-eips", + "alloy-primitives", + "alloy-serde", + "alloy-trie", + "borsh", + "serde", + "serde_with", +] + +[[package]] +name = "alloy-json-abi" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9914c147bb9b25f440eca68a31dc29f5c22298bfa7754aa802965695384122b0" +dependencies = [ + "alloy-primitives", + "alloy-sol-type-parser", + "serde", + "serde_json", +] + +[[package]] +name = "alloy-json-rpc" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f72cf87cda808e593381fb9f005ffa4d2475552b7a6c5ac33d087bf77d82abd0" +dependencies = [ + "alloy-primitives", + "alloy-sol-types", + "http", + "serde", + "serde_json", + "thiserror 2.0.17", + "tracing", +] + +[[package]] +name = "alloy-network" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12aeb37b6f2e61b93b1c3d34d01ee720207c76fe447e2a2c217e433ac75b17f5" +dependencies = [ + "alloy-consensus", + "alloy-consensus-any", + "alloy-eips", + "alloy-json-rpc", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rpc-types-any", + "alloy-rpc-types-eth", + "alloy-serde", + "alloy-signer", + "alloy-sol-types", + "async-trait", + "auto_impl", + "derive_more", + "futures-utils-wasm", + "serde", + "serde_json", + "thiserror 2.0.17", +] + +[[package]] +name = "alloy-network-primitives" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abd29ace62872083e30929cd9b282d82723196d196db589f3ceda67edcc05552" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-serde", + "serde", +] + +[[package]] +name = "alloy-primitives" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7db950a29746be9e2f2c6288c8bd7a6202a81f999ce109a2933d2379970ec0fa" +dependencies = [ + "alloy-rlp", + "bytes", + "cfg-if", + "const-hex", + "derive_more", + "foldhash 0.2.0", + "hashbrown 0.16.1", + "indexmap 2.12.1", + "itoa", + "k256", + "keccak-asm", + "paste", + "proptest", + "rand 0.9.2", + "rapidhash", + "ruint", + "rustc-hash 2.1.1", + "serde", + "sha3", + "tiny-keccak", +] + +[[package]] +name = "alloy-provider" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b710636d7126e08003b8217e24c09f0cca0b46d62f650a841736891b1ed1fc1" +dependencies = [ + "alloy-chains", + "alloy-consensus", + "alloy-eips", + "alloy-json-rpc", + "alloy-network", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rpc-client", + "alloy-rpc-types-eth", + "alloy-signer", + "alloy-sol-types", + "alloy-transport", + "alloy-transport-http", + "async-stream", + "async-trait", + "auto_impl", + "dashmap", + "either", + "futures", + "futures-utils-wasm", + "lru 0.13.0", + "parking_lot", + "pin-project", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tracing", + "url", + "wasmtimer", +] + +[[package]] +name = "alloy-rlp" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f70d83b765fdc080dbcd4f4db70d8d23fe4761f2f02ebfa9146b833900634b4" +dependencies = [ + "alloy-rlp-derive", + "arrayvec", + "bytes", +] + +[[package]] +name = "alloy-rlp-derive" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64b728d511962dda67c1bc7ea7c03736ec275ed2cf4c35d9585298ac9ccf3b73" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "alloy-rpc-client" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0882e72d2c1c0c79dcf4ab60a67472d3f009a949f774d4c17d0bdb669cfde05" +dependencies = [ + "alloy-json-rpc", + "alloy-primitives", + "alloy-transport", + "alloy-transport-http", + "futures", + "pin-project", + "reqwest", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tower", + "tracing", + "url", + "wasmtimer", +] + +[[package]] +name = "alloy-rpc-types" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cf1398cb33aacb139a960fa3d8cf8b1202079f320e77e952a0b95967bf7a9f" +dependencies = [ + "alloy-primitives", + "alloy-rpc-types-eth", + "alloy-serde", + "serde", +] + +[[package]] +name = "alloy-rpc-types-any" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a63fb40ed24e4c92505f488f9dd256e2afaed17faa1b7a221086ebba74f4122" +dependencies = [ + "alloy-consensus-any", + "alloy-rpc-types-eth", + "alloy-serde", +] + +[[package]] +name = "alloy-rpc-types-eth" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9eae0c7c40da20684548cbc8577b6b7447f7bf4ddbac363df95e3da220e41e72" +dependencies = [ + "alloy-consensus", + "alloy-consensus-any", + "alloy-eips", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "alloy-sol-types", + "itertools 0.14.0", + "serde", + "serde_json", + "serde_with", + "thiserror 2.0.17", +] + +[[package]] +name = "alloy-serde" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0df1987ed0ff2d0159d76b52e7ddfc4e4fbddacc54d2fbee765e0d14d7c01b5" +dependencies = [ + "alloy-primitives", + "serde", + "serde_json", +] + +[[package]] +name = "alloy-signer" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ff69deedee7232d7ce5330259025b868c5e6a52fa8dffda2c861fb3a5889b24" +dependencies = [ + "alloy-primitives", + "async-trait", + "auto_impl", + "either", + "elliptic-curve", + "k256", + "thiserror 2.0.17", +] + +[[package]] +name = "alloy-signer-local" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72cfe0be3ec5a8c1a46b2e5a7047ed41121d360d97f4405bb7c1c784880c86cb" +dependencies = [ + "alloy-consensus", + "alloy-network", + "alloy-primitives", + "alloy-signer", + "async-trait", + "k256", + "rand 0.8.5", + "thiserror 2.0.17", +] + +[[package]] +name = "alloy-sol-macro" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3b96d5f5890605ba9907ce1e2158e2701587631dc005bfa582cf92dd6f21147" +dependencies = [ + "alloy-sol-macro-expander", + "alloy-sol-macro-input", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "alloy-sol-macro-expander" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8247b7cca5cde556e93f8b3882b01dbd272f527836049083d240c57bf7b4c15" +dependencies = [ + "alloy-json-abi", + "alloy-sol-macro-input", + "const-hex", + "heck", + "indexmap 2.12.1", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.111", + "syn-solidity", + "tiny-keccak", +] + +[[package]] +name = "alloy-sol-macro-input" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cd54f38512ac7bae10bbc38480eefb1b9b398ca2ce25db9cc0c048c6411c4f1" +dependencies = [ + "alloy-json-abi", + "const-hex", + "dunce", + "heck", + "macro-string", + "proc-macro2", + "quote", + "serde_json", + "syn 2.0.111", + "syn-solidity", +] + +[[package]] +name = "alloy-sol-type-parser" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "444b09815b44899564566d4d56613d14fa9a274b1043a021f00468568752f449" +dependencies = [ + "serde", + "winnow", +] + +[[package]] +name = "alloy-sol-types" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1038284171df8bfd48befc0c7b78f667a7e2be162f45f07bd1c378078ebe58" +dependencies = [ + "alloy-json-abi", + "alloy-primitives", + "alloy-sol-macro", + "serde", +] + +[[package]] +name = "alloy-transport" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be98b07210d24acf5b793c99b759e9a696e4a2e67593aec0487ae3b3e1a2478c" +dependencies = [ + "alloy-json-rpc", + "auto_impl", + "base64", + "derive_more", + "futures", + "futures-utils-wasm", + "parking_lot", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tower", + "tracing", + "url", + "wasmtimer", +] + +[[package]] +name = "alloy-transport-http" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4198a1ee82e562cab85e7f3d5921aab725d9bd154b6ad5017f82df1695877c97" +dependencies = [ + "alloy-json-rpc", + "alloy-transport", + "reqwest", + "serde_json", + "tower", + "tracing", + "url", +] + +[[package]] +name = "alloy-trie" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3412d52bb97c6c6cc27ccc28d4e6e8cf605469101193b50b0bd5813b1f990b5" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "arrayvec", + "derive_more", + "nybbles", + "serde", + "smallvec", + "tracing", +] + +[[package]] +name = "alloy-tx-macros" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "333544408503f42d7d3792bfc0f7218b643d968a03d2c0ed383ae558fb4a76d0" +dependencies = [ + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -132,41 +737,230 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" [[package]] -name = "arboard" -version = "3.6.1" +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "x11rb", +] + +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[package]] +name = "ark-ff" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b3235cc41ee7a12aaaf2c575a2ad7b46713a8a50bda2fc3b003a04845c05dd6" +dependencies = [ + "ark-ff-asm 0.3.0", + "ark-ff-macros 0.3.0", + "ark-serialize 0.3.0", + "ark-std 0.3.0", + "derivative", + "num-bigint", + "num-traits", + "paste", + "rustc_version 0.3.3", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec847af850f44ad29048935519032c33da8aa03340876d351dfab5660d2966ba" +dependencies = [ + "ark-ff-asm 0.4.2", + "ark-ff-macros 0.4.2", + "ark-serialize 0.4.2", + "ark-std 0.4.0", + "derivative", + "digest 0.10.7", + "itertools 0.10.5", + "num-bigint", + "num-traits", + "paste", + "rustc_version 0.4.1", + "zeroize", +] + +[[package]] +name = "ark-ff" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a177aba0ed1e0fbb62aa9f6d0502e9b46dad8c2eab04c14258a1212d2557ea70" +dependencies = [ + "ark-ff-asm 0.5.0", + "ark-ff-macros 0.5.0", + "ark-serialize 0.5.0", + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "educe", + "itertools 0.13.0", + "num-bigint", + "num-traits", + "paste", + "zeroize", +] + +[[package]] +name = "ark-ff-asm" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db02d390bf6643fb404d3d22d31aee1c4bc4459600aef9113833d17e786c6e44" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-asm" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ed4aa4fe255d0bc6d79373f7e31d2ea147bcf486cba1be5ba7ea85abdb92348" +dependencies = [ + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-asm" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" +dependencies = [ + "quote", + "syn 2.0.111", +] + +[[package]] +name = "ark-ff-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fd794a08ccb318058009eefdf15bcaaaaf6f8161eb3345f907222bac38b20" +dependencies = [ + "num-bigint", + "num-traits", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7abe79b0e4288889c4574159ab790824d0033b9fdcb2a112a3182fac2e514565" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "ark-ff-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09be120733ee33f7693ceaa202ca41accd5653b779563608f1234f78ae07c4b3" +dependencies = [ + "num-bigint", + "num-traits", + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "ark-serialize" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d6c2b318ee6e10f8c2853e73a83adc0ccb88995aa978d8a3408d492ab2ee671" +dependencies = [ + "ark-std 0.3.0", + "digest 0.9.0", +] + +[[package]] +name = "ark-serialize" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "adb7b85a02b83d2f22f89bd5cac66c9c89474240cb6207cb1efc16d098e822a5" +dependencies = [ + "ark-std 0.4.0", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-serialize" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f4d068aaf107ebcd7dfb52bc748f8030e0fc930ac8e360146ca54c1203088f7" +dependencies = [ + "ark-std 0.5.0", + "arrayvec", + "digest 0.10.7", + "num-bigint", +] + +[[package]] +name = "ark-std" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +checksum = "1df2c09229cbc5a028b1d70e00fdb2acee28b1055dfb5ca73eea49c5a25c4e7c" dependencies = [ - "clipboard-win", - "image 0.25.9", - "log", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-core-graphics", - "objc2-foundation", - "parking_lot", - "percent-encoding", - "windows-sys 0.60.2", - "x11rb", + "num-traits", + "rand 0.8.5", ] [[package]] -name = "arc-swap" -version = "1.7.1" +name = "ark-std" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +checksum = "94893f1e0c6eeab764ade8dc4c0db24caf4fe7cbbaafc0eba0a9030f447b5185" +dependencies = [ + "num-traits", + "rand 0.8.5", +] [[package]] -name = "argon2" -version = "0.5.3" +name = "ark-std" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +checksum = "246a225cc6131e9ee4f24619af0f19d67761fff15d7ccc22e42b80846e69449a" dependencies = [ - "base64ct", - "blake2", - "cpufeatures", - "password-hash", + "num-traits", + "rand 0.8.5", ] [[package]] @@ -186,6 +980,9 @@ name = "arrayvec" version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +dependencies = [ + "serde", +] [[package]] name = "asn1-rs" @@ -328,12 +1125,23 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16e2cdb6d5ed835199484bb92bb8b3edd526effe995c61732580439c1a67e2e9" dependencies = [ - "base64 0.22.1", + "base64", "http", "log", "url", ] +[[package]] +name = "auto_impl" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -351,9 +1159,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b5ce75405893cd713f9ab8e297d8e438f624dde7d706108285f7e17a25a180f" +checksum = "6a88aab2464f1f25453baa7a07c84c5b7684e274054ba06817f382357f77a288" dependencies = [ "aws-lc-sys", "zeroize", @@ -361,9 +1169,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.34.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "179c3777a8b5e70e90ea426114ffc565b2c1a9f82f6c4a0c5a34aa6ef5e781b6" +checksum = "b45afffdee1e7c9126814751f88dddc747f41d91da16c9551a0f1e8a11e788a1" dependencies = [ "cc", "cmake", @@ -425,12 +1233,13 @@ dependencies = [ [[package]] name = "axum-server" -version = "0.7.3" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9" +checksum = "b1df331683d982a0b9492b38127151e6453639cd34926eb9c07d4cd8c6d22bfc" dependencies = [ "arc-swap", "bytes", + "either", "fs-err", "http", "http-body", @@ -438,32 +1247,24 @@ dependencies = [ "hyper-util", "pin-project-lite", "rustls", - "rustls-pemfile", "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", ] -[[package]] -name = "bardecoder" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "757eeab9757d5ddd88aa1420449f3ce92631339b7f5b3785808da7a148de35d9" -dependencies = [ - "anyhow", - "image 0.24.9", - "log", - "newtype_derive", - "thiserror 1.0.69", -] - [[package]] name = "base-x" version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cbbc9d0964165b47557570cce6c952866c2678457aca742aafc9fb771d30270" +[[package]] +name = "base16ct" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" + [[package]] name = "base256emoji" version = "1.0.2" @@ -474,22 +1275,6 @@ dependencies = [ "match-lookup", ] -[[package]] -name = "base58ck" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" -dependencies = [ - "bitcoin-internals", - "bitcoin_hashes", -] - -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - [[package]] name = "base64" version = "0.22.1" @@ -498,15 +1283,9 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55248b47b0caf0546f7988906588779981c43bb1bc9d0c44087278f80cdb44ba" - -[[package]] -name = "bech32" -version = "0.11.1" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" [[package]] name = "bincode" @@ -566,55 +1345,12 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" -[[package]] -name = "bit_field" -version = "0.10.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" - -[[package]] -name = "bitcoin" -version = "0.32.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda569d741b895131a88ee5589a467e73e9c4718e958ac9308e4f7dc44b6945" -dependencies = [ - "base58ck", - "bech32", - "bitcoin-internals", - "bitcoin-io", - "bitcoin-units", - "bitcoin_hashes", - "hex-conservative", - "hex_lit", - "secp256k1", - "serde", -] - -[[package]] -name = "bitcoin-internals" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" -dependencies = [ - "serde", -] - [[package]] name = "bitcoin-io" version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" -[[package]] -name = "bitcoin-units" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2" -dependencies = [ - "bitcoin-internals", - "serde", -] - [[package]] name = "bitcoin_hashes" version = "0.14.1" @@ -623,31 +1359,6 @@ checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" dependencies = [ "bitcoin-io", "hex-conservative", - "serde", -] - -[[package]] -name = "bitcoincore-rpc" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aedd23ae0fd321affb4bbbc36126c6f49a32818dc6b979395d24da8c9d4e80ee" -dependencies = [ - "bitcoincore-rpc-json", - "jsonrpc", - "log", - "serde", - "serde_json", -] - -[[package]] -name = "bitcoincore-rpc-json" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8909583c5fab98508e80ef73e5592a651c954993dc6b7739963257d19f0e71a" -dependencies = [ - "bitcoin", - "serde", - "serde_json", ] [[package]] @@ -683,7 +1394,7 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -709,6 +1420,41 @@ dependencies = [ "generic-array", ] +[[package]] +name = "blst" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcdb4c7013139a150f9fc55d123186dbfaba0d912817466282c73ac49e71fb45" +dependencies = [ + "cc", + "glob", + "threadpool", + "zeroize", +] + +[[package]] +name = "borsh" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "bs58" version = "0.5.1" @@ -731,9 +1477,15 @@ dependencies = [ [[package]] name = "bumpalo" -version = "3.19.0" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "byte-slice-cast" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" +checksum = "7575182f7272186991736b70173b0ea045398f984bf5ebbb3804736ce1330c9d" [[package]] name = "bytecheck" @@ -786,35 +1538,18 @@ dependencies = [ ] [[package]] -name = "camino" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "276a59bf2b2c967788139340c9f0c5b12d7fd6630315c15c217e559de85d2609" -dependencies = [ - "serde_core", -] - -[[package]] -name = "cargo-platform" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" -dependencies = [ - "serde", -] - -[[package]] -name = "cargo_metadata" -version = "0.18.1" +name = "c-kzg" +version = "2.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d886547e41f740c616ae73108f6eb70afe6d940c7bc697cb30f13daec073037" +checksum = "e00bf4b112b07b505472dbefd19e37e53307e2bfed5a79e0cc161d58ccd0e687" dependencies = [ - "camino", - "cargo-platform", - "semver 1.0.27", + "blst", + "cc", + "glob", + "hex", + "libc", + "once_cell", "serde", - "serde_json", - "thiserror 1.0.69", ] [[package]] @@ -858,9 +1593,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.48" +version = "1.2.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c481bdbf0ed3b892f6f806287d72acd515b352a4ec27a208489b8c1bc839633a" +checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c" dependencies = [ "find-msvc-tools", "jobserver", @@ -900,7 +1635,7 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -1008,24 +1743,18 @@ checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" dependencies = [ "cipher", "dbl", - "digest", + "digest 0.10.7", ] [[package]] name = "cmake" -version = "0.1.54" +version = "0.1.57" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" dependencies = [ "cc", ] -[[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - [[package]] name = "colorchoice" version = "1.0.4" @@ -1085,6 +1814,18 @@ dependencies = [ "yaml-rust2", ] +[[package]] +name = "const-hex" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bb320cac8a0750d7f25280aa97b09c26edfe161164238ecbbb31092b079e735" +dependencies = [ + "cfg-if", + "cpufeatures", + "proptest", + "serde_core", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -1117,6 +1858,26 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f421161cb492475f1661ddc9815a745a1c894592070661180fdec3d4872e9c3" +[[package]] +name = "const_format" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7faa7469a93a566e9ccc1c73fe783b4a65c274c5ace346038dca9c39fe0030ad" +dependencies = [ + "const_format_proc_macros", +] + +[[package]] +name = "const_format_proc_macros" +version = "0.2.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744" +dependencies = [ + "proc-macro2", + "quote", + "unicode-xid", +] + [[package]] name = "constant_time_eq" version = "0.3.1" @@ -1194,6 +1955,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.5.0" @@ -1368,6 +2144,18 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" +dependencies = [ + "generic-array", + "rand_core 0.6.4", + "subtle", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -1397,7 +2185,7 @@ dependencies = [ "cfg-if", "cpufeatures", "curve25519-dalek-derive", - "digest", + "digest 0.10.7", "fiat-crypto", "rustc_version 0.4.1", "subtle", @@ -1421,31 +2209,67 @@ version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.11", + "darling_macro 0.20.11", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.111", ] [[package]] name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "serde", + "strsim", + "syn 2.0.111", +] + +[[package]] +name = "darling_macro" version = "0.20.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ - "fnv", - "ident_case", - "proc-macro2", + "darling_core 0.20.11", "quote", - "strsim", "syn 2.0.111", ] [[package]] name = "darling_macro" -version = "0.20.11" +version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ - "darling_core", + "darling_core 0.21.3", "quote", "syn 2.0.111", ] @@ -1530,6 +2354,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" dependencies = [ "powerfmt", + "serde_core", +] + +[[package]] +name = "derivative" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", ] [[package]] @@ -1547,7 +2383,7 @@ version = "0.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" dependencies = [ - "darling", + "darling 0.20.11", "proc-macro2", "quote", "syn 2.0.111", @@ -1592,6 +2428,15 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + [[package]] name = "digest" version = "0.10.7" @@ -1599,6 +2444,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", + "const-oid", "crypto-common", "subtle", ] @@ -1685,6 +2531,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "dynasm" version = "3.2.1" @@ -1712,6 +2564,21 @@ dependencies = [ "memmap2", ] +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der", + "digest 0.10.7", + "elliptic-curve", + "rfc6979", + "serdect", + "signature", + "spki", +] + [[package]] name = "ed25519" version = "2.2.3" @@ -1736,11 +2603,46 @@ dependencies = [ "zeroize", ] +[[package]] +name = "educe" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7bc049e1bd8cdeb31b68bbd586a9464ecf9f3944af3958a7a9d0f8b9799417" +dependencies = [ + "enum-ordinalize", + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "elliptic-curve" +version = "0.13.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" +dependencies = [ + "base16ct", + "crypto-bigint", + "digest 0.10.7", + "ff", + "generic-array", + "group", + "pkcs8", + "rand_core 0.6.4", + "sec1", + "serdect", + "subtle", + "zeroize", +] [[package]] name = "encoding_rs" @@ -1764,12 +2666,23 @@ dependencies = [ ] [[package]] -name = "env_filter" -version = "0.1.4" +name = "enum-ordinalize" +version = "4.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +checksum = "4a1091a7bb1f8f2c4b28f1fe2cef4980ca2d410a3d727d67ecc3178c9b0800f0" dependencies = [ - "log", + "enum-ordinalize-derive", +] + +[[package]] +name = "enum-ordinalize-derive" +version = "4.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", ] [[package]] @@ -1788,18 +2701,6 @@ dependencies = [ "regex", ] -[[package]] -name = "env_logger" -version = "0.11.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" -dependencies = [ - "anstream", - "anstyle", - "env_filter", - "log", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -1855,25 +2756,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" [[package]] -name = "exr" -version = "1.74.0" +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "fastrlp" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +checksum = "139834ddba373bbdd213dffe02c8d110508dcf1726c2be27e8d1f7d7e1856418" dependencies = [ - "bit_field", - "half 2.7.1", - "lebe", - "miniz_oxide", - "rayon-core", - "smallvec", - "zune-inflate", + "arrayvec", + "auto_impl", + "bytes", ] [[package]] -name = "fastrand" -version = "2.3.0" +name = "fastrlp" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +checksum = "ce8dba4714ef14b8274c371879b175aa55b16b30f269663f19d576f380018dc4" +dependencies = [ + "arrayvec", + "auto_impl", + "bytes", +] [[package]] name = "fax" @@ -1904,6 +2812,16 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "ff" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393" +dependencies = [ + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "fiat-crypto" version = "0.2.9" @@ -1934,6 +2852,18 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b31a14f5ee08ed1a40e1252b35af18bed062e3f39b69aab34decde36bc43e40" +[[package]] +name = "fixed-hash" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835c052cb0c08c1acf6ffd71c022172e18723949c8282f2b9f27efbc51e64534" +dependencies = [ + "byteorder", + "rand 0.8.5", + "rustc-hex", + "static_assertions", +] + [[package]] name = "fixedbitset" version = "0.5.7" @@ -1971,6 +2901,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1982,9 +2918,9 @@ dependencies = [ [[package]] name = "fs-err" -version = "3.2.0" +version = "3.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62d91fd049c123429b018c47887d3f75a265540dd3c30ba9cb7bae9197edb03a" +checksum = "824f08d01d0f496b3eca4f001a13cf17690a6ee930043d20817f547455fd98f8" dependencies = [ "autocfg", "tokio", @@ -2138,18 +3074,25 @@ dependencies = [ "slab", ] +[[package]] +name = "futures-utils-wasm" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42012b0f064e01aa58b545fe3727f90f7dd4020f4a3ea735b50344965f5a57e9" + [[package]] name = "generator" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "605183a538e3e2a9c1038635cc5c2d194e2ee8fd0d1b66b8349fad7dbacce5a2" +checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" dependencies = [ "cc", "cfg-if", "libc", "log", "rustversion", - "windows 0.61.3", + "windows-link", + "windows-result 0.4.1", ] [[package]] @@ -2160,6 +3103,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" dependencies = [ "typenum", "version_check", + "zeroize", ] [[package]] @@ -2169,7 +3113,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ "rustix 1.1.2", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -2199,16 +3143,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "gif" -version = "0.13.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b" -dependencies = [ - "color_quant", - "weezl", -] - [[package]] name = "glob" version = "0.3.3" @@ -2254,6 +3188,17 @@ dependencies = [ "web-time", ] +[[package]] +name = "group" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63" +dependencies = [ + "ff", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "h2" version = "0.4.12" @@ -2266,7 +3211,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap", + "indexmap 2.12.1", "slab", "tokio", "tokio-util", @@ -2306,6 +3251,12 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" @@ -2320,7 +3271,7 @@ checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ "allocator-api2", "equivalent", - "foldhash", + "foldhash 0.1.5", ] [[package]] @@ -2328,6 +3279,11 @@ name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash 0.2.0", + "serde", + "serde_core", +] [[package]] name = "hashlink" @@ -2386,12 +3342,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" -[[package]] -name = "hex_lit" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" - [[package]] name = "hickory-proto" version = "0.25.0-alpha.5" @@ -2454,7 +3404,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -2613,7 +3563,7 @@ version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-core", @@ -2664,7 +3614,6 @@ dependencies = [ "num-traits", "rand 0.9.2", "serde", - "serde_test", "static_assertions", ] @@ -2716,9 +3665,9 @@ checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" [[package]] name = "icu_properties" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" dependencies = [ "icu_collections", "icu_locale_core", @@ -2730,9 +3679,9 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" [[package]] name = "icu_provider" @@ -2832,34 +3781,47 @@ dependencies = [ [[package]] name = "image" -version = "0.24.9" +version = "0.25.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" dependencies = [ "bytemuck", - "byteorder", - "color_quant", - "exr", - "gif", - "jpeg-decoder", + "byteorder-lite", + "moxcms", "num-traits", - "png 0.17.16", - "qoi", - "tiff 0.9.1", + "png", + "tiff", ] [[package]] -name = "image" -version = "0.25.9" +name = "impl-codec" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +checksum = "ba6a270039626615617f3f36d15fc827041df3b78c439da2cadfa47455a77f2f" dependencies = [ - "bytemuck", - "byteorder-lite", - "moxcms", - "num-traits", - "png 0.18.0", - "tiff 0.10.3", + "parity-scale-codec", +] + +[[package]] +name = "impl-trait-for-tuples" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", ] [[package]] @@ -2870,6 +3832,8 @@ checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" dependencies = [ "equivalent", "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -2916,7 +3880,7 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c" dependencies = [ - "darling", + "darling 0.20.11", "indoc", "proc-macro2", "quote", @@ -2930,7 +3894,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37221e690dcc5d0ea7c1f70decda6ae3495e72e8af06bca15e982193ffdf4fc4" dependencies = [ "async-trait", - "base64 0.22.1", + "base64", "bytes", "http", "http-body", @@ -2996,6 +3960,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.12.1" @@ -3039,15 +4012,6 @@ dependencies = [ "libc", ] -[[package]] -name = "jpeg-decoder" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07" -dependencies = [ - "rayon", -] - [[package]] name = "js-sys" version = "0.3.83" @@ -3058,12 +4022,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "json" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "078e285eafdfb6c4b434e0d31e8cfcb5115b651496faca5749b88fafd4f23bfd" - [[package]] name = "json5" version = "0.4.1" @@ -3076,15 +4034,36 @@ dependencies = [ ] [[package]] -name = "jsonrpc" -version = "0.18.0" +name = "k256" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3662a38d341d77efecb73caf01420cfa5aa63c0253fd7bc05289ef9f6616e1bf" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" dependencies = [ - "base64 0.13.1", - "minreq", - "serde", - "serde_json", + "cfg-if", + "ecdsa", + "elliptic-curve", + "once_cell", + "serdect", + "sha2", +] + +[[package]] +name = "keccak" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +dependencies = [ + "cpufeatures", +] + +[[package]] +name = "keccak-asm" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "505d1856a39b200489082f90d897c3f07c455563880bc5952e38eabf731c83b6" +dependencies = [ + "digest 0.10.7", + "sha3-asm", ] [[package]] @@ -3146,12 +4125,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" -[[package]] -name = "lebe" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" - [[package]] name = "libc" version = "0.2.178" @@ -3165,9 +4138,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "windows-link 0.2.1", + "windows-link", ] +[[package]] +name = "libm" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" + [[package]] name = "libp2p" version = "0.56.0" @@ -3283,9 +4262,9 @@ dependencies = [ [[package]] name = "libp2p-identity" -version = "0.2.12" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3104e13b51e4711ff5738caa1fb54467c8604c2e94d607e27745bcf709068774" +checksum = "f0c7892c221730ba55f7196e98b0b8ba5e04b4155651736036628e9f73ed6fc3" dependencies = [ "bs58", "ed25519-dalek", @@ -3321,7 +4300,7 @@ dependencies = [ "smallvec", "thiserror 2.0.17", "tracing", - "uint", + "uint 0.10.0", "web-time", ] @@ -3380,7 +4359,7 @@ source = "git+https://github.com/libp2p/rust-libp2p.git?rev=da0017ee887a868e231e dependencies = [ "libp2p-core", "libp2p-swarm", - "lru", + "lru 0.12.5", ] [[package]] @@ -3449,7 +4428,7 @@ dependencies = [ "libp2p-core", "libp2p-identity", "libp2p-swarm-derive", - "lru", + "lru 0.12.5", "multistream-select", "rand 0.8.5", "smallvec", @@ -3497,7 +4476,7 @@ dependencies = [ "rustls", "rustls-webpki", "thiserror 2.0.17", - "x509-parser", + "x509-parser 0.17.0", "yasna", ] @@ -3517,13 +4496,13 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.10" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +checksum = "df15f6eac291ed1cf25865b1ee60399f57e7c227e7f51bdbd4c5270396a9ed50" dependencies = [ "bitflags 2.10.0", "libc", - "redox_syscall", + "redox_syscall 0.6.0", ] [[package]] @@ -3587,12 +4566,32 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lru" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "227748d55f2f0ab4735d87fd623798cb6b664512fe979705f829c9f81c934465" +dependencies = [ + "hashbrown 0.15.5", +] + [[package]] name = "lru-slab" version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +[[package]] +name = "macro-string" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "maplit" version = "1.0.2" @@ -3691,16 +4690,6 @@ dependencies = [ "simd-adler32", ] -[[package]] -name = "minreq" -version = "2.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05015102dad0f7d61691ca347e9d9d9006685a64aefb3d79eecf62665de2153d" -dependencies = [ - "serde", - "serde_json", -] - [[package]] name = "mio" version = "0.8.11" @@ -3745,9 +4734,9 @@ dependencies = [ [[package]] name = "moxcms" -version = "0.7.10" +version = "0.7.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80986bbbcf925ebd3be54c26613d861255284584501595cf418320c078945608" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" dependencies = [ "num-traits", "pxfm", @@ -3846,6 +4835,7 @@ name = "murmur3-sys" version = "0.1.0" dependencies = [ "bindgen", + "cc", ] [[package]] @@ -3912,15 +4902,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "newtype_derive" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac8cd24d9f185bb7223958d8c1ff7a961b74b1953fd05dba7cc568a63b3861ec" -dependencies = [ - "rustc_version 0.1.7", -] - [[package]] name = "nix" version = "0.26.4" @@ -3969,9 +4950,9 @@ dependencies = [ "opentelemetry-otlp", "opentelemetry_sdk", "rand 0.9.2", - "rcgen 0.14.5", + "rcgen 0.14.6", "rustls", - "rustls-pemfile", + "rustls-pki-types", "serde", "serde_json", "signal-hook", @@ -3990,7 +4971,7 @@ dependencies = [ "tracing-test", "tracing-tracy", "webpki-roots", - "x509-parser", + "x509-parser 0.17.0", "yaque", ] @@ -4023,7 +5004,6 @@ dependencies = [ "thiserror 2.0.17", "tokio", "tokio-stream", - "tokio-test", "tonic 0.14.2", "tonic-build", "tonic-health", @@ -4048,7 +5028,6 @@ dependencies = [ "thiserror 2.0.17", "tonic 0.14.2", "tonic-build", - "tonic-health", "tonic-prost", "tonic-prost-build", ] @@ -4057,12 +5036,10 @@ dependencies = [ name = "nockchain" version = "0.1.0" dependencies = [ - "bitcoincore-rpc", "bs58", "clap", "equix", "futures", - "hoonc", "ibig", "kernels", "libp2p", @@ -4081,9 +5058,7 @@ dependencies = [ "tikv-jemallocator", "tokio", "tracing", - "tracing-test", "tracy-client", - "vergen", "zkvm-jetpack", ] @@ -4102,7 +5077,6 @@ dependencies = [ "tempfile", "tikv-jemallocator", "tokio", - "tokio-test", "tonic 0.14.2", "tracy-client", "zkvm-jetpack", @@ -4117,11 +5091,10 @@ dependencies = [ "chrono", "clap", "crossterm 0.29.0", - "nockapp-grpc", "nockapp-grpc-proto", "nockchain-math", "nockchain-types", - "ratatui 0.28.1", + "ratatui", "tokio", "tonic 0.14.2", "tracing", @@ -4150,6 +5123,7 @@ dependencies = [ "nockvm", "nockvm_macros", "noun-serde", + "num-bigint", "quickcheck", "rand 0.9.2", "serde", @@ -4157,8 +5131,6 @@ dependencies = [ "serde_cbor", "tokio", "tracing", - "void", - "zkvm-jetpack", ] [[package]] @@ -4170,7 +5142,6 @@ dependencies = [ "bs58", "bytes", "either", - "hex-literal", "ibig", "nockvm", "nockvm_macros", @@ -4204,15 +5175,18 @@ dependencies = [ name = "nockchain-types" version = "0.1.0" dependencies = [ + "alloy", "anyhow", "bs58", "bytes", + "hex", "ibig", "nockapp", "nockchain-math", "nockvm", "nockvm_macros", "noun-serde", + "num-bigint", "quickcheck", "serde", "thiserror 2.0.17", @@ -4224,14 +5198,12 @@ dependencies = [ name = "nockchain-wallet" version = "0.1.0" dependencies = [ - "bardecoder", "bs58", "clap", "crossterm 0.29.0", "either", "getrandom 0.3.4", "http", - "image 0.24.9", "kernels", "nockapp", "nockapp-grpc", @@ -4240,14 +5212,11 @@ dependencies = [ "nockvm", "nockvm_macros", "noun-serde", - "qrcode", - "ratatui 0.29.0", "rustls", "serde", "serde_json", "tempfile", "termimad", - "test-log", "thiserror 2.0.17", "tokio", "tonic 0.14.2", @@ -4258,7 +5227,7 @@ dependencies = [ [[package]] name = "nockup" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", "assert_cmd", @@ -4268,13 +5237,14 @@ dependencies = [ "colored", "dirs", "flate2", - "fs_extra", "handlebars", "hex", + "once_cell", "openssl-sys", "predicates", "proptest", "reqwest", + "semver 1.0.27", "serde", "serde_json", "sha1", @@ -4283,7 +5253,6 @@ dependencies = [ "thiserror 2.0.17", "tokio", "toml", - "walkdir", "which 8.0.0", ] @@ -4300,7 +5269,6 @@ dependencies = [ "either", "ibig", "intmap", - "json", "lazy_static", "libc", "memmap2", @@ -4311,7 +5279,6 @@ dependencies = [ "num-traits", "rand 0.9.2", "serde", - "signal-hook", "slotmap", "static_assertions", "thiserror 2.0.17", @@ -4482,6 +5449,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -4495,12 +5463,38 @@ dependencies = [ ] [[package]] -name = "num_threads" -version = "0.1.7" +name = "num_enum" +version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c" dependencies = [ - "libc", + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + +[[package]] +name = "nybbles" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4b5ecbd0beec843101bffe848217f770e8b8da81d8355b7d6e226f2199b3dc" +dependencies = [ + "alloy-rlp", + "cfg-if", + "proptest", + "ruint", + "serde", + "smallvec", ] [[package]] @@ -4723,6 +5717,34 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "parity-scale-codec" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799781ae679d79a948e13d4824a40970bfa500058d245760dd857301059810fa" +dependencies = [ + "arrayvec", + "bitvec", + "byte-slice-cast", + "const_format", + "impl-trait-for-tuples", + "parity-scale-codec-derive", + "rustversion", + "serde", +] + +[[package]] +name = "parity-scale-codec-derive" +version = "3.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b4653168b563151153c9e4c08ebed57fb8262bebfa79711552fa983c623e7a" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "parking" version = "2.2.1" @@ -4747,9 +5769,9 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -4781,7 +5803,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64 0.22.1", + "base64", "serde_core", ] @@ -4841,7 +5863,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" dependencies = [ "fixedbitset", - "indexmap", + "indexmap 2.12.1", ] [[package]] @@ -4920,19 +5942,6 @@ dependencies = [ "plotters-backend", ] -[[package]] -name = "png" -version = "0.17.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" -dependencies = [ - "bitflags 1.3.2", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - [[package]] name = "png" version = "0.18.0" @@ -4962,9 +5971,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483" +checksum = "f59e70c4aef1e55797c2e8fd94a4f2a973fc972cfde0e0b05f683667b0cd39dd" [[package]] name = "potential_utf" @@ -5030,6 +6039,26 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "primitive-types" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b34d9fd68ae0b74a41b21c03c2f62847aa0ffea044eee893b4c140b37e244e2" +dependencies = [ + "fixed-hash", + "impl-codec", + "uint 0.9.5", +] + +[[package]] +name = "proc-macro-crate" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +dependencies = [ + "toml_edit", +] + [[package]] name = "proc-macro-error-attr2" version = "2.0.0" @@ -5222,31 +6251,13 @@ dependencies = [ [[package]] name = "pxfm" -version = "0.1.26" +version = "0.1.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3502d6155304a4173a5f2c34b52b7ed0dd085890326cb50fd625fdf39e86b3b" +checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8" dependencies = [ "num-traits", ] -[[package]] -name = "qoi" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "qrcode" -version = "0.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d68782463e408eb1e668cf6152704bd856c78c5b6417adaee3203d8f4c1fc9ec" -dependencies = [ - "image 0.25.9", -] - [[package]] name = "quanta" version = "0.12.6" @@ -5301,7 +6312,7 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "588f6378e4dd99458b60ec275b4477add41ce4fa9f64dcba6f15adccb19b50d6" dependencies = [ - "env_logger 0.8.4", + "env_logger", "log", "rand 0.8.5", ] @@ -5401,6 +6412,7 @@ dependencies = [ "libc", "rand_chacha 0.3.1", "rand_core 0.6.4", + "serde", ] [[package]] @@ -5411,6 +6423,7 @@ checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", + "serde", ] [[package]] @@ -5449,6 +6462,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" dependencies = [ "getrandom 0.3.4", + "serde", ] [[package]] @@ -5461,45 +6475,33 @@ dependencies = [ ] [[package]] -name = "ratatui" -version = "0.28.1" +name = "rapidhash" +version = "4.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" +checksum = "d8e65c75143ce5d47c55b510297eeb1182f3c739b6043c537670e9fc18612dae" dependencies = [ - "bitflags 2.10.0", - "cassowary", - "compact_str", - "crossterm 0.28.1", - "instability", - "itertools 0.13.0", - "lru", - "paste", - "strum 0.26.3", - "strum_macros 0.26.4", - "unicode-segmentation", - "unicode-truncate", - "unicode-width 0.1.14", + "rustversion", ] [[package]] name = "ratatui" -version = "0.29.0" +version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +checksum = "fdef7f9be5c0122f890d58bdf4d964349ba6a6161f705907526d891efabba57d" dependencies = [ "bitflags 2.10.0", "cassowary", "compact_str", "crossterm 0.28.1", - "indoc", "instability", "itertools 0.13.0", - "lru", + "lru 0.12.5", "paste", "strum 0.26.3", + "strum_macros 0.26.4", "unicode-segmentation", "unicode-truncate", - "unicode-width 0.2.0", + "unicode-width", ] [[package]] @@ -5560,14 +6562,15 @@ dependencies = [ [[package]] name = "rcgen" -version = "0.14.5" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fae430c6b28f1ad601274e78b7dffa0546de0b73b4cd32f46723c0c2a16f7a5" +checksum = "3ec0a99f2de91c3cddc84b37e7db80e4d96b743e05607f647eb236fc0455907f" dependencies = [ "pem", "ring", "rustls-pki-types", "time", + "x509-parser 0.18.0", "yasna", ] @@ -5580,6 +6583,15 @@ dependencies = [ "bitflags 2.10.0", ] +[[package]] +name = "redox_syscall" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec96166dafa0886eb81fe1c0a388bece180fbef2135f97c1e2cf8302e74b43b5" +dependencies = [ + "bitflags 2.10.0", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -5591,6 +6603,26 @@ dependencies = [ "thiserror 2.0.17", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "regex" version = "1.12.2" @@ -5631,11 +6663,11 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.24" +version = "0.12.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d0946410b9f7b082a427e4ef5c8ff541a88b357bc6c637c40db3a68ac70a36f" +checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "encoding_rs", "futures-channel", @@ -5687,6 +6719,16 @@ dependencies = [ "rand 0.9.2", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac", + "subtle", +] + [[package]] name = "ring" version = "0.17.14" @@ -5710,7 +6752,7 @@ dependencies = [ "bytecheck", "bytes", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.12.1", "munge", "ptr_meta", "rancor", @@ -5731,6 +6773,16 @@ dependencies = [ "syn 2.0.111", ] +[[package]] +name = "rlp" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb919243f34364b6bd2fc10ef797edbfa75f33c252e7998527479c6d6b47e1ec" +dependencies = [ + "bytes", + "rustc-hex", +] + [[package]] name = "ron" version = "0.12.0" @@ -5763,6 +6815,40 @@ dependencies = [ "tokio", ] +[[package]] +name = "ruint" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68df0380e5c9d20ce49534f292a36a7514ae21350726efe1865bdb1fa91d278" +dependencies = [ + "alloy-rlp", + "ark-ff 0.3.0", + "ark-ff 0.4.2", + "ark-ff 0.5.0", + "bytes", + "fastrlp 0.3.1", + "fastrlp 0.4.0", + "num-bigint", + "num-integer", + "num-traits", + "parity-scale-codec", + "primitive-types", + "proptest", + "rand 0.8.5", + "rand 0.9.2", + "rlp", + "ruint-macro", + "serde_core", + "valuable", + "zeroize", +] + +[[package]] +name = "ruint-macro" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48fd7bd8a6377e15ad9d42a8ec25371b94ddc67abe7c8b9127bec79bebaaae18" + [[package]] name = "rust-ini" version = "0.21.3" @@ -5785,13 +6871,19 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +[[package]] +name = "rustc-hex" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e75f6a532d0fd9f7f13144f392b6ad56a32696bfcd9c78f797f16bbb6f072d6" + [[package]] name = "rustc_version" -version = "0.1.7" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5f5376ea5e30ce23c03eb77cbe4962b988deead10910c372b226388b594c084" +checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee" dependencies = [ - "semver 0.1.20", + "semver 0.11.0", ] [[package]] @@ -5866,20 +6958,11 @@ dependencies = [ "security-framework", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" -version = "1.13.1" +version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708c0f9d5f54ba0272468c1d306a52c495b31fa155e91bc25371e6df7996908c" +checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" dependencies = [ "web-time", "zeroize", @@ -5949,6 +7032,30 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "scoped-tls" version = "1.0.1" @@ -5961,11 +7068,26 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct", + "der", + "generic-array", + "pkcs8", + "serdect", + "subtle", + "zeroize", +] + [[package]] name = "secp256k1" -version = "0.29.1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" dependencies = [ "bitcoin_hashes", "rand 0.8.5", @@ -6007,18 +7129,26 @@ dependencies = [ [[package]] name = "semver" -version = "0.1.20" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4f410fedcf71af0345d7607d246e7ad15faaadd49d240ee3b24e5dc21a820ac" +checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" +dependencies = [ + "semver-parser", +] [[package]] name = "semver" version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "semver-parser" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9900206b54a3527fdc7b8a938bffd94a568bac4f4aa8113b209df75a09c0dec2" dependencies = [ - "serde", - "serde_core", + "pest", ] [[package]] @@ -6104,36 +7234,68 @@ checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" dependencies = [ "itoa", "serde", - "serde_core", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", ] [[package]] -name = "serde_spanned" -version = "1.0.3" +name = "serde_with" +version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e24345aa0fe688594e73770a5f6d1b216508b4f93484c0026d521acd30134392" +checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.12.1", + "schemars 0.9.0", + "schemars 1.1.0", "serde_core", + "serde_json", + "serde_with_macros", + "time", ] [[package]] -name = "serde_test" -version = "1.0.177" +name = "serde_with_macros" +version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f901ee573cab6b3060453d2d5f0bae4e6d628c23c0a962ff9b5f1d7c8d4f1ed" +checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c" dependencies = [ - "serde", + "darling 0.21.3", + "proc-macro2", + "quote", + "syn 2.0.111", ] [[package]] -name = "serde_urlencoded" -version = "0.7.1" +name = "serdect" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +checksum = "a84f14a19e9a014bb9f4512488d9829a68e04ecabffb0f9904cd1ace94598177" dependencies = [ - "form_urlencoded", - "itoa", - "ryu", + "base16ct", "serde", ] @@ -6145,7 +7307,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures", - "digest", + "digest 0.10.7", ] [[package]] @@ -6156,7 +7318,27 @@ checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", "cpufeatures", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha3" +version = "0.10.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75872d278a8f37ef87fa0ddbda7802605cb18344497949862c0d4dcb291eba60" +dependencies = [ + "digest 0.10.7", + "keccak", +] + +[[package]] +name = "sha3-asm" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28efc5e327c837aa837c59eae585fc250715ef939ac32881bcc11677cd02d46" +dependencies = [ + "cc", + "cfg-if", ] [[package]] @@ -6222,14 +7404,15 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ + "digest 0.10.7", "rand_core 0.6.4", ] [[package]] name = "simd-adler32" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "simdutf8" @@ -6245,9 +7428,9 @@ checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" [[package]] name = "slotmap" -version = "1.0.7" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbff4acf519f630b3a3ddcfaea6c06b42174d9a44bc70c620e9ed1649d58b82a" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" dependencies = [ "version_check", ] @@ -6257,6 +7440,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "socket2" @@ -6392,6 +7578,18 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn-solidity" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b1d2e2059056b66fec4a6bb2b79511d5e8d76196ef49c38996f4b48db7662f" +dependencies = [ + "paste", + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -6426,20 +7624,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "sysinfo" -version = "0.30.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a5b4ddaee55fb2bea2bf0e5000747e5f5c0de765e5a5ff87f4cd106439f4bb3" -dependencies = [ - "cfg-if", - "core-foundation-sys", - "libc", - "ntapi", - "once_cell", - "windows 0.52.0", -] - [[package]] name = "sysinfo" version = "0.33.1" @@ -6533,7 +7717,7 @@ dependencies = [ "minimad", "serde", "thiserror 2.0.17", - "unicode-width 0.1.14", + "unicode-width", ] [[package]] @@ -6542,28 +7726,6 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" -[[package]] -name = "test-log" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37d53ac171c92a39e4769491c4b4dde7022c60042254b5fc044ae409d34a24d4" -dependencies = [ - "env_logger 0.11.8", - "test-log-macros", - "tracing-subscriber", -] - -[[package]] -name = "test-log-macros" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be35209fd0781c5401458ab66e4f98accf63553e8fae7425503e92fdd319783b" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.111", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -6614,14 +7776,12 @@ dependencies = [ ] [[package]] -name = "tiff" -version = "0.9.1" +name = "threadpool" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" dependencies = [ - "flate2", - "jpeg-decoder", - "weezl", + "num_cpus", ] [[package]] @@ -6666,9 +7826,7 @@ checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" dependencies = [ "deranged", "itoa", - "libc", "num-conv", - "num_threads", "powerfmt", "serde", "time-core", @@ -6786,19 +7944,6 @@ dependencies = [ "tokio-util", ] -[[package]] -name = "tokio-test" -version = "0.4.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2468baabc3311435b55dd935f702f42cd1b8abb7e754fb7dfb16bd36aa88f9f7" -dependencies = [ - "async-stream", - "bytes", - "futures-core", - "tokio", - "tokio-stream", -] - [[package]] name = "tokio-util" version = "0.7.17" @@ -6815,11 +7960,11 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.8" +version = "0.9.10+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0dc8b1fb61449e27716ec0e1bdf0f6b8f3e8f6b05391e8497b8b6d7804ea6d8" +checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" dependencies = [ - "indexmap", + "indexmap 2.12.1", "serde_core", "serde_spanned", "toml_datetime", @@ -6830,27 +7975,39 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.3" +version = "0.7.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2cdb639ebbc97961c51720f858597f7f24c4fc295327923af55b74c3c724533" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" dependencies = [ "serde_core", ] +[[package]] +name = "toml_edit" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap 2.12.1", + "toml_datetime", + "toml_parser", + "winnow", +] + [[package]] name = "toml_parser" -version = "1.0.4" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0cbe268d35bdb4bb5a56a2de88d0ad0eb70af5384a99d648cd4b3d04039800e" +checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.0.4" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df8b2b54733674ad286d16267dcfc7a71ed5c776e4ac7aa3c3e2561f7c637bf2" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" [[package]] name = "tonic" @@ -6859,7 +8016,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e581ba15a835f4d9ea06c55ab1bd4dce26fc53752c69a04aac00703bfb49ba9" dependencies = [ "async-trait", - "base64 0.22.1", + "base64", "bytes", "http", "http-body", @@ -6886,7 +8043,7 @@ checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203" dependencies = [ "async-trait", "axum", - "base64 0.22.1", + "base64", "bytes", "h2", "http", @@ -6983,7 +8140,7 @@ checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" dependencies = [ "futures-core", "futures-util", - "indexmap", + "indexmap 2.12.1", "pin-project-lite", "slab", "sync_wrapper", @@ -6996,9 +8153,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.7" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" dependencies = [ "bitflags 2.10.0", "bytes", @@ -7036,9 +8193,9 @@ checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" [[package]] name = "tracing" -version = "0.1.43" +version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d15d90a0b5c19378952d479dc858407149d7bb45a14de0142f6c534b16fc647" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "log", "pin-project-lite", @@ -7059,9 +8216,9 @@ dependencies = [ [[package]] name = "tracing-core" -version = "0.1.35" +version = "0.1.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a04e24fab5c89c6a36eb8558c9656f30d81de51dfa4d3b45f26b21d61fa0a6c" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", "valuable", @@ -7191,6 +8348,18 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" +[[package]] +name = "uint" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76f64bba2c53b04fcab63c01a7d7427eadc821e3bc48c34dc9ba29c501164b52" +dependencies = [ + "byteorder", + "crunchy", + "hex", + "static_assertions", +] + [[package]] name = "uint" version = "0.10.0" @@ -7235,7 +8404,7 @@ checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ "itertools 0.13.0", "unicode-segmentation", - "unicode-width 0.1.14", + "unicode-width", ] [[package]] @@ -7244,12 +8413,6 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" -[[package]] -name = "unicode-width" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" - [[package]] name = "unicode-xid" version = "0.2.6" @@ -7321,22 +8484,6 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" -[[package]] -name = "vergen" -version = "8.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2990d9ea5967266ea0ccf413a4aa5c42a93dbcfda9cb49a97de6931726b12566" -dependencies = [ - "anyhow", - "cargo_metadata", - "cfg-if", - "regex", - "rustc_version 0.4.1", - "rustversion", - "sysinfo 0.30.13", - "time", -] - [[package]] name = "version_check" version = "0.9.5" @@ -7360,12 +8507,6 @@ dependencies = [ "syn 2.0.111", ] -[[package]] -name = "void" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" - [[package]] name = "wait-timeout" version = "0.2.1" @@ -7467,6 +8608,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasmtimer" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" +dependencies = [ + "futures", + "js-sys", + "parking_lot", + "pin-utils", + "slab", + "wasm-bindgen", +] + [[package]] name = "web-sys" version = "0.3.83" @@ -7562,16 +8717,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" -dependencies = [ - "windows-core 0.52.0", - "windows-targets 0.52.6", -] - [[package]] name = "windows" version = "0.53.0" @@ -7592,37 +8737,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows" -version = "0.61.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" -dependencies = [ - "windows-collections", - "windows-core 0.61.2", - "windows-future", - "windows-link 0.1.3", - "windows-numerics", -] - -[[package]] -name = "windows-collections" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" -dependencies = [ - "windows-core 0.61.2", -] - -[[package]] -name = "windows-core" -version = "0.52.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-core" version = "0.53.0" @@ -7645,19 +8759,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-core" -version = "0.61.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" -dependencies = [ - "windows-implement 0.60.2", - "windows-interface 0.59.3", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", -] - [[package]] name = "windows-core" version = "0.62.2" @@ -7666,20 +8767,9 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement 0.60.2", "windows-interface 0.59.3", - "windows-link 0.2.1", + "windows-link", "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[package]] -name = "windows-future" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", - "windows-threading", + "windows-strings", ] [[package]] @@ -7726,28 +8816,12 @@ dependencies = [ "syn 2.0.111", ] -[[package]] -name = "windows-link" -version = "0.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" - [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-numerics" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" -dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", -] - [[package]] name = "windows-result" version = "0.1.2" @@ -7757,31 +8831,13 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-result" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" -dependencies = [ - "windows-link 0.1.3", -] - [[package]] name = "windows-result" version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" dependencies = [ - "windows-link 0.2.1", -] - -[[package]] -name = "windows-strings" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" -dependencies = [ - "windows-link 0.1.3", + "windows-link", ] [[package]] @@ -7790,7 +8846,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -7844,7 +8900,7 @@ version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -7899,7 +8955,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link 0.2.1", + "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -7910,15 +8966,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows-threading" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" -dependencies = [ - "windows-link 0.1.3", -] - [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -8189,6 +9236,24 @@ dependencies = [ "time", ] +[[package]] +name = "x509-parser" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb3e137310115a65136898d2079f003ce33331a6c4b0d51f1531d1be082b6425" +dependencies = [ + "asn1-rs", + "data-encoding", + "der-parser", + "lazy_static", + "nom", + "oid-registry", + "ring", + "rusticata-macros", + "thiserror 2.0.17", + "time", +] + [[package]] name = "xattr" version = "1.6.1" @@ -8318,6 +9383,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.111", +] [[package]] name = "zerotrie" @@ -8371,7 +9450,6 @@ dependencies = [ "once_cell", "quickcheck", "rayon", - "smallvec", "strum 0.27.2", "tracing", ] @@ -8382,15 +9460,6 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" -[[package]] -name = "zune-inflate" -version = "0.2.54" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" -dependencies = [ - "simd-adler32", -] - [[package]] name = "zune-jpeg" version = "0.4.21" diff --git a/Cargo.toml b/Cargo.toml index 158efab4b..44b4d7ec4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ -[workspace] resolver = "2" -members = ["crates/nockchain-api", "crates/nockchain-explorer-tui", "crates/nockchain-peek", "crates/equix-latency", "crates/hoon", "crates/hoonc", "crates/kernels", "crates/nockapp", "crates/nockapp-grpc", "crates/nockapp-grpc-proto", "crates/nockchain", "crates/nockchain-libp2p-io", "crates/nockchain-math", "crates/nockchain-types", "crates/nockchain-wallet", "crates/raw-tx-checker", "crates/nockvm/rust/ibig", "crates/nockvm/rust/murmur3", "crates/nockup", "crates/nockvm/rust/nockvm", "crates/nockvm/rust/nockvm_macros", "crates/noun-serde", "crates/noun-serde-derive", "crates/zkvm-jetpack"] + +[workspace] +members = ["crates/equix-latency", "crates/hoon", "crates/hoonc", "crates/kernels", "crates/nockapp", "crates/nockapp-grpc", "crates/nockapp-grpc-proto", "crates/nockchain", "crates/nockchain-api", "crates/nockchain-explorer-tui", "crates/nockchain-libp2p-io", "crates/nockchain-math", "crates/nockchain-peek", "crates/nockchain-types", "crates/nockchain-wallet", "crates/nockup", "crates/nockvm/rust/ibig", "crates/nockvm/rust/murmur3", "crates/nockvm/rust/nockvm", "crates/nockvm/rust/nockvm_macros", "crates/noun-serde", "crates/noun-serde-derive", "crates/raw-tx-checker", "crates/zkvm-jetpack"] [workspace.package] version = "0.1.0" @@ -9,12 +10,14 @@ edition = "2021" [workspace.dependencies] aes = { version = "0.8.3", default-features = false } aes-siv = { version = "0.7.0", default-features = false, features = ["alloc"] } +alloy = "1.1.2" anyhow = "1.0" argon2 = "0.5.3" arrayref = "0.3.7" +assert_cmd = "2.0" async-trait = "0.1" axum = "0.8.1" -axum-server = { version = "0.7.2", features = ["tls-rustls"] } +axum-server = { version = "0.8.0", features = ["tls-rustls"] } bardecoder = "0.5.0" bincode = "2.0.0-rc.3" bitcoincore-rpc = "0.19.0" @@ -26,7 +29,8 @@ bytes = "1.5.0" cbor4ii = "1.0.0" cfg-if = "1.0.0" chrono = "0.4.40" -clap = { version = "4.4.4", features = ["derive"] } +clap = "4.4.4" +colored = "2.0" config = "0.15" console-subscriber = "0.4.1" criterion = { git = "https://github.com/vlovich/criterion.rs.git", rev = "9b485aece85a3546126b06cc25d33e14aba829b3", features = [ @@ -40,11 +44,14 @@ divan = "0.1.21" ed25519-dalek = { version = "2.1.0", default-features = false } either = "1.9.0" equix = "0.2.2" +flate2 = "1.1.5" +fs_extra = "1.3" futures = "0.3.31" gdt-cpus = "25.5.0" getrandom = { version = "0.3.3", features = ["std"] } glob = "0.3" gnort = "0.2.0" +handlebars = "6.3.2" hex = "0.4" hex-literal = "1.0.0" hickory-proto = "0.25.0-alpha.4" @@ -52,15 +59,18 @@ hickory-resolver = { version = "0.25.0-alpha.4", features = ["system-config"] } image = "0.24.7" instant-acme = "0.7.2" intmap = "3.1.0" -json = "0.12.4" lazy_static = "1.4.0" libc = "0.2.171" libp2p = { git = "https://github.com/libp2p/rust-libp2p.git", rev = "da0017ee887a868e231ed78c7de892779c17800d" } memmap2 = "^0.9.5" +nu-ansi-term = "0.50" +num-bigint = "0.4.6" num-derive = "0.4.2" num-traits = "0.2" num_cpus = "1.16.0" once_cell = "1.21.3" +op-alloy = "0.23.1" +openssl-sys = "0.9" opentelemetry = { version = "0.30.0", features = [ "trace", "logs", @@ -75,7 +85,9 @@ opentelemetry-otlp = { version = "0.30.0", features = [ opentelemetry_sdk = { version = "0.30.0", features = ["rt-tokio"] } parking_lot = "0.12.4" pin-project-lite = "0.2.16" +predicates = "3.0" proc-macro2 = "1.0.91" +proptest = "1.9.0" prost = "0.14.1" prost-build = "0.14.1" prost-types = "0.14.1" @@ -92,10 +104,11 @@ reqwest = { version = "0.12", default-features = false, features = [ "http2", "charset", "json", + "blocking", ] } rkyv = "0.8.10" rustls = "0.23.0" -rustls-pemfile = "2.0.0" +rustls-pki-types = "1.13.1" serde = "1.0.217" serde_bytes = { version = "0.11.15", features = ["alloc"] } serde_cbor = "0.11" @@ -110,11 +123,12 @@ smallvec = "1.15.1" static_assertions = "1.1.0" strum = { version = "0.27.2", features = ["derive"] } syn = { version = "2.0.39", features = ["full"] } +tar = "0.4.44" tempfile = "3.3" termcolor = "1.4" termimad = "0.33.0" -testcontainers = { git = "https://github.com/bitemyapp/testcontainers-rs.git", rev = "54851fd9faf9b9cded9d681b46f87c056880d870" } test-log = "0.2.18" +testcontainers = { git = "https://github.com/bitemyapp/testcontainers-rs.git", rev = "17c00e411341943196ccd9df1165e60a4d1c40f9" } thiserror = "2.0.11" tikv-jemallocator = { version = "0.6" } tiny-keccak = { version = "2", features = ["keccak"] } @@ -131,6 +145,7 @@ tokio = { version = "1.32", features = [ tokio-rustls = "0.26.0" tokio-stream = "0.1" tokio-util = "0.7.11" +toml = "0.9.8" tonic = { version = "0.14.0", features = ["tls-webpki-roots"] } tonic-build = "0.14" tonic-health = "0.14.2" @@ -152,7 +167,9 @@ tracy-client = { version = "0.18.2" } unroll = "0.1.5" vergen = "8.3.2" void = "1.0.2" +walkdir = "2.5.0" webpki-roots = "1.0.2" +which = "8.0" x25519-dalek = { version = "2.0.0", features = [ "static_secrets", ], default-features = false } @@ -177,12 +194,12 @@ path = "crates/nockvm/rust/murmur3" [workspace.dependencies.nockapp] path = "crates/nockapp" -[workspace.dependencies.nockapp-grpc-proto] -path = "crates/nockapp-grpc-proto" - [workspace.dependencies.nockapp-grpc] path = "crates/nockapp-grpc" +[workspace.dependencies.nockapp-grpc-proto] +path = "crates/nockapp-grpc-proto" + [workspace.dependencies.nockchain] path = "crates/nockchain" @@ -201,7 +218,6 @@ path = "crates/nockchain-wallet" [workspace.dependencies.nockvm] path = "crates/nockvm/rust/nockvm" - [workspace.dependencies.nockvm_crypto] path = "crates/nockvm/rust/nockvm_crypto" diff --git a/Makefile b/Makefile index d1f0997d7..d021d3b66 100644 --- a/Makefile +++ b/Makefile @@ -84,7 +84,7 @@ build-trivial: ensure-dirs echo '%trivial' > hoon/trivial.hoon hoonc --arbitrary hoon/trivial.hoon -HOON_TARGETS=assets/dumb.jam assets/wal.jam assets/miner.jam assets/peek.jam +HOON_TARGETS=assets/dumb.jam assets/wal.jam assets/miner.jam assets/peek.jam assets/bridge.jam .PHONY: nuke-hoonc-data nuke-hoonc-data: @@ -136,3 +136,10 @@ assets/peek.jam: ensure-dirs hoon/apps/peek/peek.hoon $(HOON_SRCS) rm -f assets/peek.jam hoonc hoon/apps/peek/peek.hoon hoon mv out.jam assets/peek.jam + +## Build bridge.jam +assets/bridge.jam: ensure-dirs hoon/apps/bridge/bridge.hoon $(HOON_SRCS) + $(call show_env_vars) + rm -f assets/bridge.jam + hoonc hoon/apps/bridge/bridge.hoon hoon + mv out.jam assets/bridge.jam diff --git a/crates/equix-latency/Cargo.toml b/crates/equix-latency/Cargo.toml index d051c4f82..1330784e4 100644 --- a/crates/equix-latency/Cargo.toml +++ b/crates/equix-latency/Cargo.toml @@ -2,6 +2,7 @@ name = "equix-latency" version.workspace = true edition.workspace = true +license = "MIT OR Apache-2.0" [dependencies] equix.workspace = true diff --git a/crates/equix-latency/src/main.rs b/crates/equix-latency/src/main.rs index cb93a7785..c1cfb1fc5 100644 --- a/crates/equix-latency/src/main.rs +++ b/crates/equix-latency/src/main.rs @@ -5,7 +5,9 @@ use rand::TryRngCore; fn main() { let mut msg = [0u8; 65536]; - OsRng.try_fill_bytes(&mut msg).unwrap(); + OsRng + .try_fill_bytes(&mut msg) + .expect("random bytes should be available"); let mut builder = equix::EquiXBuilder::new(); builder.runtime(equix::RuntimeOption::CompileOnly); diff --git a/crates/hoon/Cargo.toml b/crates/hoon/Cargo.toml index 027b57a70..bb5dc62d6 100644 --- a/crates/hoon/Cargo.toml +++ b/crates/hoon/Cargo.toml @@ -2,14 +2,15 @@ name = "hoon" version.workspace = true edition.workspace = true +license = "MIT OR Apache-2.0" [dependencies] + +clap.workspace = true hoonc.workspace = true nockapp.workspace = true nockvm.workspace = true -zkvm-jetpack.workspace = true - -clap.workspace = true tempfile.workspace = true tokio.workspace = true tracing.workspace = true +zkvm-jetpack.workspace = true diff --git a/crates/hoon/src/lib.rs b/crates/hoon/src/lib.rs index 8d2bf697f..d5d0c2eac 100644 --- a/crates/hoon/src/lib.rs +++ b/crates/hoon/src/lib.rs @@ -1,14 +1,12 @@ // Execute nock scripts -use std::fs::File; - -use clap::{arg, command, Parser}; +use clap::Parser; use hoonc::kick_and_save_generator; use nockapp::utils::NOCK_STACK_SIZE; use nockvm::interpreter::Context; use nockvm::jets::cold::Cold; use nockvm::jets::hot::{HotEntry, URBIT_HOT_STATE}; use nockvm::mem::NockStack; -use nockvm::trace::{JsonBackend, TraceInfo}; +use nockvm::trace::TraceInfo; /// Command line arguments #[derive(Parser, Debug, Clone)] @@ -41,35 +39,7 @@ pub async fn run(cli: HoonCli, hot_state: &[HotEntry]) -> Result<(), Box { - let file = File::create("trace.json").expect("Cannot create trace file trace.json"); - let pid = std::process::id(); - let process_start = std::time::Instant::now(); - Some( - JsonBackend { - file, - pid, - process_start, - } - .into(), - ) - } - _ => None, - } - // let file = File::create("trace.json").expect("Cannot create trace file trace.json"); - // let pid = std::process::id(); - // let process_start = std::time::Instant::now(); - // let json_backend = JsonBackend { - // file, - // pid, - // process_start, - // }; - // Some(json_backend.into()) - } else { - None - }; + let trace_info: Option = cli.boot.trace_opts.into(); let mut context: Context = init_context(Some(hot_state), trace_info); kick_and_save_generator(&mut context, &cli.nock_script, cli.dep_dir, cli.out_dir).await diff --git a/crates/hoonc/Cargo.toml b/crates/hoonc/Cargo.toml index 4ddaf3a0f..23490d573 100644 --- a/crates/hoonc/Cargo.toml +++ b/crates/hoonc/Cargo.toml @@ -2,15 +2,18 @@ name = "hoonc" version = "0.2.0" edition.workspace = true +license = "MIT OR Apache-2.0" + +[[bin]] +name = "hoonc" +path = "src/main.rs" [features] default = [] tracing-tracy = ["dep:tracing-tracy"] +no_check_oom = ["nockvm/no_check_oom"] [dependencies] -nockapp = { workspace = true } -nockvm = { workspace = true, features = ["no_check_oom"] } -nockvm_macros = { workspace = true } bincode = { workspace = true } blake3 = { workspace = true } @@ -18,6 +21,9 @@ bytes = { workspace = true } clap = { workspace = true, features = ["derive", "cargo", "color", "env"] } dirs = { workspace = true } futures = { workspace = true } +nockapp = { workspace = true } +nockvm = { workspace = true } +nockvm_macros = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["sync"] } @@ -29,7 +35,3 @@ tracing-tracy = { workspace = true, optional = true, features = [ "ondemand", ] } walkdir = "2.5.0" - -[[bin]] -name = "hoonc" -path = "src/main.rs" diff --git a/crates/hoonc/src/lib.rs b/crates/hoonc/src/lib.rs index bafa739b7..defbcdbc0 100644 --- a/crates/hoonc/src/lib.rs +++ b/crates/hoonc/src/lib.rs @@ -3,7 +3,7 @@ use std::ffi::OsStr; use std::io::Write; use std::path::PathBuf; -use clap::{arg, command, ColorChoice, Parser}; +use clap::{ColorChoice, Parser}; use nockapp::driver::Operation; use nockapp::kernel::boot::{self, default_boot_cli, Cli as BootCli}; use nockapp::noun::slab::{Jammer, NockJammer, NounSlab}; @@ -244,12 +244,13 @@ async fn initialize_hoonc_inner( && boot_cli.state_jam.is_none() && (boot_cli.new || !has_existing_checkpoint); - let mut prewarm_state_file: Option = None; + // Keep the prewarm tempfile alive for the duration of this function when used. + let mut _prewarm_state_file: Option = None; if should_use_prewarm { let mut tmp = NamedTempFile::new()?; tmp.write_all(PREWARM_STATE_JAM)?; boot_cli.state_jam = Some(tmp.path().to_string_lossy().into_owned()); - prewarm_state_file = Some(tmp); + _prewarm_state_file = Some(tmp); } let mut nockapp = boot::setup::(KERNEL_JAM, boot_cli.clone(), &[], "hoonc", Some(data_dir)).await?; diff --git a/crates/kernels/Cargo.toml b/crates/kernels/Cargo.toml index c1c7f78ea..272fe3398 100644 --- a/crates/kernels/Cargo.toml +++ b/crates/kernels/Cargo.toml @@ -2,15 +2,17 @@ name = "kernels" version.workspace = true edition.workspace = true - -[dependencies] +license = "MIT OR Apache-2.0" [features] default = [] bazel_build = [] # Specific kernels +bridge = [] dumb = [] wallet = [] miner = [] nockchain_peek = [] + +[dependencies] diff --git a/crates/kernels/src/bridge.rs b/crates/kernels/src/bridge.rs new file mode 100644 index 000000000..d1b96e568 --- /dev/null +++ b/crates/kernels/src/bridge.rs @@ -0,0 +1,8 @@ +#[cfg(feature = "bazel_build")] +pub static KERNEL: &[u8] = include_bytes!(env!("BRIDGE_JAM_PATH")); + +#[cfg(not(feature = "bazel_build"))] +pub static KERNEL: &[u8] = include_bytes!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../assets/bridge.jam" +)); diff --git a/crates/kernels/src/lib.rs b/crates/kernels/src/lib.rs index ad7009d26..812d86583 100644 --- a/crates/kernels/src/lib.rs +++ b/crates/kernels/src/lib.rs @@ -1,3 +1,6 @@ +#[cfg(feature = "bridge")] +pub mod bridge; + #[cfg(feature = "wallet")] pub mod wallet; diff --git a/crates/nockapp-grpc-proto/Cargo.toml b/crates/nockapp-grpc-proto/Cargo.toml index 4a8535dff..dfddcc9e8 100644 --- a/crates/nockapp-grpc-proto/Cargo.toml +++ b/crates/nockapp-grpc-proto/Cargo.toml @@ -2,6 +2,7 @@ name = "nockapp-grpc-proto" version.workspace = true edition.workspace = true +license = "MIT OR Apache-2.0" [features] default = ["server"] @@ -14,12 +15,9 @@ nockchain-math = { workspace = true } nockchain-types = { workspace = true } prost = { workspace = true } -prost-build = { workspace = true } prost-types = { workspace = true } thiserror = { workspace = true } tonic = { workspace = true } -tonic-build = { workspace = true } -tonic-health = { workspace = true } tonic-prost = { workspace = true } [build-dependencies] diff --git a/crates/nockapp-grpc-proto/build.rs b/crates/nockapp-grpc-proto/build.rs index ebbcdfd7a..0826cdda6 100644 --- a/crates/nockapp-grpc-proto/build.rs +++ b/crates/nockapp-grpc-proto/build.rs @@ -1,3 +1,4 @@ +#![allow(dead_code)] use std::env; use std::path::PathBuf; diff --git a/crates/nockapp-grpc-proto/src/common/mod.rs b/crates/nockapp-grpc-proto/src/common/mod.rs index b230fd39f..ef3336a90 100644 --- a/crates/nockapp-grpc-proto/src/common/mod.rs +++ b/crates/nockapp-grpc-proto/src/common/mod.rs @@ -10,7 +10,7 @@ pub trait Required { impl Required for Option { fn required(self, kind: &'static str, field: &'static str) -> Result { - self.ok_or_else(|| ConversionError::MissingField(kind, field)) + self.ok_or(ConversionError::MissingField(kind, field)) } } diff --git a/crates/nockapp-grpc-proto/src/lib.rs b/crates/nockapp-grpc-proto/src/lib.rs index 8e20b0637..fb1dc46b0 100644 --- a/crates/nockapp-grpc-proto/src/lib.rs +++ b/crates/nockapp-grpc-proto/src/lib.rs @@ -3,6 +3,10 @@ //! This crate provides a gRPC interface to NockApp, replacing the old socket-based //! interface with modern RPC patterns for easier cross-language compatibility. +// Allow clippy lints for generated protobuf code +#![allow(clippy::large_enum_variant)] +#![allow(clippy::unnecessary_fallible_conversions)] + // Include the generated protobuf code pub mod pb { pub mod common { diff --git a/crates/nockapp-grpc/Cargo.toml b/crates/nockapp-grpc/Cargo.toml index 71cd640b6..de34a0d39 100644 --- a/crates/nockapp-grpc/Cargo.toml +++ b/crates/nockapp-grpc/Cargo.toml @@ -2,6 +2,7 @@ name = "nockapp-grpc" version.workspace = true edition.workspace = true +license = "MIT OR Apache-2.0" [features] default = ["server"] @@ -9,21 +10,25 @@ server = [] client = [] [dependencies] -nockapp = { workspace = true } -nockapp-grpc-proto = { workspace = true } -nockchain-types = {workspace = true} -nockchain-math = { workspace = true } -nockvm = { workspace = true } -nockvm_macros = { workspace = true } -noun-serde = { workspace = true } anyhow = { workspace = true } +async-trait = { workspace = true } +bincode = { workspace = true } bytes = { workspace = true } dashmap = { workspace = true } +futures = { workspace = true } gnort = { workspace = true } +hex = { workspace = true } ibig = { workspace = true } +nockapp = { workspace = true } +nockapp-grpc-proto = { workspace = true } +nockchain-math = { workspace = true } +nockchain-types = { workspace = true } +nockvm = { workspace = true } +nockvm_macros = { workspace = true } +noun-serde = { workspace = true } +once_cell = { workspace = true } prost = { workspace = true } -prost-build = { workspace = true } prost-types = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } @@ -31,17 +36,10 @@ tokio = { workspace = true, features = ["macros", "rt-multi-thread", "net"] } tokio-stream = { workspace = true } tonic = { workspace = true } tonic-health = { workspace = true } -tonic-build = { workspace = true } tonic-prost = { workspace = true } -tonic-prost-build = { workspace = true } tonic-reflection = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter", "ansi"] } -bincode = { workspace = true } -hex = { workspace = true } -futures = { workspace = true } -async-trait = { workspace = true } -once_cell = { workspace = true } [build-dependencies] prost-build = { workspace = true } @@ -49,5 +47,4 @@ tonic-build = { workspace = true } tonic-prost-build = { workspace = true } [dev-dependencies] -tokio-test = "0.4" tempfile = { workspace = true } diff --git a/crates/nockapp-grpc/src/lib.rs b/crates/nockapp-grpc/src/lib.rs index 2857ffd3a..0e3e23c1e 100644 --- a/crates/nockapp-grpc/src/lib.rs +++ b/crates/nockapp-grpc/src/lib.rs @@ -3,6 +3,24 @@ //! This crate provides a gRPC interface to NockApp, replacing the old socket-based //! interface with modern RPC patterns for easier cross-language compatibility. +// Allow clippy lints for architectural patterns in gRPC services +#![allow(clippy::needless_borrow)] +#![allow(clippy::needless_borrows_for_generic_args)] +#![allow(clippy::clone_on_copy)] +#![allow(clippy::unnecessary_cast)] +#![allow(clippy::io_other_error)] +#![allow(clippy::redundant_guards)] +#![allow(clippy::single_match)] +#![allow(clippy::useless_conversion)] +#![allow(clippy::type_complexity)] +#![allow(clippy::while_let_loop)] +#![allow(clippy::option_map_or_none)] +#![allow(clippy::module_inception)] +#![allow(clippy::result_large_err)] +#![allow(clippy::bind_instead_of_map)] +// Allow unwrap in test code +#![cfg_attr(test, allow(clippy::unwrap_used))] + // Include the generated protobuf code pub mod error; diff --git a/crates/nockapp-grpc/src/services/public_nockchain/v2/block_explorer.rs b/crates/nockapp-grpc/src/services/public_nockchain/v2/block_explorer.rs index 44180a199..ba92c08d2 100644 --- a/crates/nockapp-grpc/src/services/public_nockchain/v2/block_explorer.rs +++ b/crates/nockapp-grpc/src/services/public_nockchain/v2/block_explorer.rs @@ -733,11 +733,11 @@ impl BlockExplorerCache { } let index = self.tx_b58_index.read().await; - let mut iter = index.range(prefix.to_string()..); + let iter = index.range(prefix.to_string()..); let mut first_match: Option<(String, Hash)> = None; let mut conflicts: Vec = Vec::new(); - while let Some((key, hash)) = iter.next() { + for (key, hash) in iter { if !key.starts_with(prefix) { break; } @@ -1023,6 +1023,7 @@ impl BlockExplorerCache { /// Peek /heaviest-chain-blocks-range/[start]/[end] ~ /// Returns: Vec<(height, block_id, parent_id, timestamp, tx_ids)> + #[allow(dead_code)] #[tracing::instrument( name = "block_explorer_cache.peek_blocks_range_chunked", skip(self, handle) diff --git a/crates/nockapp-grpc/src/services/public_nockchain/v2/server.rs b/crates/nockapp-grpc/src/services/public_nockchain/v2/server.rs index 1b55e9cbf..3cec24589 100644 --- a/crates/nockapp-grpc/src/services/public_nockchain/v2/server.rs +++ b/crates/nockapp-grpc/src/services/public_nockchain/v2/server.rs @@ -136,7 +136,7 @@ impl PublicNockchainGrpcServer { pub async fn serve(self, addr: SocketAddr) -> Result<()> { tracing::Span::current().record("addr", &tracing::field::display(addr)); info!("Starting PublicNockchain gRPC server on {}", addr); - let (mut health_reporter, health_service) = tonic_health::server::health_reporter(); + let (health_reporter, health_service) = tonic_health::server::health_reporter(); health_reporter .set_serving::>() .await; @@ -248,10 +248,7 @@ impl PublicNockchainGrpcServer { }); } - fn start_block_explorer_refresh( - &self, - mut health_reporter: tonic_health::server::HealthReporter, - ) { + fn start_block_explorer_refresh(&self, health_reporter: tonic_health::server::HealthReporter) { let server = self.clone(); tokio::spawn(async move { health_reporter @@ -263,7 +260,7 @@ impl PublicNockchainGrpcServer { let exit = server.exit.clone(); // Helper to handle fatal decode errors - let handle_fatal_error = |err: &NockAppGrpcError, exit: &Option<_>, context: &str| { + let handle_fatal_error = |err: &NockAppGrpcError, _exit: &Option<_>, context: &str| { if matches!(err, NockAppGrpcError::NounDecode(_)) { error!( "Fatal decode error during {}: {}. Signaling server shutdown.", @@ -428,6 +425,7 @@ pub struct NockchainBlockServer { #[derive(Clone)] pub struct NockchainMetricsServer { + #[allow(dead_code)] handle: Arc, block_explorer_cache: Arc, metrics: Arc, @@ -622,7 +620,7 @@ impl NockchainService for PublicNockchainGrpcServer { ); }; - match selector.unwrap() { + match selector.expect("selector should be set") { Selector::Address(address) => { let cursor: Option = if !token.is_empty() { match decode_cursor_address(&token) { diff --git a/crates/nockapp/Cargo.toml b/crates/nockapp/Cargo.toml index 17fa4649d..b66b47172 100644 --- a/crates/nockapp/Cargo.toml +++ b/crates/nockapp/Cargo.toml @@ -2,6 +2,11 @@ name = "nockapp" version.workspace = true edition.workspace = true +license = "MIT OR Apache-2.0" + +[lib] +name = "nockapp" +path = "src/lib.rs" [features] default = ["slog-tracing", "tracing-tracy"] @@ -11,15 +16,13 @@ bazel_build = [] tracing-tracy = ["dep:tracing-tracy"] [dependencies] -nockvm = { workspace = true } -nockvm_macros = { workspace = true } anyhow = { workspace = true } async-trait = { workspace = true } axum = { workspace = true } axum-server = { workspace = true } bincode = { workspace = true, features = ["serde"] } -bitvec = { workspace = true, default-features = false, features = ["alloc"] } +bitvec = { version = "1.0.1", default-features = false, features = ["alloc"] } blake3 = { workspace = true } byteorder = { workspace = true } bytes = { workspace = true, features = ["serde"] } @@ -34,13 +37,16 @@ gnort = { workspace = true } ibig = { workspace = true } instant-acme = { workspace = true } intmap = { workspace = true } +nockvm = { workspace = true } +nockvm_macros = { workspace = true } +noun-serde = { workspace = true } opentelemetry.workspace = true opentelemetry-otlp = { workspace = true, features = ["grpc-tonic"] } opentelemetry_sdk.workspace = true rand = { workspace = true } rcgen = { workspace = true } rustls = { workspace = true } -rustls-pemfile = { workspace = true } +rustls-pki-types = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } signal-hook = { workspace = true } @@ -65,8 +71,3 @@ tracing-tracy = { workspace = true, optional = true, features = [ webpki-roots = { workspace = true } x509-parser = { workspace = true } yaque = { workspace = true } -noun-serde = { workspace = true } - -[lib] -name = "nockapp" -path = "src/lib.rs" diff --git a/crates/nockapp/src/drivers/http/acme.rs b/crates/nockapp/src/drivers/http/acme.rs index 47d59793a..ff94cc0a3 100644 --- a/crates/nockapp/src/drivers/http/acme.rs +++ b/crates/nockapp/src/drivers/http/acme.rs @@ -8,6 +8,7 @@ use instant_acme::{ Account, AccountCredentials, AuthorizationStatus, ChallengeType, Identifier, LetsEncrypt, NewAccount, NewOrder, Order, OrderStatus, }; +use rustls::pki_types::pem::PemObject; use rustls::pki_types::{CertificateDer, PrivateKeyDer}; use rustls::ServerConfig; use serde_json; @@ -93,10 +94,10 @@ impl AcmeManager { let key_pem = fs::read_to_string(key_path).await?; let cert_chain: Vec = - rustls_pemfile::certs(&mut cert_pem.as_bytes()).collect::, _>>()?; + CertificateDer::pem_reader_iter(&mut cert_pem.as_bytes()) + .collect::, _>>()?; - let private_key: PrivateKeyDer = rustls_pemfile::private_key(&mut key_pem.as_bytes())? - .ok_or_else(|| anyhow::anyhow!("No private key found"))?; + let private_key: PrivateKeyDer = PrivateKeyDer::from_pem_reader(&mut key_pem.as_bytes())?; let config = ServerConfig::builder() .with_no_client_auth() @@ -107,8 +108,8 @@ impl AcmeManager { async fn certificate_is_valid(&self, cert_path: &Path) -> Result { let cert_pem = fs::read_to_string(cert_path).await?; - let certs: Vec = - rustls_pemfile::certs(&mut cert_pem.as_bytes()).collect::, _>>()?; + let certs: Vec = CertificateDer::pem_reader_iter(&mut cert_pem.as_bytes()) + .collect::, _>>()?; if let Some(cert_der) = certs.first() { let cert = x509_parser::parse_x509_certificate(cert_der.as_ref())?; @@ -245,10 +246,10 @@ impl AcmeManager { // Build rustls config let cert_chain: Vec = - rustls_pemfile::certs(&mut cert_chain_pem.as_bytes()).collect::, _>>()?; + CertificateDer::pem_reader_iter(&mut cert_chain_pem.as_bytes()) + .collect::, _>>()?; - let private_key: PrivateKeyDer = rustls_pemfile::private_key(&mut key_pem.as_bytes())? - .ok_or_else(|| anyhow::anyhow!("No private key found"))?; + let private_key: PrivateKeyDer = PrivateKeyDer::from_pem_reader(&mut key_pem.as_bytes())?; let config = ServerConfig::builder() .with_no_client_auth() diff --git a/crates/nockapp/src/drivers/http/http.rs b/crates/nockapp/src/drivers/http/http.rs index 2a3c96489..da279f032 100644 --- a/crates/nockapp/src/drivers/http/http.rs +++ b/crates/nockapp/src/drivers/http/http.rs @@ -133,6 +133,7 @@ impl CachedResponse { self.timestamp.elapsed() > max_age } + #[allow(clippy::result_large_err)] fn to_response(&self) -> Result, HttpError> { let mut res = Response::builder().status(self.status); for (k, v) in &self.headers { @@ -300,7 +301,8 @@ pub fn http() -> IODriverFn { tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; // Start certificate generation in background - don't block main loop - let acme_manager = acme_manager_opt.unwrap(); + let acme_manager = + acme_manager_opt.expect("acme_manager should be set when https is enabled"); let app_for_https = app.clone(); tokio::spawn(async move { match tokio::time::timeout( @@ -315,15 +317,24 @@ pub fn http() -> IODriverFn { match tokio::net::TcpListener::bind("0.0.0.0:443").await { Ok(https_listener) => { - let https_addr = https_listener.local_addr().unwrap(); + let https_addr = https_listener + .local_addr() + .expect("listener should have local addr"); info!("HTTPS server listening on {}", https_addr); - let std_listener = https_listener.into_std().unwrap(); - if let Err(e) = - axum_server::from_tcp_rustls(std_listener, rustls_config) - .serve(app_for_https.into_make_service()) - .await - { - error!("HTTPS server error: {}", e); + let std_listener = https_listener + .into_std() + .expect("listener should convert to std"); + match axum_server::from_tcp_rustls(std_listener, rustls_config) { + Ok(server) => { + if let Err(e) = + server.serve(app_for_https.into_make_service()).await + { + error!("HTTPS server error: {}", e); + } + } + Err(e) => { + error!("Failed to create HTTPS server: {}", e); + } } } Err(e) => { @@ -778,5 +789,5 @@ async fn favicon_handler() -> Response { .header("content-type", "image/svg+xml") .header("cache-control", "public, max-age=86400") .body(Body::from(svg)) - .unwrap() + .expect("static response should build successfully") } diff --git a/crates/nockapp/src/drivers/http/mod.rs b/crates/nockapp/src/drivers/http/mod.rs index d792e9559..6fa71caaf 100644 --- a/crates/nockapp/src/drivers/http/mod.rs +++ b/crates/nockapp/src/drivers/http/mod.rs @@ -1,4 +1,5 @@ pub mod acme; +#[allow(clippy::module_inception)] pub mod http; pub use acme::AcmeManager; diff --git a/crates/nockapp/src/kernel/boot.rs b/crates/nockapp/src/kernel/boot.rs index 95cebd0cd..85dc45c14 100644 --- a/crates/nockapp/src/kernel/boot.rs +++ b/crates/nockapp/src/kernel/boot.rs @@ -1,13 +1,11 @@ +#![allow(clippy::items_after_test_module)] use std::path::PathBuf; use chrono; -use clap::{arg, command, Args, ColorChoice, Parser, ValueEnum}; +use clap::{Args, ColorChoice, Parser, ValueEnum}; use nockvm::jets::hot::HotEntry; use nockvm::noun::Atom; -use nockvm::trace::{ - IntervalFilter, JsonBackend, KeywordFilter, TraceBackend, TraceFilter, TraceInfo, - TracingBackend, -}; +use nockvm::trace::{IntervalFilter, KeywordFilter, TraceFilter, TraceInfo, TracingBackend}; use tokio::fs; use tracing::{debug, info, Level, Subscriber}; use tracing_subscriber::fmt::format::Writer; @@ -15,7 +13,9 @@ use tracing_subscriber::fmt::{FmtContext, FormatEvent, FormatFields}; use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::registry::LookupSpan; use tracing_subscriber::util::SubscriberInitExt; -use tracing_subscriber::{fmt, EnvFilter, Layer}; +#[cfg(feature = "tracing-tracy")] +use tracing_subscriber::Layer as _; +use tracing_subscriber::{fmt, EnvFilter}; use crate::export::ExportedState; use crate::kernel::form::Kernel; @@ -40,16 +40,14 @@ pub enum NockStackSize { #[derive(Clone, Copy, Debug, ValueEnum)] pub enum TraceMode { - Json, Tracing, } /// Trace options for NockApp #[derive(Args, Clone, Debug, Default)] pub struct TraceOpts { - /// You don't really need this, but it is here in case a new tracing backend is added or you want to use JSON tracing. - /// We strongly recommend using Tracy - #[arg(long = "trace", help = "Make a Sword trace in json or tracing mode")] + /// Enable nock interpreter tracing (integrates with Tracy profiler) + #[arg(long = "trace", help = "Enable nock interpreter tracing")] pub mode: Option, #[arg(long, requires = "mode")] @@ -76,24 +74,10 @@ impl From for Option { (None, None) => None, }; - trace_opts - .mode - .map(|mode| match mode { - TraceMode::Json => { - let file = std::fs::File::create("trace.json") - .expect("Cannot create trace file trace.json"); - let pid = std::process::id(); - let process_start = std::time::Instant::now(); - - Box::new(JsonBackend { - file, - pid, - process_start, - }) as Box - } - TraceMode::Tracing => Box::new(TracingBackend::new()), - }) - .map(|backend| TraceInfo { backend, filter }) + trace_opts.mode.map(|_mode| TraceInfo { + backend: Box::new(TracingBackend::new()), + filter, + }) } } @@ -168,16 +152,19 @@ mod tests { #[test] fn parse_save_interval_none_variants() { - assert_eq!(parse_save_interval("none").unwrap(), 0); - assert_eq!(parse_save_interval("NoNe").unwrap(), 0); - assert_eq!(parse_save_interval("0").unwrap(), 0); - assert_eq!(parse_save_interval(" 0 ").unwrap(), 0); + assert_eq!(parse_save_interval("none").expect("should parse"), 0); + assert_eq!(parse_save_interval("NoNe").expect("should parse"), 0); + assert_eq!(parse_save_interval("0").expect("should parse"), 0); + assert_eq!(parse_save_interval(" 0 ").expect("should parse"), 0); } #[test] fn parse_save_interval_positive_values() { - assert_eq!(parse_save_interval("1").unwrap(), 1); - assert_eq!(parse_save_interval(" 120000 ").unwrap(), 120000); + assert_eq!(parse_save_interval("1").expect("should parse"), 1); + assert_eq!( + parse_save_interval(" 120000 ").expect("should parse"), + 120000 + ); } #[test] @@ -197,6 +184,7 @@ mod tests { } /// Result of setting up a NockApp +#[allow(clippy::large_enum_variant)] pub enum SetupResult { /// A fully initialized NockApp App(NockApp), @@ -517,7 +505,8 @@ pub fn parse_test_jets(jets: &str) -> Vec { .as_noun(); let ver_atom = Atom::from_value( &mut slab, - u64::from_str_radix(ver_split[1], 10) + ver_split[1] + .parse::() .expect("Could not parse cold path version"), ) .expect("Could not construct version atom") diff --git a/crates/nockapp/src/kernel/form.rs b/crates/nockapp/src/kernel/form.rs index 61a11518b..5ff3227ef 100644 --- a/crates/nockapp/src/kernel/form.rs +++ b/crates/nockapp/src/kernel/form.rs @@ -1,4 +1,5 @@ #![allow(dead_code)] +#![allow(clippy::items_after_test_module)] use std::any::Any; use std::future::Future; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; diff --git a/crates/nockapp/src/lib.rs b/crates/nockapp/src/lib.rs index 0684bbb22..925a40b9a 100644 --- a/crates/nockapp/src/lib.rs +++ b/crates/nockapp/src/lib.rs @@ -1,4 +1,6 @@ #![feature(slice_pattern)] +// Allow unwrap in test code - standard practice for test assertions +#![cfg_attr(test, allow(clippy::unwrap_used))] //! # Crown //! diff --git a/crates/nockapp/src/nockapp/error.rs b/crates/nockapp/src/nockapp/error.rs index 8407c5f28..1b38c0b34 100644 --- a/crates/nockapp/src/nockapp/error.rs +++ b/crates/nockapp/src/nockapp/error.rs @@ -1,6 +1,5 @@ use thiserror::Error; use tokio::sync::mpsc::error::{SendError, TrySendError}; -use tracing::error; use super::driver::IOAction; use crate::nockapp::save::CheckpointError; diff --git a/crates/nockapp/src/nockapp/mod.rs b/crates/nockapp/src/nockapp/mod.rs index 440c8ebf9..46514d5e1 100644 --- a/crates/nockapp/src/nockapp/mod.rs +++ b/crates/nockapp/src/nockapp/mod.rs @@ -188,7 +188,7 @@ impl NockApp { .await .expect("Failed to provide metrics to kernel"); - let signals = Signals::new(&[TERM_SIGNALS, &[SIGHUP]].concat()) + let signals = Signals::new([TERM_SIGNALS, &[SIGHUP]].concat()) .expect("Failed to create signal handler"); let (exit, exit_recv) = NockAppExit::new(); @@ -317,9 +317,7 @@ impl NockApp { let guard = self.save_mutex.clone().lock_owned().await; trace!("save_blocking: save_mutex locked"); let join_handle = self.save_f(async {}, guard).await?; - join_handle - .await - .map_err(|e| NockAppError::JoinError(e))??; + join_handle.await.map_err(NockAppError::JoinError)??; Ok(()) } @@ -463,10 +461,8 @@ impl NockApp { if let Err(e) = exit.done(Err(NockAppError::from(e))).await { error!("Error completing shutdown: {e}"); } - } else { - if let Err(e) = exit.done(res).await { - error!("Error completing shutdown: {e}"); - } + } else if let Err(e) = exit.done(res).await { + error!("Error completing shutdown: {e}"); } }); Ok(NockAppRun::Pending) @@ -510,7 +506,9 @@ impl NockApp { break Ok(NockAppRun::Pending); } } else { - std::process::exit(code.try_into().unwrap()); + std::process::exit( + code.try_into().expect("exit code should fit in i32"), + ); } } } else { @@ -589,7 +587,7 @@ impl NockApp { if let Some(timeout) = timeout { let poke_future = self.kernel.poke_timeout(wire, cause, timeout); let effect_broadcast = self.effect_broadcast.clone(); - let _ = self.tasks.spawn(async move { + drop(self.tasks.spawn(async move { let poke_result = poke_future.await; match poke_result { Ok(effects) => { @@ -602,11 +600,11 @@ impl NockApp { let _ = ack_channel.send(PokeResult::Nack); } } - }); + })); } else { let poke_future = self.kernel.poke(wire, cause); let effect_broadcast = self.effect_broadcast.clone(); - let _ = self.tasks.spawn(async move { + drop(self.tasks.spawn(async move { let poke_result = poke_future.await; match poke_result { Ok(effects) => { @@ -619,7 +617,7 @@ impl NockApp { let _ = ack_channel.send(PokeResult::Nack); } } - }); + })); } } @@ -630,7 +628,7 @@ impl NockApp { result_channel: tokio::sync::oneshot::Sender>, ) { let peek_future = self.kernel.peek(path); - let _ = self.tasks.spawn(async move { + drop(self.tasks.spawn(async move { let peek_res = peek_future.await; match peek_res { @@ -642,7 +640,7 @@ impl NockApp { let _ = result_channel.send(None); } } - }); + })); } // TODO: We should explicitly kick off a save somehow diff --git a/crates/nockapp/src/nockapp/save.rs b/crates/nockapp/src/nockapp/save.rs index 101b0c834..4cc73c2cd 100644 --- a/crates/nockapp/src/nockapp/save.rs +++ b/crates/nockapp/src/nockapp/save.rs @@ -1,5 +1,5 @@ use std::future::Future; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::Instant; @@ -66,8 +66,8 @@ impl Saver { /// around the 'Saver' is released, or a deadlock will result. #[tracing::instrument(skip(self))] #[allow(clippy::async_yields_async)] - pub async fn wait_for_snapshot<'a>( - &'a mut self, + pub async fn wait_for_snapshot( + &mut self, wait_for_event_num: u64, ) -> impl Future> { if self.last_event_num >= wait_for_event_num { @@ -159,7 +159,7 @@ impl Saver { } }; let last_event_num = loaded_checkpoint.event_num(); - let saveable = loaded_checkpoint.into_saveable::(metrics.clone())?; + let saveable = loaded_checkpoint.into_saveable(metrics.clone())?; trace!("After from_jammed_checkpoint"); let c = C::from_saveable(saveable)?; Ok(( @@ -191,10 +191,7 @@ impl Saver { jammed.save_to_file(&path).await?; self.save_to_next = self.save_to_next.next(); std::mem::drop(jammed); - debug!( - "Saved checkpoint to file: {}", - &path.as_os_str().to_str().unwrap() - ); + debug!("Saved checkpoint to file: {}", path.display()); let mut still_waiting = Vec::new(); for (waiting_event_num, waiter) in self.waiters.drain(..) { if waiting_event_num <= event_num { @@ -228,6 +225,7 @@ pub struct SaveableCheckpoint { } impl SaveableCheckpoint { + #[allow(clippy::wrong_self_convention)] #[tracing::instrument(skip(self, metrics))] fn to_jammed_checkpoint(self, metrics: Arc) -> JammedCheckpointV2 { let SaveableCheckpoint { @@ -245,7 +243,7 @@ impl SaveableCheckpoint { JammedCheckpointV2::new(ker_hash, event_num, cold_jam, state_jam) } - fn from_jammed_checkpoint_v1<'a, J: Jammer>( + fn from_jammed_checkpoint_v1( jammed: JammedCheckpointV1, metrics: Option>, ) -> Result { @@ -274,7 +272,7 @@ impl SaveableCheckpoint { }) } - fn from_jammed_checkpoint_v2<'a, J: Jammer>( + fn from_jammed_checkpoint_v2( jammed: JammedCheckpointV2, metrics: Option>, ) -> Result { @@ -391,11 +389,11 @@ impl JammedCheckpointV1 { } } - pub fn validate(&self, path: &PathBuf) -> Result<(), CheckpointError> { + pub fn validate(&self, path: &Path) -> Result<(), CheckpointError> { if self.version != SNAPSHOT_VERSION_1 { - Err(CheckpointError::InvalidVersion(path.clone())) + Err(CheckpointError::InvalidVersion(path.to_path_buf())) } else if self.checksum != Self::checksum(self.event_num, &self.jam.0) { - Err(CheckpointError::InvalidChecksum(path.clone())) + Err(CheckpointError::InvalidChecksum(path.to_path_buf())) } else { Ok(()) } @@ -417,11 +415,8 @@ impl JammedCheckpointV1 { } #[tracing::instrument(skip_all)] - async fn load_from_file(path: &PathBuf) -> Result { - debug!( - "Loading jammed checkpoint from file: {}", - path.as_os_str().to_str().unwrap() - ); + async fn load_from_file(path: &Path) -> Result { + debug!("Loading jammed checkpoint from file: {}", path.display()); let bytes = tokio::fs::read(path).await?; let config = bincode::config::standard(); let (checkpoint, _) = bincode::decode_from_slice::(&bytes, config)?; @@ -431,7 +426,7 @@ impl JammedCheckpointV1 { #[allow(dead_code)] #[tracing::instrument(skip(self))] - async fn save_to_file(&self, path: &PathBuf) -> Result<(), CheckpointError> { + async fn save_to_file(&self, path: &Path) -> Result<(), CheckpointError> { let bytes = self.encode()?; trace!("Saving jammed checkpoint to file: {}", path.display()); tokio::fs::write(path, bytes).await?; @@ -478,9 +473,9 @@ impl JammedCheckpointV2 { } } - pub fn validate(&self, path: &PathBuf) -> Result<(), CheckpointError> { + pub fn validate(&self, path: &Path) -> Result<(), CheckpointError> { if self.checksum != Self::checksum(self.event_num, &self.cold_jam.0, &self.state_jam.0) { - Err(CheckpointError::InvalidChecksum(path.clone())) + Err(CheckpointError::InvalidChecksum(path.to_path_buf())) } else { Ok(()) } @@ -511,11 +506,8 @@ impl JammedCheckpointV2 { } #[tracing::instrument(skip_all)] - async fn load_from_file(path: &PathBuf) -> Result { - debug!( - "Loading jammed checkpoint from file: {}", - path.as_os_str().to_str().unwrap() - ); + async fn load_from_file(path: &Path) -> Result { + debug!("Loading jammed checkpoint from file: {}", path.display()); let bytes = tokio::fs::read(path).await?; let config = bincode::config::standard(); let (envelope, _) = bincode::decode_from_slice::( @@ -527,7 +519,7 @@ impl JammedCheckpointV2 { } #[tracing::instrument(skip(self))] - async fn save_to_file(&self, path: &PathBuf) -> Result<(), CheckpointError> { + async fn save_to_file(&self, path: &Path) -> Result<(), CheckpointError> { let bytes = self.encode()?; trace!("Saving jammed checkpoint to file: {}", path.display()); tokio::fs::write(path, bytes).await?; @@ -536,7 +528,7 @@ impl JammedCheckpointV2 { fn from_envelope( envelope: JammedCheckpointV2Envelope, - path: Option<&PathBuf>, + path: Option<&Path>, ) -> Result { if envelope.magic_bytes != JAM_MAGIC_BYTES { return Err(CheckpointError::InvalidVersion(path_or_memory(path))); @@ -563,8 +555,9 @@ impl JammedCheckpointV2 { } } -fn path_or_memory(path: Option<&PathBuf>) -> PathBuf { - path.cloned().unwrap_or_else(|| PathBuf::from("")) +fn path_or_memory(path: Option<&Path>) -> PathBuf { + path.map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from("")) } #[derive(Clone, Debug)] @@ -588,22 +581,18 @@ impl LoadedCheckpoint { } } - fn into_saveable( + fn into_saveable( self, metrics: Option>, ) -> Result { match self { - LoadedCheckpoint::V2(cp) => { - SaveableCheckpoint::from_jammed_checkpoint_v2::(cp, metrics) - } - LoadedCheckpoint::V1(cp) => { - SaveableCheckpoint::from_jammed_checkpoint_v1::(cp, metrics) - } + LoadedCheckpoint::V2(cp) => SaveableCheckpoint::from_jammed_checkpoint_v2(cp, metrics), + LoadedCheckpoint::V1(cp) => SaveableCheckpoint::from_jammed_checkpoint_v1(cp, metrics), } } } -async fn load_checkpoint_file(path: &PathBuf) -> Result { +async fn load_checkpoint_file(path: &Path) -> Result { match JammedCheckpointV2::load_from_file(path).await { Ok(cp) => Ok(LoadedCheckpoint::V2(cp)), Err(e_v2) => match JammedCheckpointV1::load_from_file(path).await { @@ -687,11 +676,11 @@ impl JammedCheckpointV0 { } } - pub fn validate(&self, path: &PathBuf) -> Result<(), CheckpointError> { + pub fn validate(&self, path: &Path) -> Result<(), CheckpointError> { if self.version != SNAPSHOT_VERSION_0 { - Err(CheckpointError::InvalidVersion(path.clone())) + Err(CheckpointError::InvalidVersion(path.to_path_buf())) } else if self.checksum != Self::checksum(self.event_num, &self.jam.0) { - Err(CheckpointError::InvalidChecksum(path.clone())) + Err(CheckpointError::InvalidChecksum(path.to_path_buf())) } else { Ok(()) } @@ -713,11 +702,8 @@ impl JammedCheckpointV0 { } #[tracing::instrument(skip_all)] - async fn load_from_file(path: &PathBuf) -> Result { - debug!( - "Loading jammed checkpoint from file: {}", - path.as_os_str().to_str().unwrap() - ); + async fn load_from_file(path: &Path) -> Result { + debug!("Loading jammed checkpoint from file: {}", path.display()); let bytes = tokio::fs::read(path).await?; let config = bincode::config::standard(); let (checkpoint, _) = bincode::decode_from_slice::(&bytes, config)?; @@ -727,7 +713,7 @@ impl JammedCheckpointV0 { #[tracing::instrument(skip(self))] #[allow(dead_code)] // Preserving this for posterity - async fn save_to_file(&self, path: &PathBuf) -> Result<(), CheckpointError> { + async fn save_to_file(&self, path: &Path) -> Result<(), CheckpointError> { let bytes = self.encode()?; trace!("Saving jammed checkpoint to file: {}", path.display()); tokio::fs::write(path, bytes).await?; diff --git a/crates/nockapp/src/noun/slab.rs b/crates/nockapp/src/noun/slab.rs index f1f255c1d..c042b6676 100644 --- a/crates/nockapp/src/noun/slab.rs +++ b/crates/nockapp/src/noun/slab.rs @@ -1101,7 +1101,12 @@ mod tests { fn test_cell_construction_for_noun_slab() { let mut slab: NounSlab = NounSlab::new(); let (cell, cell_mem_ptr) = unsafe { Cell::new_raw_mut(&mut slab) }; - unsafe { assert!(cell_mem_ptr as *const CellMemory == cell.to_raw_pointer()) }; + unsafe { + assert!(std::ptr::eq( + cell_mem_ptr as *const CellMemory, + cell.to_raw_pointer() + )) + }; } #[test] diff --git a/crates/nockchain-api/Cargo.toml b/crates/nockchain-api/Cargo.toml index 94a23f56d..619a968eb 100644 --- a/crates/nockchain-api/Cargo.toml +++ b/crates/nockchain-api/Cargo.toml @@ -2,32 +2,35 @@ name = "nockchain-api" version.workspace = true edition.workspace = true +license = "MIT OR Apache-2.0" [features] default = [] tracing-heap = ["tracy-client"] malloc = [] +[target.'cfg(not(target_vendor = "apple"))'.dependencies] +tikv-jemallocator = { workspace = true, features = ["unprefixed_malloc_on_supported_platforms"] } + +[target.'cfg(target_vendor = "apple")'.dependencies] +tikv-jemallocator = { workspace = true } + [dependencies] + +clap.workspace = true kernels = { workspace = true, features = ["dumb"] } nockapp.workspace = true nockchain.workspace = true nockvm.workspace = true -zkvm-jetpack.workspace = true - -clap.workspace = true -tikv-jemallocator = { workspace = true, features = [ - "unprefixed_malloc_on_supported_platforms", -] } tokio.workspace = true tracy-client = { workspace = true, optional = true } +zkvm-jetpack.workspace = true [dev-dependencies] +nockapp-grpc-proto = { workspace = true } nockchain-types = { workspace = true } noun-serde = { workspace = true } -tokio-test = "0.4" tempfile = { workspace = true } -nockapp-grpc-proto = { workspace = true } tonic = { workspace = true } [[bench]] diff --git a/crates/nockchain-explorer-tui/Cargo.toml b/crates/nockchain-explorer-tui/Cargo.toml index b50ee6235..e3e2d6bee 100644 --- a/crates/nockchain-explorer-tui/Cargo.toml +++ b/crates/nockchain-explorer-tui/Cargo.toml @@ -2,6 +2,7 @@ name = "nockchain-explorer-tui" version = "0.1.0" edition = "2021" +license = "MIT OR Apache-2.0" [[bin]] name = "nockchain-explorer-tui" @@ -9,17 +10,16 @@ path = "src/main.rs" [dependencies] anyhow = { workspace = true } -clap = { workspace = true, features = ["derive"] } +arboard = "3.3" chrono = { workspace = true } +clap = { workspace = true, features = ["derive"] } crossterm = { workspace = true } -nockapp-grpc = { workspace = true } nockapp-grpc-proto = { workspace = true } -nockchain-types = { workspace = true } nockchain-math = { workspace = true } +nockchain-types = { workspace = true } ratatui = "0.28" tokio = { workspace = true, features = ["full"] } tonic = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter"] } tracing-tracy = { workspace = true } -arboard = "3.3" diff --git a/crates/nockchain-explorer-tui/src/main.rs b/crates/nockchain-explorer-tui/src/main.rs index 7b88d6c19..7e14517df 100644 --- a/crates/nockchain-explorer-tui/src/main.rs +++ b/crates/nockchain-explorer-tui/src/main.rs @@ -1,3 +1,14 @@ +// Allow architectural patterns +#![allow(clippy::large_enum_variant)] +#![allow(clippy::unnecessary_cast)] +#![allow(clippy::needless_borrow)] +#![allow(clippy::needless_borrows_for_generic_args)] +#![allow(clippy::redundant_closure)] +#![allow(clippy::or_fun_call)] +#![allow(clippy::explicit_counter_loop)] +#![allow(clippy::vec_init_then_push)] +#![allow(clippy::unwrap_or_default)] + use std::cmp::Ordering; use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering}; @@ -38,7 +49,7 @@ use ratatui::{Frame, Terminal}; use tokio::sync::mpsc::error::TryRecvError; use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; use tonic::Request; -use tracing::{debug, info, trace, warn}; +use tracing::{info, trace, warn}; use tracing_subscriber::prelude::*; use tracing_subscriber::{fmt, EnvFilter}; use tracing_tracy::TracyLayer; @@ -147,6 +158,7 @@ struct App { last_successful_connection: Option, last_connection_attempt: Instant, last_connection_error: Option, + #[allow(dead_code)] fail_fast: bool, // Prefetch state - background loading of block details to avoid loading screens @@ -293,11 +305,8 @@ impl App { (None, ConnectionStatus::NeverConnected, Some(e.to_string())) } }; - let metrics_client = match NockchainMetricsServiceClient::connect(server_uri.clone()).await - { - Ok(client) => Some(client), - Err(_) => None, - }; + let metrics_client = + (NockchainMetricsServiceClient::connect(server_uri.clone()).await).ok(); let (wallet_cmd_tx, wallet_cmd_rx) = mpsc::unbounded_channel(); let (wallet_res_tx, wallet_res_rx) = mpsc::unbounded_channel(); @@ -3989,7 +3998,7 @@ async fn wallet_index_worker( let fetch_result = client .as_mut() - .unwrap() + .expect("client should be connected") .get_transaction_details(Request::new(request)) .await; diff --git a/crates/nockchain-libp2p-io/Cargo.toml b/crates/nockchain-libp2p-io/Cargo.toml index 9ffe59c36..03469e894 100644 --- a/crates/nockchain-libp2p-io/Cargo.toml +++ b/crates/nockchain-libp2p-io/Cargo.toml @@ -2,13 +2,9 @@ name = "nockchain-libp2p-io" version.workspace = true edition.workspace = true +license = "MIT OR Apache-2.0" [dependencies] -nockapp = { workspace = true } -nockvm = { workspace = true } -nockvm_macros = { workspace = true } -noun-serde = { workspace = true } -zkvm-jetpack = { workspace = true } bs58 = { workspace = true } bytes = { workspace = true } @@ -35,14 +31,18 @@ libp2p = { workspace = true, features = [ "cbor", "peer-store", ] } +nockapp = { workspace = true } +nockvm = { workspace = true } +nockvm_macros = { workspace = true } +noun-serde = { workspace = true } +num-bigint.workspace = true rand = { workspace = true, features = ["std"] } serde = { workspace = true, features = ["alloc", "derive", "serde_derive"] } serde_bytes = { workspace = true, features = ["alloc"] } tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } -void = { workspace = true } [dev-dependencies] +cbor4ii = { workspace = true, features = ["serde1", "use_std"] } quickcheck = { workspace = true } serde_cbor = { workspace = true } -cbor4ii = { workspace = true, features = ["serde1", "use_std"] } diff --git a/crates/nockchain-libp2p-io/src/cbor_tests.rs b/crates/nockchain-libp2p-io/src/cbor_tests.rs index 10cbfeaae..1292bcc5a 100644 --- a/crates/nockchain-libp2p-io/src/cbor_tests.rs +++ b/crates/nockchain-libp2p-io/src/cbor_tests.rs @@ -1,3 +1,4 @@ +#![allow(clippy::unwrap_used)] use quickcheck::{Arbitrary, Gen}; use serde_bytes::ByteBuf; @@ -36,8 +37,8 @@ impl Arbitrary for NockchainRequest { false => NockchainRequest::Request { pow: { let mut arr = [0u8; 16]; - for i in 0..16 { - arr[i] = u8::arbitrary(g); + for elem in &mut arr { + *elem = u8::arbitrary(g); } arr }, @@ -140,7 +141,6 @@ impl Arbitrary for CorruptedCborData { #[cfg(test)] mod tests { use quickcheck::TestResult; - use serde_cbor; use super::*; @@ -439,9 +439,7 @@ mod tests { } Err(e) => { let error_msg = format!("{:?}", e); - if error_msg.contains("Eof") && error_msg.contains("enum") && error_msg.contains("Small(1)") { - TestResult::from_bool(true) - } else if error_msg.contains("Eof") { + if error_msg.contains("Eof") { TestResult::from_bool(true) } else { TestResult::error(format!("Unexpected error type: {}", error_msg)) @@ -592,21 +590,14 @@ mod tests { match cbor4ii_serde::from_slice::(&corrupted_data) { Ok(_) => TestResult::from_bool(true), - Err(e) => { - let error_msg = format!("{:?}", e); - if error_msg.contains("Eof") && error_msg.contains("enum") && error_msg.contains("Small(1)") { - TestResult::from_bool(true) - } else { - TestResult::from_bool(true) - } - } + Err(_) => TestResult::from_bool(true), } } } #[test] fn test_comprehensive_eof_enum_search() { - let test_messages = vec![ + let test_messages = [ NockchainRequest::Gossip { message: ByteBuf::from(vec![]), }, @@ -630,7 +621,7 @@ mod tests { let mut exact_error_found = false; - for (_msg_idx, message) in test_messages.iter().enumerate() { + for message in test_messages.iter() { let cbor_data = serde_cbor::to_vec(message).expect("Serialization should work"); for corruption_type in 0..4 { @@ -709,7 +700,8 @@ mod tests { } } - assert!(exact_error_found || !exact_error_found, "Test completed"); + // Just ensure test executes completely + let _ = exact_error_found; } #[test] @@ -756,7 +748,7 @@ mod tests { #[test] fn test_cbor_baseline_robustness() { - let request_cases = vec![ + let request_cases = [ NockchainRequest::Gossip { message: ByteBuf::from(b"test message".to_vec()), }, @@ -767,7 +759,7 @@ mod tests { }, ]; - let response_cases = vec![ + let response_cases = [ NockchainResponse::Ack { acked: true }, NockchainResponse::Result { message: ByteBuf::from(b"response data".to_vec()), @@ -802,12 +794,8 @@ mod tests { let ack_response = NockchainResponse::Ack { acked: true }; - let serde_cbor_result = serde_cbor::to_vec(&ack_response); - match serde_cbor_result { - Ok(serde_cbor_bytes) => { - let _result = serde_cbor::from_slice::(&serde_cbor_bytes); - } - Err(_) => {} + if let Ok(serde_cbor_bytes) = serde_cbor::to_vec(&ack_response) { + let _result = serde_cbor::from_slice::(&serde_cbor_bytes); } let mut cbor4ii_buffer = Vec::new(); @@ -897,10 +885,7 @@ mod tests { } } - assert!( - true, - "Test completed - documented the EOF enum error pattern" - ); + // Test completed - documented the EOF enum error pattern } #[test] @@ -920,7 +905,7 @@ mod tests { && error_msg.contains("enum") && error_msg.contains("Small(1)") { - return; // Successfully reproduced the error + // Successfully reproduced the error } else { panic!("Got EOF error but not the expected pattern: {}", error_msg); } diff --git a/crates/nockchain-libp2p-io/src/driver.rs b/crates/nockchain-libp2p-io/src/driver.rs index 397d27c0d..fd97977cf 100644 --- a/crates/nockchain-libp2p-io/src/driver.rs +++ b/crates/nockchain-libp2p-io/src/driver.rs @@ -291,16 +291,13 @@ pub fn make_libp2p_driver( send_back_addr, local_addr, error); // When connection limits are reached, randomly prune inbound connections - match error { - ListenError::Denied { cause } => { - metrics.incoming_connections_blocked_by_limits.increment(); - if let Some(prune_factor) = prune_inbound_size { - if let Ok(_exceeded) = cause.downcast::() { - driver_state.lock().await.prune_inbound_connections(metrics.clone(), &mut swarm, prune_factor); - } + if let ListenError::Denied { cause } = error { + metrics.incoming_connections_blocked_by_limits.increment(); + if let Some(prune_factor) = prune_inbound_size { + if let Ok(_exceeded) = cause.downcast::() { + driver_state.lock().await.prune_inbound_connections(metrics.clone(), &mut swarm, prune_factor); } } - _ => {} } }, SwarmEvent::Behaviour(NockchainEvent::RequestResponse(Message { connection_id , peer, message })) => { @@ -604,14 +601,12 @@ async fn handle_effect( EffectType::LiarPeer => { let effect_cell = unsafe { noun_slab.root().as_cell()? }; let liar_peer_cell = effect_cell.tail().as_cell().map_err(|_| { - NockAppError::IoError(std::io::Error::new( - std::io::ErrorKind::Other, + NockAppError::IoError(std::io::Error::other( "Expected peer ID cell in liar-peer effect", )) })?; let peer_id_atom = liar_peer_cell.head().as_atom().map_err(|_| { - NockAppError::IoError(std::io::Error::new( - std::io::ErrorKind::Other, + NockAppError::IoError(std::io::Error::other( "Expected peer ID atom in liar-peer effect", )) })?; @@ -621,17 +616,11 @@ async fn handle_effect( .expect("failed to strip null bytes"); let peer_id_str = String::from_utf8(bytes).map_err(|_| { - NockAppError::IoError(std::io::Error::new( - std::io::ErrorKind::Other, - "Invalid UTF-8 in peer ID", - )) + NockAppError::IoError(std::io::Error::other("Invalid UTF-8 in peer ID")) })?; let peer_id = PeerId::from_str(&peer_id_str).map_err(|_| { - NockAppError::IoError(std::io::Error::new( - std::io::ErrorKind::Other, - "Invalid peer ID format", - )) + NockAppError::IoError(std::io::Error::other("Invalid peer ID format")) })?; swarm_tx @@ -644,8 +633,7 @@ async fn handle_effect( EffectType::LiarBlockId => { let effect_cell = unsafe { noun_slab.root().as_cell()? }; let liar_block_cell = effect_cell.tail().as_cell().map_err(|_| { - NockAppError::IoError(std::io::Error::new( - std::io::ErrorKind::Other, + NockAppError::IoError(std::io::Error::other( "Expected block ID cell in liar-block-id effect", )) })?; @@ -695,8 +683,7 @@ async fn handle_effect( let mut state_guard = driver_state.lock().await; state_guard.remove_block_id(block_id)?; } else { - return Err(NockAppError::IoError(std::io::Error::new( - std::io::ErrorKind::Other, + return Err(NockAppError::IoError(std::io::Error::other( "Invalid track action", ))); } @@ -1584,6 +1571,7 @@ fn record_crown_error_metric(error: &CrownError, metrics: &Nockch } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use std::sync::LazyLock; @@ -1594,7 +1582,7 @@ mod tests { use super::*; - pub static LIBP2P_CONFIG: LazyLock = LazyLock::new(|| LibP2PConfig::default()); + pub static LIBP2P_CONFIG: LazyLock = LazyLock::new(LibP2PConfig::default); #[test] #[cfg_attr(miri, ignore)] // ibig has a memory leak so miri fails this test @@ -1687,7 +1675,7 @@ mod tests { let mut slab: NounSlab = NounSlab::new(); slab.set_root(D(123)); let result = NockchainDataRequest::from_noun(*unsafe { slab.root() }) - .and_then(|r| request_to_scry_slab(r)); + .and_then(request_to_scry_slab); assert!(result.is_err()); } @@ -1809,8 +1797,8 @@ mod tests { ); slab.set_root(invalid_request); - let result = NockchainDataRequest::from_noun(invalid_request) - .and_then(|r| request_to_scry_slab(r)); + let result = + NockchainDataRequest::from_noun(invalid_request).and_then(request_to_scry_slab); assert!(result.is_err()); drop(slab); } diff --git a/crates/nockchain-libp2p-io/src/key_fair_queue.rs b/crates/nockchain-libp2p-io/src/key_fair_queue.rs index c129b3f58..f02021711 100644 --- a/crates/nockchain-libp2p-io/src/key_fair_queue.rs +++ b/crates/nockchain-libp2p-io/src/key_fair_queue.rs @@ -50,7 +50,7 @@ impl From> for Error { impl Sender { pub fn send(&self, key: K, value: V) -> Result<(), Error> { let value_mutex = Mutex::new(value); - if let None = self.enqueued.insert(key.clone(), value_mutex) { + if self.enqueued.insert(key.clone(), value_mutex).is_none() { Ok(self.key_sender.send(key)?) } else { Ok(()) diff --git a/crates/nockchain-libp2p-io/src/lib.rs b/crates/nockchain-libp2p-io/src/lib.rs index ef11e00b0..480678ca5 100644 --- a/crates/nockchain-libp2p-io/src/lib.rs +++ b/crates/nockchain-libp2p-io/src/lib.rs @@ -1,3 +1,9 @@ +// Allow architectural design choices that would be disruptive to change +#![allow(clippy::too_many_arguments)] +#![allow(clippy::result_large_err)] +#![allow(clippy::enum_variant_names)] +#![allow(clippy::items_after_test_module)] + mod behaviour; // Nockchain libp2p behavior type pub mod config; // Configurable values for the Nockchain libp2p driver pub mod driver; // Nockchain libp2p driver for NockApp diff --git a/crates/nockchain-libp2p-io/src/messages.rs b/crates/nockchain-libp2p-io/src/messages.rs index 1bd82d4b4..7a056733c 100644 --- a/crates/nockchain-libp2p-io/src/messages.rs +++ b/crates/nockchain-libp2p-io/src/messages.rs @@ -61,9 +61,9 @@ impl NockchainFact { } pub fn fact_poke(&self) -> &NounSlab { match self { - Self::HeardBlock(_, slab) => &slab, - Self::HeardTx(_, slab) => &slab, - Self::HeardElders(_, _, slab) => &slab, + Self::HeardBlock(_, slab) => slab, + Self::HeardTx(_, slab) => slab, + Self::HeardElders(_, _, slab) => slab, } } } @@ -76,12 +76,13 @@ fn block_id_from_page(page: Noun) -> Result { Ok(version_atom) => { let version = version_atom.as_u64()?; if version == 1 { - return Ok(page_cell.tail().as_cell()?.head()); + Ok(page_cell.tail().as_cell()?.head()) + } else { + Err(NockAppError::OtherError(format!( + "Unsupported page version {}", + version + ))) } - return Err(NockAppError::OtherError(format!( - "Unsupported page version {}", - version - ))); } Err(_) => Ok(page_cell.head()), } @@ -95,12 +96,13 @@ fn tx_id_from_raw_tx(raw_tx: Noun) -> Result { Ok(version_atom) => { let version = version_atom.as_u64()?; if version == 1 { - return Ok(raw_tx_cell.tail().as_cell()?.head()); + Ok(raw_tx_cell.tail().as_cell()?.head()) + } else { + Err(NockAppError::OtherError(format!( + "Unsupported raw-tx version {}", + version + ))) } - return Err(NockAppError::OtherError(format!( - "Unsupported raw-tx version {}", - version - ))); } Err(_) => Ok(raw_tx_cell.head()), } @@ -167,16 +169,11 @@ impl NockchainDataRequest { ))) } })(); - res.map_err(|_| { - NockAppError::IoError(std::io::Error::new( - std::io::ErrorKind::Other, - "bad request", - )) - }) + res.map_err(|_| NockAppError::IoError(std::io::Error::other("bad request"))) } } -#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] +#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)] /// Network struct (in serde/CBOR) for requests pub enum NockchainRequest { /// Request a block or TX from another node, carry PoW @@ -275,7 +272,7 @@ impl NockchainRequest { } } -#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)] /// Responses to Nockchain requests pub enum NockchainResponse { /// The requested block or raw-tx diff --git a/crates/nockchain-libp2p-io/src/p2p_state.rs b/crates/nockchain-libp2p-io/src/p2p_state.rs index d43b6cf8e..b0570ffcb 100644 --- a/crates/nockchain-libp2p-io/src/p2p_state.rs +++ b/crates/nockchain-libp2p-io/src/p2p_state.rs @@ -16,22 +16,13 @@ use crate::metrics::NockchainP2PMetrics; use crate::p2p_util::MultiaddrExt; use crate::tip5_util::tip5_hash_to_base58; +#[derive(Default)] struct IpInfo { request_count: u64, ping_failure_count: u64, connections: BTreeSet, } -impl Default for IpInfo { - fn default() -> Self { - IpInfo { - request_count: 0, - ping_failure_count: 0, - connections: BTreeSet::new(), - } - } -} - pub struct P2PState { metrics: Arc, block_id_to_peers: BTreeMap>, @@ -418,6 +409,7 @@ pub enum CacheResponse { } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use std::net::{Ipv4Addr, Ipv6Addr}; use std::sync::LazyLock; @@ -430,7 +422,7 @@ mod tests { use crate::config::LibP2PConfig; use crate::p2p_util::PeerIdExt; - pub static LIBP2P_CONFIG: LazyLock = LazyLock::new(|| LibP2PConfig::default()); + pub static LIBP2P_CONFIG: LazyLock = LazyLock::new(LibP2PConfig::default); #[test] #[cfg_attr(miri, ignore)] // ibig has a memory leak so miri fails this test diff --git a/crates/nockchain-libp2p-io/src/tip5_util.rs b/crates/nockchain-libp2p-io/src/tip5_util.rs index cb62c3139..aeca4da38 100644 --- a/crates/nockchain-libp2p-io/src/tip5_util.rs +++ b/crates/nockchain-libp2p-io/src/tip5_util.rs @@ -3,6 +3,7 @@ use ibig::{ubig, Stack, UBig}; use nockapp::NockAppError; use nockvm::noun::Noun; use noun_serde::prelude::*; +use num_bigint::BigUint; //TODO all this stuff would be useful as jets, which mostly just requires //using the Atom::as_ubig with the NockStack instead of ibig's heap version @@ -40,9 +41,17 @@ pub fn tip5_hash_to_base58_stack( Ok(base58_string) } -// FIXME: This use of ibig's pow will leak memory. -fn accum_prime_ubig(prime: &UBig, acc: &mut UBig, value: u64, i: usize) { - *acc += UBig::from(value) * prime.pow(i); +fn biguint_to_ubig(value: BigUint) -> UBig { + UBig::from_be_bytes(&value.to_bytes_be()) +} + +fn ubig_to_biguint(value: &UBig) -> BigUint { + BigUint::from_bytes_be(&value.to_be_bytes()) +} + +fn accum_prime_biguint(prime: &BigUint, acc: &mut BigUint, value: u64, i: usize) { + let pow = prime.pow(i as u32); + *acc += BigUint::from(value) * pow; } fn accum_prime_ubig_stack( @@ -58,14 +67,13 @@ fn accum_prime_ubig_stack( } pub fn base_p_to_decimal(hash: [u64; 5]) -> Result { - let prime_ubig = UBig::from(P); - let mut result = ubig!(0); + let prime = BigUint::from(P); + let mut result = BigUint::from(0u8); for (i, value) in hash.iter().enumerate() { - // Add the value * P^i to the result - accum_prime_ubig(&prime_ubig, &mut result, *value, i); + accum_prime_biguint(&prime, &mut result, *value, i); } - Ok(result) + Ok(biguint_to_ubig(result)) } pub fn base_p_to_decimal_stack( @@ -101,16 +109,23 @@ pub fn base58_to_ubig(value: String) -> Result { pub fn decimal_to_base_p(value: UBig) -> Result<[u64; 5], NockAppError> { let mut result = [0; 5]; - let mut value = value.clone(); - for i in 0..5 { - // TODO: I shouldn't have to clone here - result[i] = (value.clone() % P) as u64; - value /= P; + let prime = BigUint::from(P); + let mut value = ubig_to_biguint(&value); + for (i, result_elem) in result.iter_mut().enumerate() { + let rem = &value % ′ + *result_elem = rem.try_into().map_err(|_| { + NockAppError::NounDecodeError(Box::new(bs58::decode::Error::InvalidCharacter { + character: '?', + index: i, + })) + })?; + value /= ′ } Ok(result) } #[cfg(test)] +#[allow(clippy::unwrap_used)] mod tests { use nockapp::noun::slab::NounSlab; use nockvm::noun::{D, T}; @@ -119,10 +134,10 @@ mod tests { use super::*; fn iso(tip5: [u64; 5]) { - let ubig = base_p_to_decimal(tip5).unwrap(); + let ubig = base_p_to_decimal(tip5).expect("base_p_to_decimal should work"); let base58 = ubig_to_base58(ubig); - let ubig2 = base58_to_ubig(base58).unwrap(); - let tip5_2 = decimal_to_base_p(ubig2).unwrap(); + let ubig2 = base58_to_ubig(base58).expect("base58_to_ubig should work"); + let tip5_2 = decimal_to_base_p(ubig2).expect("decimal_to_base_p should work"); assert_eq!(tip5, tip5_2); } @@ -213,11 +228,11 @@ mod tests { fn arbitrary(g: &mut Gen) -> Self { // Generate 5 u64 values modulo the Goldilocks prime P let mut hash = [0u64; 5]; - for i in 0..5 { + for hash_elem in &mut hash { // Generate a random u32 and convert to u64 to avoid very large values // This helps avoid triggering ibig buffer capacity issues let value: u32 = u32::arbitrary(g); - hash[i] = (value as u64) % P; + *hash_elem = (value as u64) % P; } Tip5Hash(hash) } @@ -284,13 +299,13 @@ mod tests { }; // Manually calculate the expected value - let prime_ubig = UBig::from(P); - let mut expected = ubig!(0); + let prime_big = BigUint::from(P); + let mut expected = BigUint::from(0u8); for (i, &value) in tip5.iter().enumerate() { - expected += UBig::from(value) * prime_ubig.pow(i); + expected += BigUint::from(value) * prime_big.pow(i as u32); } - TestResult::from_bool(ubig == expected) + TestResult::from_bool(ubig == biguint_to_ubig(expected)) } QuickCheck::new() @@ -414,13 +429,7 @@ mod tests { // Create a noun from the tip5 hash let noun = T( &mut slab, - &[ - D(tip5[0] as u64), - D(tip5[1] as u64), - D(tip5[2] as u64), - D(tip5[3] as u64), - D(tip5[4] as u64), - ], + &[D(tip5[0]), D(tip5[1]), D(tip5[2]), D(tip5[3]), D(tip5[4])], ); // Convert to base58 and back diff --git a/crates/nockchain-libp2p-io/src/traffic_cop.rs b/crates/nockchain-libp2p-io/src/traffic_cop.rs index 7246d4a05..410876574 100644 --- a/crates/nockchain-libp2p-io/src/traffic_cop.rs +++ b/crates/nockchain-libp2p-io/src/traffic_cop.rs @@ -135,18 +135,16 @@ async fn traffic_cop_task( Some((_peer_id,TrafficCopPoke { wire, cause, result, enable, timing })) => { let enabled = enable.await; if !(enabled) { - let _ = result.send(Ok(PokeResult::Nack)).map_err(|e| { + let _ = result.send(Ok(PokeResult::Nack)).inspect_err(|_e| { error!("Failed to send high priority poke result"); - e }); continue; } let now = Instant::now(); let res = handle.poke_timeout(wire, cause, poke_timeout).await; timing.map(|c| c.send(now.elapsed())); - let _ = result.send(res).map_err(|e| { + let _ = result.send(res).inspect_err(|_e| { error!("Failed to send high priority poke result"); - e }); } None => { @@ -158,9 +156,8 @@ async fn traffic_cop_task( Some((_peer_id, TrafficCopAction::Poke(TrafficCopPoke { wire, cause, result, enable, timing }))) => { let enabled = enable.await; if !enabled { - let _ = result.send(Ok(PokeResult::Nack)).map_err(|e| { + let _ = result.send(Ok(PokeResult::Nack)).inspect_err(|_e| { error!("Failed to send low priority peek result"); - e }); continue; } @@ -168,16 +165,14 @@ async fn traffic_cop_task( let res = handle.poke_timeout(wire, cause, poke_timeout).await; let elapsed = now.elapsed(); timing.map(|c| c.send(elapsed)); - let _ = result.send(res).map_err(|e| { + let _ = result.send(res).inspect_err(|_e| { error!("Failed to send low priority poke result"); - e }); } Some((_peer_id, TrafficCopAction::Peek { path, result })) => { let res = handle.peek(path).await; - let _ = result.send(res).map_err(|e| { + let _ = result.send(res).inspect_err(|_e| { error!("Failed to send low priority peek result"); - e }); } None => { diff --git a/crates/nockchain-math/Cargo.toml b/crates/nockchain-math/Cargo.toml index e8ba56f83..018c2061b 100644 --- a/crates/nockchain-math/Cargo.toml +++ b/crates/nockchain-math/Cargo.toml @@ -2,6 +2,7 @@ name = "nockchain-math" version.workspace = true edition.workspace = true +license = "MIT OR Apache-2.0" [dependencies] argon2.workspace = true @@ -9,7 +10,6 @@ arrayref.workspace = true bs58.workspace = true bytes.workspace = true either.workspace = true -hex-literal.workspace = true ibig.workspace = true nockvm.workspace = true nockvm_macros.workspace = true @@ -18,9 +18,9 @@ num-traits.workspace = true once_cell.workspace = true quickcheck.workspace = true rkyv.workspace = true -serde = { workspace = true, features = ["derive"], default-features = false } -tracing.workspace = true +serde = { version = "1.0.217", features = ["derive"], default-features = false } thiserror.workspace = true +tracing.workspace = true [dev-dependencies] quickcheck.workspace = true diff --git a/crates/nockchain-math/src/belt.rs b/crates/nockchain-math/src/belt.rs index ddfbbb45f..b19ac3d34 100644 --- a/crates/nockchain-math/src/belt.rs +++ b/crates/nockchain-math/src/belt.rs @@ -12,8 +12,6 @@ use serde::de::Error as SerdeError; use serde::{Deserialize as SerdeDeserialize, Serialize as SerdeSerialize}; use tracing::debug; -use crate::based; - // Base field arithmetic functions. pub const PRIME: u64 = 18446744069414584321; pub const PRIME_PRIME: u64 = PRIME - 2; diff --git a/crates/nockchain-math/src/bpoly.rs b/crates/nockchain-math/src/bpoly.rs index 2d056be03..40b72cf1c 100644 --- a/crates/nockchain-math/src/bpoly.rs +++ b/crates/nockchain-math/src/bpoly.rs @@ -365,22 +365,22 @@ pub fn bpegcd(a: &[Belt], b: &[Belt], d: &mut [Belt], u: &mut [Belt], v: &mut [B b = r; let q_len = q.len(); - let m1_u_len = m1_u.len() as usize; + let m1_u_len = m1_u.len(); let mut res1_len = q_len + m1_u_len - 1; - let mut res1 = vec![Belt(0); res1_len as usize]; + let mut res1 = vec![Belt(0); res1_len]; bpmul(q.as_slice(), m1_u.as_slice(), res1.as_mut_slice()); let m2_u_len = m2_u.len(); let len_res2 = std::cmp::max(m2_u_len, res1_len); - let mut res2 = vec![Belt(0); len_res2 as usize]; + let mut res2 = vec![Belt(0); len_res2]; bpsub(m2_u.as_slice(), res1.as_slice(), res2.as_mut_slice()); m2_u = m1_u; m1_u = res2; - let m1_v_len = m1_v.len() as usize; + let m1_v_len = m1_v.len(); res1.fill(Belt(0)); res1_len = q_len + m1_v_len - 1; @@ -390,7 +390,7 @@ pub fn bpegcd(a: &[Belt], b: &[Belt], d: &mut [Belt], u: &mut [Belt], v: &mut [B let m2_v_len = m2_v.len(); let len_res3 = std::cmp::max(m2_v_len, res1_len); - let mut res3 = vec![Belt(0); len_res3 as usize]; + let mut res3 = vec![Belt(0); len_res3]; bpsub(m2_v.as_slice(), res1.as_slice(), res3.as_mut_slice()); @@ -404,8 +404,8 @@ pub fn bpegcd(a: &[Belt], b: &[Belt], d: &mut [Belt], u: &mut [Belt], v: &mut [B let m2_u_len = m2_u.len(); let m2_v_len = m2_v.len(); - u[0..(m2_u_len as usize)].copy_from_slice(&m2_u[0..(m2_u_len as usize)]); - v[0..(m2_v_len as usize)].copy_from_slice(&m2_v[0..(m2_v_len as usize)]); + u[..m2_u_len].copy_from_slice(&m2_u[..m2_u_len]); + v[..m2_v_len].copy_from_slice(&m2_v[..m2_v_len]); } #[inline(always)] diff --git a/crates/nockchain-math/src/convert.rs b/crates/nockchain-math/src/convert.rs index 3adb920d5..481cb897c 100644 --- a/crates/nockchain-math/src/convert.rs +++ b/crates/nockchain-math/src/convert.rs @@ -107,9 +107,9 @@ impl NounMathExt for Noun { }); if let Some(e) = ret.iter_mut().find(|v| v.is_err()) { let n = core::mem::replace(e, Ok(D(0))); - return Err(n.unwrap_err()); + return Err(n.expect_err("checked is_err above")); } - Ok(ret.map(|v| v.unwrap())) + Ok(ret.map(|v| v.expect("all results are Ok after filtering errors"))) } } @@ -340,9 +340,9 @@ impl NounDecode for FPolyVec { impl NounEncode for FPolyVec { fn to_noun(&self, allocator: &mut A) -> nockvm::noun::Noun { let (res, res_poly): (IndirectAtom, &mut [Felt]) = - new_handle_mut_slice(allocator, Some(self.0.len() as usize)); + new_handle_mut_slice(allocator, Some(self.0.len())); res_poly.copy_from_slice(&self.0); - finalize_poly(allocator, Some(self.0.len() as usize), res) + finalize_poly(allocator, Some(self.0.len()), res) } } @@ -358,8 +358,8 @@ impl NounDecode for BPolyVec { impl NounEncode for BPolyVec { fn to_noun(&self, allocator: &mut A) -> nockvm::noun::Noun { let (res, res_poly): (IndirectAtom, &mut [Belt]) = - new_handle_mut_slice(allocator, Some(self.0.len() as usize)); + new_handle_mut_slice(allocator, Some(self.0.len())); res_poly.copy_from_slice(&self.0); - finalize_poly(allocator, Some(self.0.len() as usize), res) + finalize_poly(allocator, Some(self.0.len()), res) } } diff --git a/crates/nockchain-math/src/crypto/cheetah.rs b/crates/nockchain-math/src/crypto/cheetah.rs index 21a8477ab..44fe2b7c2 100644 --- a/crates/nockchain-math/src/crypto/cheetah.rs +++ b/crates/nockchain-math/src/crypto/cheetah.rs @@ -12,7 +12,7 @@ pub static G_ORDER: Lazy = Lazy::new(|| { UBig::from_str_radix( "7af2599b3b3f22d0563fbf0f990a37b5327aa72330157722d443623eaed4accf", 16, ) - .unwrap() + .expect("G_ORDER constant is valid hex") }); pub static P_BIG: Lazy = Lazy::new(|| UBig::from(PRIME)); @@ -99,12 +99,8 @@ impl CheetahPoint { v64.reverse(); let c_pt = CheetahPoint { - x: F6lt { - 0: <[Belt; 6]>::try_from(&v64[..6]).map_err(|_| CheetahError::ArrayConversion)?, - }, - y: F6lt { - 0: <[Belt; 6]>::try_from(&v64[6..]).map_err(|_| CheetahError::ArrayConversion)?, - }, + x: F6lt(<[Belt; 6]>::try_from(&v64[..6]).map_err(|_| CheetahError::ArrayConversion)?), + y: F6lt(<[Belt; 6]>::try_from(&v64[6..]).map_err(|_| CheetahError::ArrayConversion)?), inf: false, }; @@ -119,7 +115,7 @@ impl CheetahPoint { if *self == A_ID { return true; } - let scaled = ch_scal_big(&G_ORDER, self).unwrap(); + let scaled = ch_scal_big(&G_ORDER, self).expect("scalar multiplication should succeed"); scaled == A_ID } } diff --git a/crates/nockchain-math/src/mary.rs b/crates/nockchain-math/src/mary.rs index 69553f216..3302c573d 100644 --- a/crates/nockchain-math/src/mary.rs +++ b/crates/nockchain-math/src/mary.rs @@ -31,14 +31,14 @@ pub struct Table<'a> { } impl Mary { - pub fn as_slice(&self) -> MarySlice { + pub fn as_slice(&self) -> MarySlice<'_> { MarySlice { step: self.step, len: self.len, dat: self.dat.as_slice(), } } - pub fn as_mut_slice(&mut self) -> MarySliceMut { + pub fn as_mut_slice(&mut self) -> MarySliceMut<'_> { MarySliceMut { step: self.step, len: self.len, @@ -104,8 +104,7 @@ impl NounEncode for Mary { res_poly.dat.copy_from_slice(&self.dat[..]); - let res_cell = finalize_mary(allocator, self.step as usize, self.len as usize, res); - res_cell + finalize_mary(allocator, self.step as usize, self.len as usize, res) } } @@ -169,7 +168,7 @@ pub fn mary_transpose(fpolys: MarySlice, offset: usize, res: &mut MarySliceMut) } #[inline(always)] -pub fn snag_as_bpoly(a: MarySlice, i: usize) -> &[Belt] { +pub fn snag_as_bpoly(a: MarySlice<'_>, i: usize) -> &[Belt] { let step = a.step as usize; to_belts(&a.dat[step * i..(step * (i + 1))]) } diff --git a/crates/nockchain-math/src/tip5/hash.rs b/crates/nockchain-math/src/tip5/hash.rs index e0ab8f915..0608881d8 100644 --- a/crates/nockchain-math/src/tip5/hash.rs +++ b/crates/nockchain-math/src/tip5/hash.rs @@ -15,7 +15,7 @@ pub fn assert_all_based(vecbelt: &Vec) { } // calc q and r for vecbelt, based on RATE -pub fn tip5_calc_q_r(input_vec: &Vec) -> (usize, usize) { +pub fn tip5_calc_q_r(input_vec: &[Belt]) -> (usize, usize) { let lent_input = input_vec.len(); let (q, r) = (lent_input / RATE, lent_input % RATE); (q, r) @@ -30,9 +30,9 @@ pub fn tip5_pad_vecbelt(input_vec: &mut Vec, r: usize) { } // monitify vecbelt (bring into montgomery space) -pub fn tip5_montify_vecbelt(input_vec: &mut Vec) { - for i in 0..input_vec.len() { - input_vec[i] = Belt(montify(input_vec[i].0)); +pub fn tip5_montify_vecbelt(input_vec: &mut [Belt]) { + for belt in input_vec.iter_mut() { + *belt = Belt(montify(belt.0)); } } diff --git a/crates/nockchain-math/src/zoon/common.rs b/crates/nockchain-math/src/zoon/common.rs index aba33b458..d22cb2c5f 100644 --- a/crates/nockchain-math/src/zoon/common.rs +++ b/crates/nockchain-math/src/zoon/common.rs @@ -51,7 +51,7 @@ pub fn double_tip( let mut ten_cell = [0; 10]; ten_cell[0..5].copy_from_slice(&hash); ten_cell[5..].copy_from_slice(&hash); - Ok(hasher.hash_ten_cell(ten_cell)?) + hasher.hash_ten_cell(ten_cell) } pub fn lth_tip(a: &[u64; 5], b: &[u64; 5]) -> bool { @@ -62,7 +62,7 @@ pub fn lth_tip(a: &[u64; 5], b: &[u64; 5]) -> bool { return false; } } - return false; + false } pub fn gor_tip( diff --git a/crates/nockchain-math/src/zoon/zmap.rs b/crates/nockchain-math/src/zoon/zmap.rs index 16fa639d3..636849727 100644 --- a/crates/nockchain-math/src/zoon/zmap.rs +++ b/crates/nockchain-math/src/zoon/zmap.rs @@ -18,18 +18,17 @@ pub fn z_map_put( let kv = T(stack, &[*b, *c]); Ok(T(stack, &[kv, D(0), D(0)])) } else { - let [mut an, mut al, mut ar] = a.uncell()?; + let [mut an, al, ar] = a.uncell()?; let [mut anp, mut anq] = an.uncell()?; if unsafe { stack.equals(b, &mut anp) } { if unsafe { stack.equals(c, &mut anq) } { return Ok(*a); - } else { - an = T(stack, &[*b, *c]); - let anbc = T(stack, &[an, al, ar]); - return Ok(anbc); } + an = T(stack, &[*b, *c]); + let anbc = T(stack, &[an, al, ar]); + Ok(anbc) } else if gor_tip(stack, b, &mut anp, hasher)? { - let d = z_map_put(stack, &mut al, b, c, hasher)?; + let d = z_map_put(stack, &al, b, c, hasher)?; let [dn, dl, dr] = d.uncell()?; let [mut dnp, _dnq] = dn.uncell()?; if mor_tip(stack, &mut anp, &mut dnp, hasher)? { @@ -39,7 +38,7 @@ pub fn z_map_put( Ok(T(stack, &[dn, dl, new_a])) } } else { - let d = z_map_put(stack, &mut ar, b, c, hasher)?; + let d = z_map_put(stack, &ar, b, c, hasher)?; let [dn, dl, dr] = d.uncell()?; let [mut dnp, _dnq] = dn.uncell()?; if mor_tip(stack, &mut anp, &mut dnp, hasher)? { diff --git a/crates/nockchain-peek/Cargo.toml b/crates/nockchain-peek/Cargo.toml index bedbbdd88..02e1f76e6 100644 --- a/crates/nockchain-peek/Cargo.toml +++ b/crates/nockchain-peek/Cargo.toml @@ -12,8 +12,8 @@ nockapp-grpc = { workspace = true } nockvm = { workspace = true } nockvm_macros = { workspace = true } rustls.workspace = true -zkvm-jetpack.workspace = true tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } +zkvm-jetpack.workspace = true diff --git a/crates/nockchain-peek/src/lib.rs b/crates/nockchain-peek/src/lib.rs index 4c788bdea..e115154e6 100644 --- a/crates/nockchain-peek/src/lib.rs +++ b/crates/nockchain-peek/src/lib.rs @@ -1,6 +1,6 @@ use std::error::Error; -use clap::{arg, command, Parser, Subcommand}; +use clap::{Parser, Subcommand}; use nockapp::driver::Operation; use nockapp::kernel::boot; use nockapp::noun::slab::NounSlab; diff --git a/crates/nockchain-types/Cargo.toml b/crates/nockchain-types/Cargo.toml index a8d7fb56e..3b7c9e4cb 100644 --- a/crates/nockchain-types/Cargo.toml +++ b/crates/nockchain-types/Cargo.toml @@ -2,19 +2,22 @@ name = "nockchain-types" version.workspace = true edition.workspace = true +license = "MIT OR Apache-2.0" [dependencies] +alloy = { workspace = true } anyhow = { workspace = true } -bytes = { workspace = true } bs58 = { workspace = true } -nockapp = { workspace = true } -nockvm = { workspace = true } +bytes = { workspace = true } +hex = { workspace = true } ibig = { workspace = true } +nockapp = { workspace = true } nockchain-math = { workspace = true } +nockvm = { workspace = true } nockvm_macros = { workspace = true } noun-serde = { workspace = true } -serde = { workspace = true, features = ["derive"], default-features = false } - +num-bigint.workspace = true +serde = { version = "1.0.217", features = ["derive"], default-features = false } thiserror = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/crates/nockchain-types/src/eth.rs b/crates/nockchain-types/src/eth.rs new file mode 100644 index 000000000..75f8305ac --- /dev/null +++ b/crates/nockchain-types/src/eth.rs @@ -0,0 +1,215 @@ +use alloy::primitives::Address as AlloyAddress; +use hex::FromHex; +use nockvm::noun::{Atom, IndirectAtom, Noun, NounAllocator}; +use noun_serde::{NounDecode, NounDecodeError, NounEncode}; +use thiserror::Error; + +/// 20-byte Ethereum-compatible address wrapper. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct EthAddress(pub [u8; Self::LEN]); + +impl EthAddress { + pub const LEN: usize = 20; + pub const ZERO: Self = Self([0u8; Self::LEN]); + + pub fn as_bytes(&self) -> &[u8; Self::LEN] { + &self.0 + } + + pub fn as_slice(&self) -> &[u8] { + &self.0 + } + + /// Parses a hex string (optional `0x` prefix, underscores ignored) into an address. + pub fn from_hex_str(raw: &str) -> Result { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err(EthAddressParseError::Empty); + } + + let without_prefix = trimmed + .strip_prefix("0x") + .or_else(|| trimmed.strip_prefix("0X")) + .unwrap_or(trimmed); + let cleaned: String = without_prefix.chars().filter(|c| *c != '_').collect(); + let len = cleaned.len(); + if len != Self::LEN * 2 { + return Err(EthAddressParseError::WrongLength(len)); + } + if !cleaned.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(EthAddressParseError::InvalidCharacters); + } + + let bytes = <[u8; Self::LEN]>::from_hex(&cleaned) + .map_err(|err| EthAddressParseError::InvalidHex(err.to_string()))?; + Ok(Self(bytes)) + } +} + +impl From<[u8; EthAddress::LEN]> for EthAddress { + fn from(value: [u8; EthAddress::LEN]) -> Self { + Self(value) + } +} + +impl From for [u8; EthAddress::LEN] { + fn from(value: EthAddress) -> Self { + value.0 + } +} + +impl From for EthAddress { + fn from(value: AlloyAddress) -> Self { + let mut bytes = [0u8; EthAddress::LEN]; + bytes.copy_from_slice(value.as_slice()); + Self(bytes) + } +} + +impl From for AlloyAddress { + fn from(value: EthAddress) -> Self { + AlloyAddress::from_slice(&value.0) + } +} + +impl std::fmt::Display for EthAddress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "0x")?; + for byte in &self.0 { + write!(f, "{:02x}", byte)?; + } + Ok(()) + } +} + +impl NounEncode for EthAddress { + fn to_noun(&self, allocator: &mut A) -> Noun { + let mut le_bytes = self.0; + le_bytes.reverse(); + let trimmed_len = le_bytes + .iter() + .rposition(|&b| b != 0) + .map(|i| i + 1) + .unwrap_or(0); + + if trimmed_len == 0 { + return Atom::new(allocator, 0).as_noun(); + } + + unsafe { + let mut atom = IndirectAtom::new_raw_bytes(allocator, trimmed_len, le_bytes.as_ptr()); + atom.normalize_as_atom().as_noun() + } + } +} + +impl NounDecode for EthAddress { + fn from_noun(noun: &Noun) -> Result { + let atom = noun.as_atom().map_err(|_| NounDecodeError::ExpectedAtom)?; + let bytes = atom.as_ne_bytes(); + let mut buf = [0u8; EthAddress::LEN]; + for (i, byte) in bytes.iter().enumerate() { + if i < EthAddress::LEN { + buf[EthAddress::LEN - 1 - i] = *byte; + } else if *byte != 0 { + return Err(NounDecodeError::Custom( + "EthAddress noun is longer than 20 bytes".into(), + )); + } + } + Ok(Self(buf)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Error)] +pub enum EthAddressParseError { + #[error("EVM address cannot be empty")] + Empty, + #[error("EVM address must contain exactly 40 hex characters (20 bytes), got length {0}")] + WrongLength(usize), + #[error("EVM address must be valid hex (0-9, a-f)")] + InvalidCharacters, + #[error("Failed to parse EVM address: {0}")] + InvalidHex(String), +} + +#[cfg(test)] +mod tests { + use ibig::UBig; + use nockapp::noun::slab::{NockJammer, NounSlab}; + use nockvm::noun::Atom; + + use super::*; + + #[test] + fn parse_hex_strings() { + let addr = EthAddress::from_hex_str("0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") + .expect("parse works"); + assert_eq!(addr.as_bytes(), &[0xaa; EthAddress::LEN]); + + let addr = EthAddress::from_hex_str("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") + .expect("upper case works"); + assert_eq!(addr.as_bytes(), &[0xaa; EthAddress::LEN]); + } + + #[test] + fn noun_roundtrip() { + let mut slab = NounSlab::::new(); + let addr = EthAddress::from([0x11; EthAddress::LEN]); + let noun = addr.to_noun(&mut slab); + let decoded = EthAddress::from_noun(&noun).expect("decode"); + assert_eq!(decoded, addr); + } + + #[test] + fn noun_encoding_preserves_byte_order() { + let addr = EthAddress([ + 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, + 0x32, 0x10, 0xaa, 0xbb, 0xcc, 0xdd, + ]); + + let mut slab = NounSlab::::new(); + let noun = addr.to_noun(&mut slab); + let atom = noun + .as_atom() + .expect("expected EthAddress noun to be an atom"); + let be_bytes = { + let mut bytes = atom.to_be_bytes(); + if bytes.len() < EthAddress::LEN { + let mut padded = vec![0u8; EthAddress::LEN]; + padded[EthAddress::LEN - bytes.len()..].copy_from_slice(&bytes); + bytes = padded; + } else if bytes.len() > EthAddress::LEN { + bytes = bytes[bytes.len() - EthAddress::LEN..].to_vec(); + } + bytes + }; + + assert_eq!(be_bytes.as_slice(), addr.as_slice()); + } + + #[test] + fn noun_decoding_preserves_byte_order() { + let addr = EthAddress([ + 0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef, 0xfe, 0xdc, 0xba, 0x98, 0x76, 0x54, + 0x32, 0x10, 0xaa, 0xbb, 0xcc, 0xdd, + ]); + + let value = UBig::from_be_bytes(addr.as_slice()); + let mut slab = NounSlab::::new(); + let noun = Atom::from_ubig(&mut slab, &value).as_noun(); + let decoded = EthAddress::from_noun(&noun).expect("decode"); + + assert_eq!(decoded, addr); + } + + #[test] + fn display_is_lower_hex() { + let addr = + EthAddress::from_hex_str("0x0123456789abcdef0123456789abcdef01234567").expect("parse"); + assert_eq!( + addr.to_string(), + "0x0123456789abcdef0123456789abcdef01234567" + ); + } +} diff --git a/crates/nockchain-types/src/lib.rs b/crates/nockchain-types/src/lib.rs index dee352ddd..b6bcdd3a6 100644 --- a/crates/nockchain-types/src/lib.rs +++ b/crates/nockchain-types/src/lib.rs @@ -1,3 +1,5 @@ +pub mod eth; pub mod tx_engine; +pub use eth::*; pub use tx_engine::*; diff --git a/crates/nockchain-types/src/tx_engine/common/mod.rs b/crates/nockchain-types/src/tx_engine/common/mod.rs index b6c0e6373..fc7e7f094 100644 --- a/crates/nockchain-types/src/tx_engine/common/mod.rs +++ b/crates/nockchain-types/src/tx_engine/common/mod.rs @@ -1,14 +1,14 @@ pub mod page; use anyhow::Result; -use ibig::{ubig, UBig}; use nockchain_math::belt::{Belt, PRIME}; -use nockchain_math::crypto::cheetah::{CheetahError, CheetahPoint, P_BIG}; +use nockchain_math::crypto::cheetah::{CheetahError, CheetahPoint}; use nockchain_math::noun_ext::NounMathExt; use nockchain_math::zoon::common::DefaultTipHasher; use nockchain_math::zoon::zmap; use nockvm::noun::{Noun, NounAllocator, D}; use noun_serde::{NounDecode, NounDecodeError, NounEncode}; +use num_bigint::BigUint; pub use page::{BigNum, BlockId, CoinbaseSplit, Page, PageMsg}; use serde::{Deserialize, Serialize}; @@ -19,7 +19,7 @@ impl SchnorrPubkey { pub const BYTES_BASE58: usize = 132; pub fn to_base58(&self) -> Result { - Ok(CheetahPoint::into_base58(&self.0)?) + CheetahPoint::into_base58(&self.0) } pub fn from_base58(b58: &str) -> Result { @@ -150,15 +150,15 @@ pub struct Hash(pub [Belt; 5]); impl Hash { pub fn to_base58(&self) -> String { fn base_p_to_decimal(belts: [Belt; N]) -> String { - let prime_ubig = UBig::from(PRIME); - let mut result = ubig!(0); + let prime = BigUint::from(PRIME); + let mut result = BigUint::from(0u8); for (i, value) in belts.iter().enumerate() { - result += UBig::from(value.0) * prime_ubig.pow(i); + let pow = prime.pow(i as u32); + result += BigUint::from(value.0) * pow; } - let bytes = result.to_be_bytes(); - bs58::encode(bytes).into_string() + bs58::encode(result.to_bytes_be()).into_string() } base_p_to_decimal(self.0) @@ -166,13 +166,18 @@ impl Hash { pub fn from_base58(s: &str) -> Result { let bytes = bs58::decode(s).into_vec()?; - let mut value = UBig::from_be_bytes(&bytes); + let mut value = BigUint::from_bytes_be(&bytes); + let prime = BigUint::from(PRIME); let mut belts = [Belt(0); 5]; - for i in 0..5 { - belts[i] = Belt((value.clone() % PRIME) as u64); - value /= PRIME; + for belt in &mut belts { + let rem = &value % ′ + let rem_u64: u64 = rem + .try_into() + .map_err(|_| HashDecodeError::ProvidedValueTooLarge)?; + *belt = Belt(rem_u64); + value /= ′ } - if value > *P_BIG { + if value > prime { return Err(HashDecodeError::ProvidedValueTooLarge); } Ok(Hash(belts)) @@ -183,6 +188,21 @@ impl Hash { } } +/// Peek response for the heaviest block ID. +/// Wraps `(unit (unit Hash))` - the Hoon peek response encoding. +#[derive(Debug, Clone, PartialEq, Eq, NounDecode, NounEncode)] +pub struct Heavy(pub Option>>); + +impl Heavy { + /// Convert to Base58 string if the heavy block ID exists. + pub fn to_base58(&self) -> Option { + match &self.0 { + Some(Some(Some(hash))) => Some(hash.to_base58()), + _ => None, + } + } +} + #[derive(NounEncode, NounDecode, Clone, Debug, PartialEq, Eq)] pub struct Name { pub first: Hash, diff --git a/crates/nockchain-types/src/tx_engine/common/page.rs b/crates/nockchain-types/src/tx_engine/common/page.rs index 2f5c934c2..4a6925176 100644 --- a/crates/nockchain-types/src/tx_engine/common/page.rs +++ b/crates/nockchain-types/src/tx_engine/common/page.rs @@ -198,6 +198,8 @@ pub struct Page { impl NounEncode for Page { fn to_noun(&self, allocator: &mut A) -> Noun { + // TODO: should not be hardcoding v1 here, need a better solution + let version = nockvm::noun::D(1); let digest = self.digest.to_noun(allocator); let pow = if let Some(ref pow_data) = self.pow { @@ -220,7 +222,7 @@ impl NounEncode for Page { nockvm::noun::T( allocator, &[ - digest, pow, parent, tx_ids, coinbase, timestamp, epoch_counter, target, + version, digest, pow, parent, tx_ids, coinbase, timestamp, epoch_counter, target, accumulated_work, height, msg, ], ) diff --git a/crates/nockchain-types/src/tx_engine/v0/note.rs b/crates/nockchain-types/src/tx_engine/v0/note.rs index e7862064b..6bc1e34ce 100644 --- a/crates/nockchain-types/src/tx_engine/v0/note.rs +++ b/crates/nockchain-types/src/tx_engine/v0/note.rs @@ -9,8 +9,6 @@ use nockchain_math::zoon::{zmap, zset}; use nockvm::noun::{NounAllocator, D, SIG}; use noun_serde::{NounDecode, NounDecodeError, NounEncode}; -#[cfg(test)] -use crate::tx_engine::common::BlockHeightDelta; use crate::tx_engine::common::{ BlockHeight, Hash, Name, Nicks, SchnorrPubkey, Source, TimelockRangeAbsolute, TimelockRangeRelative, Version, @@ -56,15 +54,14 @@ impl NounEncode for Lock { zset::z_set_put(stack, &map, &mut val, &DefaultTipHasher) .expect("Failed to put public key into set") }); - let lock_noun = nockvm::noun::T(stack, &[m, keys_noun_map]); - lock_noun + nockvm::noun::T(stack, &[m, keys_noun_map]) } } impl NounDecode for Lock { fn from_noun(noun: &Noun) -> Result { let cell = noun.as_cell()?; - let keys_required = cell.head().as_atom()?.as_u64()? as u64; + let keys_required = cell.head().as_atom()?.as_u64()?; // It is called HoonMapIter, but it can be used for sets as well let pubkeys_iter = HoonMapIter::from(cell.tail()); @@ -124,12 +121,12 @@ pub struct Balance(pub Vec<(Name, NoteV0)>); impl NounEncode for Balance { fn to_noun(&self, stack: &mut A) -> Noun { - let keys_noun_map = self.0.iter().fold(D(0), |map, (name, note)| { + self.0.iter().fold(D(0), |map, (name, note)| { let mut key = name.to_noun(stack); let mut value = note.to_noun(stack); - zmap::z_map_put(stack, &map, &mut key, &mut value, &DefaultTipHasher).unwrap() - }); - keys_noun_map + zmap::z_map_put(stack, &map, &mut key, &mut value, &DefaultTipHasher) + .expect("Failed to put into z_map") + }) } } @@ -192,9 +189,9 @@ mod test { fn try_path(jam: &str) -> Result> { let possible_paths = [ std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("jams") + .join("jams/v0") .join(jam), - std::path::Path::new("open/crates/nockchain-types/jams").join(jam), + std::path::Path::new("open/crates/nockchain-types/jams/v0").join(jam), ]; let jam_path = possible_paths @@ -402,7 +399,7 @@ mod test { impl Arbitrary for Name { fn arbitrary(g: &mut Gen) -> Self { - return Name::new(arb_hash(g), arb_hash(g)); + Name::new(arb_hash(g), arb_hash(g)) } } @@ -460,12 +457,13 @@ mod test { } } + #[allow(clippy::clone_on_copy)] impl Arbitrary for Balance { fn arbitrary(g: &mut Gen) -> Self { use std::collections::HashSet; let mut set: HashSet<(Hash, Hash)> = HashSet::new(); let mut items: Vec<(Name, NoteV0)> = Vec::new(); - let len = 1 + (usize::arbitrary(g) % 5) as usize; + let len = 1 + (usize::arbitrary(g) % 5); for _ in 0..len { let name = Name::arbitrary(g); if set.insert((name.first.clone(), name.last.clone())) { diff --git a/crates/nockchain-types/src/tx_engine/v0/tx.rs b/crates/nockchain-types/src/tx_engine/v0/tx.rs index 718771ce0..ed283e5c5 100644 --- a/crates/nockchain-types/src/tx_engine/v0/tx.rs +++ b/crates/nockchain-types/src/tx_engine/v0/tx.rs @@ -68,7 +68,8 @@ impl NounEncode for Inputs { self.0.iter().fold(D(0), |map, (name, input)| { let mut key = name.to_noun(stack); let mut value = input.to_noun(stack); - zmap::z_map_put(stack, &map, &mut key, &mut value, &DefaultTipHasher).unwrap() + zmap::z_map_put(stack, &map, &mut key, &mut value, &DefaultTipHasher) + .expect("Failed to put into z_map") }) } } diff --git a/crates/nockchain-types/src/tx_engine/v1/note.rs b/crates/nockchain-types/src/tx_engine/v1/note.rs index bfc49a963..d78e7982f 100644 --- a/crates/nockchain-types/src/tx_engine/v1/note.rs +++ b/crates/nockchain-types/src/tx_engine/v1/note.rs @@ -24,12 +24,12 @@ pub struct Balance(pub Vec<(Name, Note)>); impl NounEncode for Balance { fn to_noun(&self, stack: &mut A) -> Noun { - let keys_noun_map = self.0.iter().fold(D(0), |map, (name, note)| { + self.0.iter().fold(D(0), |map, (name, note)| { let mut key = name.to_noun(stack); let mut value = note.to_noun(stack); - zmap::z_map_put(stack, &map, &mut key, &mut value, &DefaultTipHasher).unwrap() - }); - keys_noun_map + zmap::z_map_put(stack, &map, &mut key, &mut value, &DefaultTipHasher) + .expect("Failed to put into z_map") + }) } } @@ -67,8 +67,8 @@ pub struct NoteV1 { impl NounEncode for Note { fn to_noun(&self, stack: &mut A) -> Noun { match self { - Note::V0(note) => NoteV0::to_noun(¬e, stack), - Note::V1(note) => NoteV1::to_noun(¬e, stack), + Note::V0(note) => NoteV0::to_noun(note, stack), + Note::V1(note) => NoteV1::to_noun(note, stack), } } } diff --git a/crates/nockchain-types/tests/balance_from_peek_v0.rs b/crates/nockchain-types/tests/balance_from_peek_v0.rs index 157da2df3..b6e050302 100644 --- a/crates/nockchain-types/tests/balance_from_peek_v0.rs +++ b/crates/nockchain-types/tests/balance_from_peek_v0.rs @@ -1,3 +1,5 @@ +#![allow(clippy::unwrap_used)] + use bytes::Bytes; use nockapp::noun::slab::NounSlab; use nockchain_math::belt::Belt; diff --git a/crates/nockchain-types/tests/balance_from_peek_v1.rs b/crates/nockchain-types/tests/balance_from_peek_v1.rs index 157da2df3..b6e050302 100644 --- a/crates/nockchain-types/tests/balance_from_peek_v1.rs +++ b/crates/nockchain-types/tests/balance_from_peek_v1.rs @@ -1,3 +1,5 @@ +#![allow(clippy::unwrap_used)] + use bytes::Bytes; use nockapp::noun::slab::NounSlab; use nockchain_math::belt::Belt; diff --git a/crates/nockchain-wallet/Cargo.toml b/crates/nockchain-wallet/Cargo.toml index 13927536d..0a2c1f59b 100644 --- a/crates/nockchain-wallet/Cargo.toml +++ b/crates/nockchain-wallet/Cargo.toml @@ -2,31 +2,27 @@ name = "nockchain-wallet" version.workspace = true edition.workspace = true +license = "MIT OR Apache-2.0" [dependencies] + +bs58 = { workspace = true } +clap = { workspace = true, features = ["derive", "cargo"] } +crossterm.workspace = true +either.workspace = true +getrandom.workspace = true +http = "1" kernels = { workspace = true, features = ["wallet"] } nockapp = { workspace = true } nockapp-grpc = { workspace = true } -nockchain-types = { workspace = true } nockchain-math = { workspace = true } +nockchain-types = { workspace = true } nockvm = { workspace = true } nockvm_macros = { workspace = true } noun-serde = { workspace = true } +rustls = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } -test-log = { workspace = true } - -bardecoder = { workspace = true } -bs58 = { workspace = true } -clap = { workspace = true, features = ["derive", "cargo"] } -crossterm.workspace = true -either.workspace = true -getrandom.workspace = true -http = "1" -image = { workspace = true } -qrcode = { workspace = true } -ratatui.workspace = true -rustls = { workspace = true } tempfile.workspace = true termimad.workspace = true thiserror.workspace = true diff --git a/crates/nockchain-wallet/README.md b/crates/nockchain-wallet/README.md index c616c4b4c..d6fc81753 100644 --- a/crates/nockchain-wallet/README.md +++ b/crates/nockchain-wallet/README.md @@ -219,11 +219,13 @@ Gifts and fees are denominated in nicks (65536 nicks = 1 nock). ```json {"kind":"p2pkh","address":"","amount":10000} {"kind":"multisig","threshold":2,"addresses":["","",""],"amount":9000} +{"kind":"bridge-deposit","evm-address":"0x0123abcd...","amount":500000000} ``` - `kind` must be either `p2pkh` or `multisig` - `amount` is specified in nicks - Multisig objects also require a `threshold` (m) and at least one `addresses` entry +- Bridge deposits route funds to the Base bridge; `evm-address` expects a 20-byte hex string (40 hex chars, case-insensitive) with or without the `0x` prefix. Only one `%bridge-deposit` output is allowed per transaction, and the bridge enforces a protocol-level minimum gift size. Provide multiple `--recipient` flags to fan out to several recipients in one transaction. @@ -239,6 +241,21 @@ Multisig outputs are expressed via the JSON form. Supply each output as: - `addresses` is the list of base58 payee hashes that define the lock - `amount` is denominated in nicks +### Bridge Deposits + +Send Nockchain assets to Base by targeting the on-chain bridge lock root: + +```bash +nockchain-wallet create-tx \ + --names "[first1 last1]" \ + --recipient '{"kind":"bridge-deposit","evm-address":"0x0c8d9cf278d4f3e23b00ea0a16bba2d05c07a7b6","amount":100000000}' \ + --fee 60000000 +``` + +- The `evm-address` is the Base/ETH recipient; provide exactly 20 bytes of hex (`0x` prefix optional). +- Only a single bridge deposit output is allowed per transaction. +- The bridge kernel enforces a minimum deposit amount; use docs/BRIDGE.md for current values. + ```bash nockchain-wallet create-tx \ --names "[first1 last1],[first2 last2]" \ diff --git a/crates/nockchain-wallet/src/command.rs b/crates/nockchain-wallet/src/command.rs index c6879381d..9767f42d1 100644 --- a/crates/nockchain-wallet/src/command.rs +++ b/crates/nockchain-wallet/src/command.rs @@ -95,11 +95,11 @@ impl TimelockIntentCli { pub fn has_upper_bound(&self) -> bool { self.absolute .as_ref() - .map_or(false, TimelockRangeCli::has_upper_bound) + .is_some_and(TimelockRangeCli::has_upper_bound) || self .relative .as_ref() - .map_or(false, TimelockRangeCli::has_upper_bound) + .is_some_and(TimelockRangeCli::has_upper_bound) } } @@ -192,6 +192,9 @@ pub struct WalletCli { #[command(flatten)] pub boot: BootCli, + #[arg(long, default_value = "false")] + pub fakenet: bool, + #[command(flatten)] pub connection: ConnectionCli, diff --git a/crates/nockchain-wallet/src/connection.rs b/crates/nockchain-wallet/src/connection.rs index 93281c918..dcbe9acf4 100644 --- a/crates/nockchain-wallet/src/connection.rs +++ b/crates/nockchain-wallet/src/connection.rs @@ -86,8 +86,8 @@ impl GrpcEndpoint { )); } - let host = uri.host().unwrap(); - let port = uri.port_u16().unwrap_or_else(|| match scheme_str { + let host = uri.host().expect("URI should have a host"); + let port = uri.port_u16().unwrap_or(match scheme_str { "https" => 443, _ => 80, }); diff --git a/crates/nockchain-wallet/src/main.rs b/crates/nockchain-wallet/src/main.rs index 4260caafc..e486e44ec 100644 --- a/crates/nockchain-wallet/src/main.rs +++ b/crates/nockchain-wallet/src/main.rs @@ -1,4 +1,15 @@ #![allow(clippy::doc_overindented_list_items)] +// Allow architectural patterns that would be disruptive to change +#![allow(clippy::io_other_error)] +#![allow(clippy::redundant_closure)] +#![allow(clippy::unnecessary_fallible_conversions)] +#![allow(clippy::result_large_err)] +#![allow(clippy::empty_line_after_doc_comments)] +#![allow(clippy::unnecessary_lazy_evaluations)] +#![allow(clippy::too_many_arguments)] +#![allow(clippy::unused_enumerate_index)] +#![allow(clippy::option_as_ref_cloned)] +#![cfg_attr(test, allow(clippy::unwrap_used))] mod command; mod connection; @@ -75,7 +86,13 @@ async fn main() -> Result<(), NockAppError> { let mut wallet = Wallet::new(kernel); - // Determine if this command requires chain synchronization + if cli.fakenet { + wallet.set_fakenet().await?; + } else if wallet.is_fakenet().await? { + return Err(NockAppError::OtherError( + "Attempted to boot the wallet in mainnet mode, but the loaded state is in fakenet mode. Please use the --fakenet flag to boot the wallet or boot the wallet with the --new flag to create a new mainnet wallet".to_string(), + )); + } let requires_sync = match &cli.command { // Commands that DON'T need syncing either because they don't sync @@ -370,7 +387,11 @@ async fn main() -> Result<(), NockAppError> { .await?; for poke in pokes { - let _ = wallet.app.poke(SystemWire.to_wire(), poke).await.unwrap(); + let _ = wallet + .app + .poke(SystemWire.to_wire(), poke) + .await + .expect("poke should succeed"); } } @@ -427,6 +448,30 @@ impl Wallet { Wallet { app: nockapp } } + async fn set_fakenet(&mut self) -> Result<(), NockAppError> { + let mut slab: NounSlab = NounSlab::new(); + let tag = String::from("fakenet").to_noun(&mut slab); + slab.modify(|_| vec![tag, SIG]); + let wire = SystemWire.to_wire(); + let _ = self.app.poke(wire, slab).await?; + Ok(()) + } + + async fn is_fakenet(&mut self) -> Result { + let mut slab: NounSlab = NounSlab::new(); + let tag = String::from("fakenet").to_noun(&mut slab); + slab.modify(|_| vec![tag, SIG]); + let result = self.app.peek(slab).await?; + let is_fakenet: Option> = + unsafe { >>::from_noun(result.root())? }; + match is_fakenet { + Some(Some(res)) => Ok(res), + _ => Err(NockAppError::OtherError( + "Unexpected result from is_fakenet".to_string(), + )), + } + } + /// Prepares a wallet command for execution. /// /// # Arguments @@ -524,6 +569,7 @@ impl Wallet { /// /// * `transaction_path` - Path to the transaction file /// * `index` - Optional index of the key to use for signing + #[allow(dead_code)] fn sign_tx( transaction_path: &str, index: Option, @@ -734,6 +780,7 @@ impl Wallet { /// # Arguments /// /// * `first_name` - Base58-encoded first name hash. + #[allow(dead_code)] fn watch_first_name(first_name: &str) -> CommandNoun { let mut slab = NounSlab::new(); let first_name_noun = make_tas(&mut slab, first_name).as_noun(); @@ -1335,6 +1382,7 @@ impl Wallet { ) } + #[allow(dead_code)] fn show_multisig_tx(transaction_path: &str) -> CommandNoun { let mut slab = NounSlab::new(); @@ -1391,7 +1439,7 @@ fn confirm_upper_bound_warning() -> Result<(), NockAppError> { } fn normalize_watch_address(value: String) -> Result, NockAppError> { - if value.as_bytes().len() >= SchnorrPubkey::BYTES_BASE58 { + if value.len() >= SchnorrPubkey::BYTES_BASE58 { match SchnorrPubkey::from_base58(&value) { Ok(pubkey) => pubkey .to_base58() @@ -1416,6 +1464,7 @@ fn normalize_watch_address(value: String) -> Result, NockAppError } } +#[allow(dead_code)] fn normalize_first_name(value: String) -> Result, NockAppError> { match Hash::from_base58(&value) { Ok(hash) => Ok(Some(hash.to_base58())), diff --git a/crates/nockchain-wallet/src/recipient.rs b/crates/nockchain-wallet/src/recipient.rs index bc8b9ba48..11ea5f310 100644 --- a/crates/nockchain-wallet/src/recipient.rs +++ b/crates/nockchain-wallet/src/recipient.rs @@ -1,6 +1,7 @@ use std::collections::BTreeSet; use nockchain_types::common::Hash; +use nockchain_types::{EthAddress, EthAddressParseError}; use noun_serde::{NounDecode, NounEncode}; use serde::Deserialize; @@ -18,6 +19,12 @@ pub enum RecipientSpecToken { addresses: Vec, amount: u64, }, + #[serde(rename = "bridge-deposit")] + BridgeDeposit { + #[serde(rename = "evm-address")] + evm_address: String, + amount: u64, + }, } #[derive(Debug, Clone, NounEncode, NounDecode, PartialEq)] @@ -30,6 +37,11 @@ pub enum RecipientSpec { addresses: Vec, amount: u64, }, + #[noun(tag = "bridge-deposit")] + BridgeDeposit { + evm_address: EthAddress, + amount: u64, + }, } impl RecipientSpecToken { @@ -153,7 +165,40 @@ impl RecipientSpecToken { amount, }) } + RecipientSpecToken::BridgeDeposit { + evm_address, + amount, + } => { + if amount == 0 { + return Err(CrownError::Unknown( + "Recipient amount must be greater than zero".into(), + ) + .into()); + } + let parsed = EthAddress::from_hex_str(&evm_address).map_err(|err| { + NockAppError::from(CrownError::Unknown(format!( + "Invalid EVM address '{}': {}", + evm_address, + format_eth_addr_error(err) + ))) + })?; + Ok(RecipientSpec::BridgeDeposit { + evm_address: parsed, + amount, + }) + } + } + } +} + +fn format_eth_addr_error(err: EthAddressParseError) -> String { + match err { + EthAddressParseError::Empty => "address cannot be empty".into(), + EthAddressParseError::WrongLength(len) => { + format!("expected 40 hex chars (20 bytes), got length {}", len) } + EthAddressParseError::InvalidCharacters => "contains non-hex characters".into(), + EthAddressParseError::InvalidHex(msg) => msg, } } @@ -183,6 +228,7 @@ mod tests { const SAMPLE_P2PKH: &str = "9yPePjfWAdUnzaQKyxcRXKRa5PpUzKKEwtpECBZsUYt9Jd7egSDEWoV"; const SAMPLE_P2PKH_ALT: &str = "9phXGACnW4238oqgvn2gpwaUjG3RAqcxq2Ash2vaKp8KjzSd3MQ56Jt"; + const SAMPLE_EVM_ADDRESS: &str = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; #[test] fn parse_recipient_arg_accepts_json_p2pkh() { @@ -219,6 +265,33 @@ mod tests { )); } + #[test] + fn parse_recipient_arg_accepts_bridge_deposit() { + let raw = format!( + "{{\"kind\":\"bridge-deposit\",\"evm-address\":\"{}\",\"amount\":123456}}", + SAMPLE_EVM_ADDRESS + ); + let token = RecipientSpecToken::from_cli_arg(&raw).expect("bridge deposit parses"); + assert!(matches!( + token, + RecipientSpecToken::BridgeDeposit { amount, .. } if amount == 123456 + )); + } + + #[test] + fn bridge_deposit_rejects_bad_address() { + let raw = "{\"kind\":\"bridge-deposit\",\"evm-address\":\"0xdeadbeef\",\"amount\":10}"; + let token = + RecipientSpecToken::from_cli_arg(raw).expect("json parsing should succeed initially"); + let err = token + .into_recipient_spec() + .expect_err("invalid bridge deposit should fail conversion"); + assert!( + format!("{err}").contains("EVM address"), + "unexpected error: {err}" + ); + } + #[test] fn parse_recipient_arg_rejects_empty() { let err = RecipientSpecToken::from_cli_arg(" ").expect_err("empty spec should fail"); @@ -237,9 +310,13 @@ mod tests { addresses: vec![SAMPLE_P2PKH_ALT.to_string(), SAMPLE_P2PKH.to_string()], amount: 5, }, + RecipientSpecToken::BridgeDeposit { + evm_address: SAMPLE_EVM_ADDRESS.to_string(), + amount: 9, + }, ]; let specs = recipient_tokens_to_specs(tokens).expect("tokens -> specs"); - assert_eq!(specs.len(), 2); + assert_eq!(specs.len(), 3); match &specs[0] { RecipientSpec::P2pkh { address, amount } => { assert_eq!(*amount, 1000); @@ -270,6 +347,20 @@ mod tests { } _ => panic!("second spec should be multisig"), } + match &specs[2] { + RecipientSpec::BridgeDeposit { + evm_address, + amount, + .. + } => { + assert_eq!(*amount, 9); + assert_eq!( + evm_address, + &EthAddress::from_hex_str(SAMPLE_EVM_ADDRESS).expect("sample evm address") + ); + } + _ => panic!("third spec should be bridge deposit"), + } } #[test] @@ -293,6 +384,11 @@ mod tests { ], amount: 20, }, + RecipientSpec::BridgeDeposit { + evm_address: EthAddress::from_hex_str(SAMPLE_EVM_ADDRESS) + .expect("sample evm address"), + amount: 30, + }, ]; let mut slab = NounSlab::::new(); diff --git a/crates/nockchain/Cargo.toml b/crates/nockchain/Cargo.toml index 471d6df90..8d8a5b356 100644 --- a/crates/nockchain/Cargo.toml +++ b/crates/nockchain/Cargo.toml @@ -11,22 +11,13 @@ jemalloc = ["tikv-jemallocator"] tracing-heap = ["tikv-jemallocator", "tracy-client"] [dependencies] -hoonc.workspace = true -kernels = { workspace = true, features = ["dumb", "miner"] } -nockapp.workspace = true -nockapp-grpc.workspace = true -nockvm.workspace = true -nockvm_macros.workspace = true -noun-serde.workspace = true -nockchain-types.workspace = true -nockchain-math.workspace = true -bitcoincore-rpc.workspace = true bs58.workspace = true clap.workspace = true equix.workspace = true futures.workspace = true ibig.workspace = true +kernels = { workspace = true, features = ["dumb", "miner"] } libp2p = { workspace = true, features = [ "ping", "kad", @@ -39,7 +30,14 @@ libp2p = { workspace = true, features = [ "request-response", "cbor", ] } +nockapp.workspace = true +nockapp-grpc.workspace = true nockchain-libp2p-io.workspace = true +nockchain-math.workspace = true +nockchain-types.workspace = true +nockvm.workspace = true +nockvm_macros.workspace = true +noun-serde.workspace = true num_cpus = { workspace = true } rand = { workspace = true } tempfile = { workspace = true } @@ -47,17 +45,6 @@ termcolor.workspace = true tikv-jemallocator = { workspace = true, optional = true } tokio = { workspace = true, features = ["full"] } tracing.workspace = true -tracing-test.workspace = true tracy-client = { workspace = true, optional = true } zkvm-jetpack.workspace = true - -[build-dependencies] -vergen = { workspace = true, features = [ - "build", - "cargo", - "git", - "gitcl", - "rustc", - "si", -] } diff --git a/crates/nockchain/src/config.rs b/crates/nockchain/src/config.rs index f56e72d36..f5993eb96 100644 --- a/crates/nockchain/src/config.rs +++ b/crates/nockchain/src/config.rs @@ -1,10 +1,10 @@ use std::path::PathBuf; use std::time::Duration; -use clap::{arg, command, value_parser, ArgAction, Parser}; -use nockchain_types::tx_engine::common::{Hash, SchnorrPubkey}; +use clap::{value_parser, ArgAction, Parser}; +use nockchain_types::tx_engine::common::Hash; -use crate::mining::{MiningKeyConfig, MiningPkhConfig}; +use crate::mining::MiningPkhConfig; // TODO: command-line/configure /** Path to read current node's identity from */ @@ -110,13 +110,6 @@ pub struct NockchainCli { requires = "fakenet" )] pub fakenet_log_difficulty: u64, - #[arg( - long, - help = "Minimum timelock for coinbase transactions on fakenet. Defaults to 100 blocks. Ignored on mainnet.", - default_value = "100", - requires = "fakenet" - )] - pub fakenet_coinbase_timelock_min: Option, #[arg( long, help = "Override the v1-phase activation height when running on fakenet. Requires --fakenet.", @@ -200,7 +193,6 @@ mod tests { fakenet_log_difficulty: 1, fakenet_v1_phase: None, fakenet_genesis_jam_path: None, - fakenet_coinbase_timelock_min: None, bind_public_grpc_addr: Some("127.0.0.1:5555".parse().unwrap()), bind_private_grpc_port: 5555, fast_sync: false, diff --git a/crates/nockchain/src/lib.rs b/crates/nockchain/src/lib.rs index 56f7054f7..40637a677 100644 --- a/crates/nockchain/src/lib.rs +++ b/crates/nockchain/src/lib.rs @@ -1,3 +1,13 @@ +// Allow architectural patterns +#![allow(clippy::result_large_err)] +#![allow(clippy::collapsible_else_if)] +#![allow(clippy::let_underscore_future)] +#![allow(clippy::manual_map)] +#![allow(clippy::redundant_field_names)] +#![allow(clippy::new_without_default)] +#![allow(clippy::vec_init_then_push)] +#![cfg_attr(test, allow(clippy::unwrap_used))] + pub mod config; pub mod mining; pub mod setup; @@ -392,9 +402,6 @@ pub async fn init_with_kernel( // Set the require fakenet constants first, then handle the optional ones let mut fakenet_constants = fakenet_blockchain_constants(cli.fakenet_pow_len, cli.fakenet_log_difficulty); - if let Some(coinbase_timelock_min) = cli.fakenet_coinbase_timelock_min { - fakenet_constants = fakenet_constants.with_coinbase_timelock_min(coinbase_timelock_min); - } if let Some(v1_phase) = cli.fakenet_v1_phase { fakenet_constants = fakenet_constants.with_v1_phase(v1_phase); } diff --git a/crates/nockchain/src/mining.rs b/crates/nockchain/src/mining.rs index 886e02bca..33cbe33de 100644 --- a/crates/nockchain/src/mining.rs +++ b/crates/nockchain/src/mining.rs @@ -111,7 +111,7 @@ struct MiningData { } pub fn create_mining_driver( - mining_config: Option>, + _mining_config: Option>, mining_pkh_config: Option>, mine: bool, num_threads: u64, @@ -416,13 +416,13 @@ async fn start_mining_attempt( id: u64, ) { let nonce = nonce.unwrap_or_else(|| { - let mut rng = rand::thread_rng(); + let mut rng = rand::rng(); let mut nonce_slab = NounSlab::new(); - let mut nonce_cell = Atom::from_value(&mut nonce_slab, rng.gen::() % PRIME) + let mut nonce_cell = Atom::from_value(&mut nonce_slab, rng.random::() % PRIME) .expect("Failed to create nonce atom") .as_noun(); for _ in 1..5 { - let nonce_atom = Atom::from_value(&mut nonce_slab, rng.gen::() % PRIME) + let nonce_atom = Atom::from_value(&mut nonce_slab, rng.random::() % PRIME) .expect("Failed to create nonce atom") .as_noun(); nonce_cell = T(&mut nonce_slab, &[nonce_atom, nonce_cell]); diff --git a/crates/nockchain/src/setup.rs b/crates/nockchain/src/setup.rs index 800874249..620fc4ccf 100644 --- a/crates/nockchain/src/setup.rs +++ b/crates/nockchain/src/setup.rs @@ -40,6 +40,7 @@ pub fn fakenet_blockchain_constants(pow_len: u64, target_bex: u64) -> Blockchain .with_pow_len(pow_len) .with_genesis_target_atom_bex(target_bex as u128) .with_first_month_coinbase_min(0) + .with_coinbase_timelock_min(1) } pub async fn poke( diff --git a/crates/nockup/Cargo.toml b/crates/nockup/Cargo.toml index 2d3f5169f..6a26a2f56 100644 --- a/crates/nockup/Cargo.toml +++ b/crates/nockup/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nockup" -version = "0.4.0" +version = "0.5.0" edition = "2021" authors = [""] description = "A developer support framework for NockApp development" @@ -12,53 +12,47 @@ readme = "README.md" keywords = ["nockchain", "nockapp", "development", "cli"] categories = ["command-line-utilities", "development-tools"] +[package.metadata.docs.rs] +all-features = true +rustdoc-args = ["--cfg", "docsrs"] + [[bin]] name = "nockup" path = "src/main.rs" +[features] +vendored-openssl = ["openssl-sys", "openssl-sys/vendored"] + [dependencies] anyhow = { workspace = true } blake3 = { workspace = true } -chrono = { version = "0.4", features = ["serde"] } -clap = { workspace = true } -colored = "2.0" +chrono = { workspace = true, features = ["serde"] } +clap = { workspace = true, features = ["derive"] } +colored = { workspace = true } dirs = { workspace = true } -flate2 = "1.1.5" -fs_extra = "1.3" -handlebars = "6.3.2" +flate2 = { workspace = true } +handlebars = { workspace = true } hex = { workspace = true } +once_cell = "1.20" openssl-sys = { version = "0.9", optional = true } proptest = "1.9.0" reqwest = { workspace = true } +semver = "1.0" serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } sha1 = { workspace = true } -tar = "0.4.44" +tar = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["rt-multi-thread", "macros", "fs", "process"] } -toml = "0.9.8" -walkdir = "2.5.0" -which = "8.0" +toml = { workspace = true } +which = { workspace = true } [build-dependencies] # For generating version info at build time -chrono = { version = "0.4", features = ["serde"] } +chrono = { workspace = true, features = ["serde"] } [dev-dependencies] # Testing utilities -assert_cmd = "2.0" -predicates = "3.0" -tempfile = "3.0" - -# Performance profiling (optional) -[profile.release] -lto = true -codegen-units = 1 -panic = "abort" - -[package.metadata.docs.rs] -all-features = true -rustdoc-args = ["--cfg", "docsrs"] - -[features] -vendored-openssl = ["openssl-sys", "openssl-sys/vendored"] \ No newline at end of file +assert_cmd = { workspace = true } +predicates = { workspace = true } +tempfile = { workspace = true } diff --git a/crates/nockup/README.md b/crates/nockup/README.md index 3aedacd09..df2c636de 100644 --- a/crates/nockup/README.md +++ b/crates/nockup/README.md @@ -64,13 +64,7 @@ Prerequisites: Rust toolchain, Git 4. Install `nockup` and dependencies. ```sh - nockup install - ``` - -5. Check for updates. - - ```sh - $ nockup update + nockup update ``` ### On Replit @@ -85,33 +79,51 @@ Nockup provides a command-line interface for managing NockApp projects. It uses # Show basic program information. (On some systems, like Docker # containers, the hoon and hoonc binaries are not identified.) $ nockup -nockup version 0.0.1 +nockup version 0.5.0 hoon version 0.1.0 hoonc version 0.2.0 current channel stable -current architecture aarch64 +current architecture aarch64-apple-darwin # Start the nockup environment. -$ nockup install -🚀 Setting up nockup cache directory... +$ nockup update +🔄 Updating nockup... 📁 Cache location: /Users/myuser/.nockup -📁 Creating cache directory structure... -✓ Created directory structure -⬇️ Downloading templates from GitHub... -Cloning into '/Users/myuser/.nockup/temp_repo'... -remote: Enumerating objects: 36, done. -remote: Counting objects: 100% (36/36), done. -remote: Compressing objects: 100% (30/30), done. -remote: Total 36 (delta 1), reused 18 (delta 0), pack-reused 0 (from 0) -Receiving objects: 100% (36/36), 45.18 KiB | 1.56 MiB/s, done. -Resolving deltas: 100% (1/1), done. -✓ Templates downloaded successfully -✅ Setup complete! -📂 Templates are now available in: /Users/myuser/.nockup/templates +⬇️ Downloading templates from GitHub... +✓ Templates and manifests downloaded successfully +🔄 Existing toolchain files found, updating... +⬇️ Fetching latest channel manifests from GitHub releases... +🔍 Fetching manifest for stable... +⬇️ Downloading from: https://github.com/nockchain/nockchain/releases/download/build-a19ad4dc66c81ec4d97134dd11a7425bc88b4d7b/nockchain-manifest.toml +✅ Downloaded: channel-nockup-stable.toml +✅ Toolchain files setup complete +⬇️ Downloading binaries for channel 'channel-nockup-stable' and architecture 'aarch64-apple-darwin'... +⬇️ Downloading hoon binary... +✅ Blake3 checksum passed. +✅ SHA1 checksum passed. +📦 Extracting hoon and signature from archive... +✅ Extracted /var/folders/6_/nx76z6bs66q9y177y1ggfs100000gn/T/nockup_extract_hoon/hoon +⚠️ Skipping signature verification on macos (not yet supported) +✅ Installed hoon to /Users/myuser/.nockup/bin/hoon +⬇️ Downloading hoonc binary... +✅ Blake3 checksum passed. +✅ SHA1 checksum passed. +📦 Extracting hoonc and signature from archive... +✅ Extracted /var/folders/6_/nx76z6bs66q9y177y1ggfs100000gn/T/nockup_extract_hoonc/hoonc +⚠️ Skipping signature verification on macos (not yet supported) +✅ Installed hoonc to /Users/myuser/.nockup/bin/hoonc +⬇️ Downloading nockup binary... +✅ Blake3 checksum passed. +✅ SHA1 checksum passed. +📦 Extracting nockup and signature from archive... +✅ Extracted /var/folders/6_/nx76z6bs66q9y177y1ggfs100000gn/T/nockup_extract_nockup/nockup +⚠️ Skipping signature verification on macos (not yet supported) +✅ Installed nockup to /Users/myuser/.nockup/bin/nockup +✅ Update complete! # Initialize a default project. -$ cp ~/.nockup/manifests/example-manifest.toml arcadia.toml -$ nockup init arcadia +$ cp ~/.nockup/manifests/example-nockapp.toml nockapp.toml +$ nockup project init Initializing new NockApp project 'arcadia'... create Cargo.toml create manifest.toml @@ -132,8 +144,8 @@ Initializing new NockApp project 'arcadia'... ✓ All libraries processed successfully! ✓ New project created in ./arcadia// To get started: - nockup build arcadia - nockup run arcadia + nockup project build + nockup project run # Show project settings. $ cd arcadia @@ -142,8 +154,8 @@ Cargo.lock Cargo.toml hoon manifest.toml README.md src $ cd .. -# Build the project (wraps hoonc). -$ nockup build arcadia +# Build the project (wraps hoonc and uses local nockapp.toml). +$ nockup project build 🔨 Building project 'arcadia'... Updating crates.io index Updating git repository `https://github.com/nockchain/nockchain.git` @@ -158,7 +170,7 @@ no panic! ✓ Hoon compilation completed successfully! # Run the project (wraps hoon). -$ nockup run arcadia +$ nockup project run 🔨 Running project 'arcadia'... Finished `release` profile [optimized] target(s) in 0.31s Running `target/release/arcadia` @@ -170,7 +182,7 @@ I (11:53:15) Pokes awaiting implementation ✓ Run completed successfully! ``` -The final product is, of course, a binary which you may run either via `nockup run` (as demonstrated here) or directly (from `./target/release`). +The final product is, of course, a binary which you may run either via `nockup project run` (as demonstrated here) or directly (from `./target/release`). ### Project Templates and Manifests @@ -201,21 +213,20 @@ A project is specified by its manifest file, which includes details like the pro #### Manifests -A project manifest is a file containing sufficient information to produce a basic NockApp from a template with specified imports. +A project manifest is a file containing sufficient information to produce a basic NockApp from a template with specified imports. Only one `nockapp.toml` should be present in a project directory, and it will result in a NockApp build directory with the package `name`. ```toml -[project] -name = "Et In Arcadia Ego" -project_name = "arcadia" -version = "1.0.0" -description = "I too was in Arcadia." -author_name = "Nicolas Poussin" -author_email = "nicolas@poussin.edu" -github_username = "arcadia" +[package] +name = "arcadia" +version = "0.1.0" +description = "My famous game." +authors = ["sigilante"] license = "MIT" -keywords = ["nockapp", "nockchain", "hoon"] -nockapp_commit_hash = "336f744b6b83448ec2b86473a3dec29b15858999" template = "basic" + +[dependencies] +"urbit/bits" = "latest" +"nockchain/zose" = "latest" ``` Manifests let you set several project parameters and specify the template to use. This information will also be used to populate a README file. (By default we supply the [MIT License](https://opensource.org/licenses/MIT) and we specify the version as [0.1.0](https://0ver.org/).) @@ -251,24 +262,24 @@ path = "src/bin/main2.rs" Nockup is opinionated here, and will match `hoon/app/main1.hoon`, etc., as kernels; that is, ```sh -nockup build myproject +nockup project build ``` will produce both `target/release/main1` and `target/release/main2`. -Projects which produce more than one binary cannot be used directly with `nockup run` since more than one process must be started. This should be kept in mind when using templates which produce more than one binary (like `grpc`). +Projects which produce more than one binary cannot be used directly with `nockup project run` since more than one process must be started. This should be kept in mind when using templates which produce more than one binary (like `grpc`). #### Nockchain Interactions A Nockchain must be running locally in order to obtain chain state data. -For instance, with a NockApp based on the template `chain`, you need to connect to a running NockApp instance at port 5555: + ### Libraries @@ -284,18 +295,16 @@ Examples of each are provided in [`example-manifest-with-libraries.toml`](https: #### Single Libraries -A single file may be plucked out of context from a public repo for inclusion. +A single file may be plucked out of context from a public repo for inclusion. If it is aliased in the [Typhoon registry](https://github.com/sigilante/typhoon), you may specify it by its registry name and version: - [`urbit/urbit`: `bits.hoon`](https://github.com/urbit/urbit/blob/develop/pkg/arvo/lib/bits.hoon) bitwise aliases for Hoon stdlib ```toml -[libraries.bits] -url = "https://github.com/urbit/urbit" -branch = "develop" -file = "pkg/arvo/lib/bits.hoon" +[dependencies] +"urbit/bits" = "latest" ``` -This supplies `bits.hoon` at `/hoon/lib/bits.hoon`. (The developer is responsible for managing dependencies such as `/sur` structure files.) +This supplies `bits.hoon` at `/hoon/lib/bits.hoon`. Registry entries track dependencies automatically. #### Top-Level Libraries @@ -305,16 +314,22 @@ Sequent is a good example of the simplest possible structure: - [`jackfoxy/sequent`](https://github.com/jackfoxy/sequent) list functions -This is imported via the `configuration.toml` manifest: +This is imported via the `nockapp.toml` manifest: ```toml -[libraries.sequent] -url = "https://github.com/jackfoxy/sequent" -commit = "0f6e6777482447d4464948896b763c080dc9e559" +[dependencies.sequent] +git = "https://github.com/jackfoxy/sequent" +commit = "7fc95fd4d6df7548cf354c9c91df2980e902770d" +# Specify the subdirectory within the repository (which will be omitted) +path = "desk" +# Keep the part of the path you need to preserve +files = ["lib/seq"] ``` which supplies `/desk/lib/seq.hoon` at `/hoon/lib/seq.hoon` and ignores `/mar` and `/tests` (which are both Urbit-specific affordances). +For libraries not included in the registry, the developer is responsible for managing dependencies such as `/sur` structure files explicitly. + Other Hoon libraries of note include: - [`lynko/re.hoon`](https://github.com/lynko/re.hoon) @@ -326,18 +341,19 @@ A more complex structure features top-level nesting before the Hoon source libra - [`urbit/numerics`](https://github.com/urbit/numerics) -```toml -[libraries.math] -url = "https://github.com/urbit/numerics" -branch = "main" -directory = "libmath" -commit = "7c11c48ab3f21135caa5a4e8744a9c3f828f2607" +A complete library may be imported by omitting the `files` key: -[libraries.lagoon] -url = "https://github.com/urbit/numerics" -branch = "main" -directory = "lagoon" +```toml +[dependencies.lagoon] +git = "https://github.com/urbit/numerics" +commit = "01905f364178958bb2d0c1a7ce009b6f3e68f737" +# Specify only the subdirectory within the repository; if no files, all will be included. +path = "lagoon/desk" + +[dependencies.math] +git = "https://github.com/urbit/numerics" commit = "7c11c48ab3f21135caa5a4e8744a9c3f828f2607" +path = "libmath/desk" ``` which supplies these files (among others) in the following pattern: @@ -379,21 +395,46 @@ Nockup supports the following `nockup` commands. ### Operations -- `nockup install`: Initialize Nockup cache and download binaries and templates. -- `nockup update`: Check for updates to binaries and templates. +- `nockup`: Print version information for Nockup and installed binaries. +- `nockup update`: Initialize and check for updates to binaries and templates. - `nockup help`: Print this message or the help of the given subcommand(s). ### Project -- `nockup init`: Initialize a new NockApp project from a `.toml` config file. +- `nockup build init`: Initialize a new NockApp project from a `.toml` config file. - `nockup build`: Build a NockApp project using Cargo. -- `nockup run`: Run a NockApp project. +- `nockup build run`: Run a NockApp project. -### channel +### Channels - `nockup channel show`: Show currently active channel. - `nockup channel set`: Set the active channel, from `stable` and `nightly`. (Most users will prefer `stable`.) +### Packages + +- `nockup package install`: Install Hoon libraries specified in a project manifest. +- `nockup package list`: List installed Hoon libraries in a project. +- `nockup package add`: Add a Hoon library to a project manifest at a particular version. +- `nockup package remove`: Remove an installed Hoon library from a project. +- `nockup package purge`: Clear the package cache. +- `nockup package purge --dry-run`: Preview what would be deleted. + +## Registry + +Nockup supports publishing and consuming Hoon libraries via a registry. A registry is a Git repository which contains a `registry.toml` file listing available packages. The standard registry is currently hosted at [Typhoon, `sigilante/typhoon`](https://github.com/sigilante/typhoon). + +To generate a registry file for your Hoon library, you may use the `scan-deps-v2.py` script included in the Nockup repository. This script scans a directory for Hoon files and their dependencies, then produces a registry-compatible TOML segment. + +```sh +python3 scan-deps-v2.py \ + --workspace nockchain \ + --root-path "hoon" \ + --git-url "https://github.com/nockchain/nockchain" \ + --ref "a19ad4dc" \ + --description "Nockchain standard library" \ + /path/to/nockchain/hoon/common +``` + ## Security *Nockup is entirely experimental and many parts are unaudited. We make no representations or guarantees as to the behavior of this software.* @@ -430,6 +471,9 @@ Code building is a general-purpose computing process, like `eval`. You should n ### Release Roadmap +* [x] support registry and dependency management +* [ ] support multiple files per library (limited to one now) +* [ ] detect dependencies in non-registry libraries * [ ] Replit instance (needs better memory swap management) * [ ] add Apple code signing support * [x] update manifest files (and install/update strings) to `nockchain/nockchain` @@ -441,6 +485,12 @@ Code building is a general-purpose computing process, like `eval`. You should n * expand repertoire of templates * list and ship appropriate Hoon libraries * `nockup publish`/`nockup clone` (awaiting PKI/namespace) +* The current code handles a single `specific_file`. To support multiple files, the changes needed are: + 1. Change GitSpec struct to store files: `Option>` instead of file: `Option` + 2. Change ResolvedPackage struct to store source_files: `Option>` instead of source_file: `Option` + 3. Update resolver to pass all files from the manifest to `GitSpec` + 4. Update installer to loop through all files and create symlinks for each + The key change is in the install logic - instead of handling specific_file: `Option<&str>`, it needs to handle specific_files: `Option<&[String]>` and loop through them. ## Contributor's Guide diff --git a/crates/nockup/build.rs b/crates/nockup/build.rs index 5d041a4d0..a7a6ff470 100644 --- a/crates/nockup/build.rs +++ b/crates/nockup/build.rs @@ -25,7 +25,7 @@ fn main() { // Create version string like your example: "0.1.0" let version = env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.1.0".to_string()); - let full_version = format!("{}", version); + let full_version = version.to_string(); println!("cargo:rustc-env=FULL_VERSION={}", full_version); // Tell cargo to re-run if git state changes @@ -35,7 +35,7 @@ fn main() { fn get_git_hash() -> Option { let output = Command::new("git") - .args(&["rev-parse", "--short", "HEAD"]) + .args(["rev-parse", "--short", "HEAD"]) .output() .ok()?; diff --git a/crates/nockup/install-replit.sh b/crates/nockup/install-replit.sh index 9e7944995..6d05062e7 100644 --- a/crates/nockup/install-replit.sh +++ b/crates/nockup/install-replit.sh @@ -410,13 +410,13 @@ main() { # Set channel and run install print_step "Setting channel to $CHANNEL and running installation" - if "$nockup_path" channel set "$CHANNEL" && "$nockup_path" install; then + if "$nockup_path" channel set "$CHANNEL" && "$nockup_path" update; then print_success "Nockup installation completed successfully!" else print_error "Installation failed" print_info "You can try running manually:" print_info " $nockup_path channel set $CHANNEL" - print_info " $nockup_path install" + print_info " $nockup_path update" exit 1 fi @@ -429,9 +429,9 @@ main() { echo "" print_info "🚀 Next steps:" print_info " 1. Verify installation: nockup --help" - print_info " 2. Create a project: cp example-manifest.toml my-project.toml" - print_info " 3. Initialize project: nockup start my-project.toml" - print_info " 4. Build and run: nockup build my-project && nockup run my-project" + print_info " 2. Create a nockapp.toml manifest for your project" + print_info " 3. Initialize project: nockup project init" + print_info " 4. Build and run: nockup project build && nockup project run" echo "" print_info "📁 Installation directory: $install_dir" print_info "📄 Configuration file: $HOME/.nockup/config.toml" diff --git a/crates/nockup/install.sh b/crates/nockup/install.sh index 2ed6495f8..87ab2cdf0 100644 --- a/crates/nockup/install.sh +++ b/crates/nockup/install.sh @@ -482,13 +482,13 @@ main() { add_to_path "$install_dir" print_step "Setting channel to $CHANNEL and running installation" - if "$nockup_path" channel set "$CHANNEL" && "$nockup_path" install; then + if "$nockup_path" channel set "$CHANNEL" && "$nockup_path" update; then print_success "Nockup installation completed successfully!" else print_error "Installation failed" print_info "You can try running manually:" print_info " $nockup_path channel set $CHANNEL" - print_info " $nockup_path install" + print_info " $nockup_path update" exit 1 fi @@ -507,7 +507,7 @@ main() { print_info "Next steps:" print_info " 1. Run the export command above, OR restart your shell" print_info " 2. Verify installation: nockup --help" - print_info " 3. Create a project: nockup start " + print_info " 3. Create a project: nockup project init" echo "" >&2 } diff --git a/crates/nockup/manifests/example-manifest-with-libraries.toml b/crates/nockup/manifests/example-manifest-with-libraries.toml deleted file mode 100644 index 1c756ce5e..000000000 --- a/crates/nockup/manifests/example-manifest-with-libraries.toml +++ /dev/null @@ -1,31 +0,0 @@ -[project] -name = "Et In Arcadia Ego" -project_name = "arcadia" -version = "1.0.0" -description = "I too was in Arcadia." -author_name = "Nicolas Poussin" -author_email = "nicolas@poussin.edu" -github_username = "arcadia" -license = "MIT" -keywords = ["nockapp", "nockchain", "hoon"] -nockapp_commit_hash = "336f744b6b83448ec2b86473a3dec29b15858999" -template = "basic" - -[libraries.sequent] -url = "https://github.com/jackfoxy/sequent" -commit = "0f6e6777482447d4464948896b763c080dc9e559" - -[libraries.math] -url = "https://github.com/urbit/numerics" -branch = "main" -directory = "libmath" - -[libraries.lagoon] -url = "https://github.com/urbit/numerics" -directory = "lagoon" -commit = "7c11c48ab3f21135caa5a4e8744a9c3f828f2607" - -[libraries.bits] -url = "https://github.com/urbit/urbit" -branch = "develop" -file = "pkg/arvo/lib/bits.hoon" diff --git a/crates/nockup/manifests/example-manifest.toml b/crates/nockup/manifests/example-manifest.toml deleted file mode 100644 index 2e3be94a3..000000000 --- a/crates/nockup/manifests/example-manifest.toml +++ /dev/null @@ -1,12 +0,0 @@ -[project] -name = "Et In Arcadia Ego" -project_name = "arcadia" -version = "1.0.0" -description = "I too was in Arcadia." -author_name = "Nicolas Poussin" -author_email = "nicolas@poussin.edu" -github_username = "arcadia" -license = "MIT" -keywords = ["nockapp", "nockchain", "hoon"] -nockapp_commit_hash = "336f744b6b83448ec2b86473a3dec29b15858999" -template = "basic" diff --git a/crates/nockup/manifests/example-nockapp.toml b/crates/nockup/manifests/example-nockapp.toml new file mode 100644 index 000000000..d79361c8e --- /dev/null +++ b/crates/nockup/manifests/example-nockapp.toml @@ -0,0 +1,14 @@ +[package] +name = "arcadia" +version = "0.1.0" +authors = ["sigilante"] +description = "My famous game." +license = "MIT" + +template = "http-server" +template-commit = "a19ad4dc66c81ec4d97134dd11a7425bc88b4d7b" + +[dependencies] +zuse = "@k409" +lagoon = { git = "https://github.com/urbit/numerics", path = "lagoon", commit = "01905f364178958bb2d0c1a7ce009b6f3e68f737" } +sequent = { git = "https://github.com/jackfoxy/sequent", commit = "7fc95fd4d6df7548cf354c9c91df2980e902770d" } diff --git a/crates/nockup/quick_start.sh b/crates/nockup/quick_start.sh deleted file mode 100644 index 1f7f902a1..000000000 --- a/crates/nockup/quick_start.sh +++ /dev/null @@ -1,527 +0,0 @@ -#!/bin/bash - -set -euo pipefail - -# Colors for output -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -CYAN='\033[0;36m' -NC='\033[0m' # No Color - -# Global variables for project configuration -PROJECT_NAME="" -PROJECT_TEMPLATE="" -PROJECT_AUTHOR_NAME="" -PROJECT_AUTHOR_EMAIL="" -PROJECT_GITHUB_USERNAME="" -PROJECT_DESCRIPTION="" -DRY_RUN=false -NON_INTERACTIVE=false - -# Function to print colored output -print_info() { - echo -e "${BLUE}ℹ️ $1${NC}" -} - -print_success() { - echo -e "${GREEN}✅ $1${NC}" -} - -print_warning() { - echo -e "${YELLOW}⚠️ $1${NC}" -} - -print_error() { - echo -e "${RED}❌ $1${NC}" -} - -print_step() { - echo -e "${CYAN}🚀 $1${NC}" -} - -# Function to show usage -show_usage() { - cat << EOF -Usage: $(basename "$0") [OPTIONS] - -NockApp Quick Start Helper - Create a new NockApp project interactively - -Options: - -h, --help Show this help message - -n, --name NAME Project name (skips interactive prompt) - -t, --template TEMPLATE Template to use (basic|grpc|http-static|http-server|repl|chain|rollup) - -a, --author NAME Author name - -e, --email EMAIL Author email - -g, --github USERNAME GitHub username - -d, --description DESC Project description - --non-interactive Run without prompts (requires all options) - --dry-run Show what would be created without actually creating it - -Examples: - $(basename "$0") # Interactive mode - $(basename "$0") --name my-app --template basic # Partial interactive - $(basename "$0") -n my-app -t basic -a "John Doe" -e john@example.com -g johndoe -d "My app" --non-interactive - -Templates: - basic - Simple NockApp (recommended for beginners) - grpc - gRPC server and client - http-static - Static file server - http-server - Dynamic web application - repl - Interactive command line - chain - Nockchain integration (light client) - rollup - Rollup bundler - -EOF -} - -# Function to check if nockup is available -check_nockup() { - # Add nockup to PATH if not already there - export PATH="$HOME/.nockup/bin:$PATH" - - if ! command -v nockup >/dev/null 2>&1; then - print_error "Nockup not found. Please run the installer first:" - print_info " curl -fsSL https://raw.githubusercontent.com/nockchain/nockchain/master/crates/nockup/install.sh | bash" - exit 1 - fi - - print_success "Nockup found: $(which nockup)" -} - -# Function to get user input with default -get_input() { - local prompt="$1" - local default="$2" - local result - - if [[ -n "$default" ]]; then - read -p "$prompt (default: $default): " result - echo "${result:-$default}" - else - read -p "$prompt: " result - echo "$result" - fi -} - -# Function to sanitize input (remove potentially dangerous characters) -sanitize_input() { - local input="$1" - # Remove newlines, carriage returns, and other control characters - echo "$input" | tr -d '\n\r\t' | sed 's/[`$]//g' -} - -# Function to validate project name -validate_project_name() { - local name="$1" - - # Check if name is empty - if [[ -z "$name" ]]; then - echo "Project name cannot be empty" - return 1 - fi - - # Check if name contains invalid characters - if [[ ! "$name" =~ ^[a-zA-Z0-9_-]+$ ]]; then - echo "Project name can only contain letters, numbers, hyphens, and underscores" - return 1 - fi - - # Check if directory already exists - if [[ -d "$name" ]]; then - echo "Directory '$name' already exists" - return 1 - fi - - return 0 -} - -# Function to validate template -validate_template() { - local template="$1" - local valid_templates="basic grpc http-static http-server repl chain rollup" - - for valid in $valid_templates; do - if [[ "$template" == "$valid" ]]; then - return 0 - fi - done - - echo "Invalid template: $template" - echo "Valid templates: $valid_templates" - return 1 -} - -# Function to show template information -show_template_info() { - echo "" - print_info "Available NockApp templates:" - echo "" - echo " 1. basic - Simple NockApp (recommended for beginners)" - echo " Single process, minimal setup" - echo "" - echo " 2. grpc - gRPC server and client" - echo " May require multiple processes" - echo "" - echo " 3. http-static - Static file server" - echo " Serve HTML, CSS, JS files" - echo "" - echo " 4. http-server - Dynamic web application" - echo " Full web server with routing" - echo "" - echo " 5. repl - Interactive command line" - echo " Read-eval-print loop interface" - echo "" - echo " 6. chain - Nockchain integration" - echo " Connect to Nockchain network (light client)" - echo "" - echo " 7. rollup - Rollup bundler" - echo " Package NockApps for deployment" - echo "" -} - -# Function to get template choice -get_template() { - local choice - local template - - show_template_info - - while true; do - choice=$(get_input "Choose template (1-7)" "1") - - case $choice in - 1|basic) template="basic"; break ;; - 2|grpc) template="grpc"; break ;; - 3|http-static) template="http-static"; break ;; - 4|http-server) template="http-server"; break ;; - 5|repl) template="repl"; break ;; - 6|chain) template="chain"; break ;; - 7|rollup) template="rollup"; break ;; - *) - print_warning "Invalid choice. Please enter 1-7 or template name." - continue - ;; - esac - done - - echo "$template" -} - -# Function to get project details interactively -get_project_details_interactive() { - print_step "Let's create your NockApp project!" - echo "" - - # Get project name (if not already set) - if [[ -z "$PROJECT_NAME" ]]; then - while true; do - PROJECT_NAME=$(get_input "Enter your project name" "my-nockapp") - PROJECT_NAME=$(sanitize_input "$PROJECT_NAME") - - if validate_project_name "$PROJECT_NAME"; then - break - else - print_warning "$(validate_project_name "$PROJECT_NAME" 2>&1)" - fi - done - fi - - # Get template (if not already set) - if [[ -z "$PROJECT_TEMPLATE" ]]; then - PROJECT_TEMPLATE=$(get_template) - fi - - # Get author details (if not already set) - echo "" - print_info "Author information (for project metadata):" - - if [[ -z "$PROJECT_AUTHOR_NAME" ]]; then - PROJECT_AUTHOR_NAME=$(sanitize_input "$(get_input "Your name" "Your Name")") - fi - - if [[ -z "$PROJECT_AUTHOR_EMAIL" ]]; then - PROJECT_AUTHOR_EMAIL=$(sanitize_input "$(get_input "Your email" "you@example.com")") - fi - - if [[ -z "$PROJECT_GITHUB_USERNAME" ]]; then - PROJECT_GITHUB_USERNAME=$(sanitize_input "$(get_input "GitHub username" "yourusername")") - fi - - # Get description (if not already set) - if [[ -z "$PROJECT_DESCRIPTION" ]]; then - PROJECT_DESCRIPTION=$(sanitize_input "$(get_input "Project description" "A NockApp built with Nockup")") - fi -} - -# Function to validate all required fields -validate_config() { - local missing_fields=() - - [[ -z "$PROJECT_NAME" ]] && missing_fields+=("name") - [[ -z "$PROJECT_TEMPLATE" ]] && missing_fields+=("template") - [[ -z "$PROJECT_AUTHOR_NAME" ]] && missing_fields+=("author_name") - [[ -z "$PROJECT_AUTHOR_EMAIL" ]] && missing_fields+=("author_email") - [[ -z "$PROJECT_GITHUB_USERNAME" ]] && missing_fields+=("github_username") - [[ -z "$PROJECT_DESCRIPTION" ]] && missing_fields+=("description") - - if [[ ${#missing_fields[@]} -gt 0 ]]; then - print_error "Missing required fields: ${missing_fields[*]}" - return 1 - fi - - # Validate template - if ! validate_template "$PROJECT_TEMPLATE"; then - return 1 - fi - - # Validate project name - if ! validate_project_name "$PROJECT_NAME"; then - return 1 - fi - - return 0 -} - -# Function to create manifest file -create_manifest() { - local manifest_file="${PROJECT_NAME}.toml" - - print_step "Creating project manifest: $manifest_file" - - cat > "$manifest_file" << EOF -# NockApp Project Configuration -# Generated by Nockup Quick Start - -[project] -name = "$PROJECT_DESCRIPTION" -project_name = "$PROJECT_NAME" -version = "0.1.0" -description = "$PROJECT_DESCRIPTION" -author_name = "$PROJECT_AUTHOR_NAME" -author_email = "$PROJECT_AUTHOR_EMAIL" -github_username = "$PROJECT_GITHUB_USERNAME" -license = "MIT" -keywords = ["nockapp", "nockchain", "hoon"] -nockapp_commit_hash = "336f744b6b83448ec2b86473a3dec29b15858999" -template = "$PROJECT_TEMPLATE" - -# Libraries: Uncomment and modify as needed -# [libraries.sequent] -# url = "https://github.com/jackfoxy/sequent" -# commit = "0f6e6777482447d4464948896b763c080dc9e559" - -# [libraries.bits] -# url = "https://github.com/urbit/urbit" -# branch = "develop" -# file = "pkg/arvo/lib/bits.hoon" - -# [libraries.math] -# url = "https://github.com/urbit/numerics" -# branch = "main" -# directory = "libmath" -# commit = "7c11c48ab3f21135caa5a4e8744a9c3f828f2607" -EOF - - print_success "Created manifest: $manifest_file" -} - -# Function to initialize project -initialize_project() { - local manifest_file="${PROJECT_NAME}.toml" - - print_step "Initializing NockApp project with Nockup..." - - if nockup start "$manifest_file"; then - print_success "Project initialized successfully!" - return 0 - else - print_error "Project initialization failed" - print_info "You can try manually:" - print_info " nockup start $manifest_file" - return 1 - fi -} - -# Function to show next steps -show_next_steps() { - echo "" - print_success "🎉 Your NockApp project '$PROJECT_NAME' is ready!" - echo "" - print_info "📁 Project structure created in: ./$PROJECT_NAME/" - print_info "🌙 Hoon code: ./$PROJECT_NAME/hoon/app/app.hoon" - print_info "🦀 Rust code: ./$PROJECT_NAME/src/main.rs" - print_info "📋 Configuration: ./$PROJECT_NAME/manifest.toml" - echo "" - print_info "🚀 Next steps:" - echo " cd $PROJECT_NAME" - echo " nockup build ." - echo " nockup run ." - echo "" - - # Template-specific instructions - case "$PROJECT_TEMPLATE" in - "grpc") - print_info "💡 gRPC template may need multiple processes" - echo "" - ;; - "chain") - print_info "💡 Chain template connects to Nockchain network" - print_info " Uses light client - no additional setup needed" - echo "" - ;; - "http-static"|"http-server") - print_info "💡 Web server template:" - print_info " Your app will be available at a local URL after running" - echo "" - ;; - esac - - print_info "📚 To learn more:" - print_info " • Edit the Hoon kernel: $PROJECT_NAME/hoon/app/app.hoon" - print_info " • Customize the Rust code: $PROJECT_NAME/src/main.rs" - print_info " • Add libraries by editing: $PROJECT_NAME/manifest.toml" - print_info " • View documentation: nockup --help" -} - -# Function to show dry run summary -show_dry_run_summary() { - echo "" - print_info "=== DRY RUN - No files will be created ===" - echo "" - print_info "Project Configuration:" - print_info " Name: $PROJECT_NAME" - print_info " Template: $PROJECT_TEMPLATE" - print_info " Author: $PROJECT_AUTHOR_NAME" - print_info " Email: $PROJECT_AUTHOR_EMAIL" - print_info " GitHub: $PROJECT_GITHUB_USERNAME" - print_info " Description: $PROJECT_DESCRIPTION" - echo "" - print_info "Would create:" - print_info " • Manifest file: ${PROJECT_NAME}.toml" - print_info " • Project directory: ./${PROJECT_NAME}/" - print_info " • Hoon code: ./${PROJECT_NAME}/hoon/app/app.hoon" - print_info " • Rust code: ./${PROJECT_NAME}/src/main.rs" - echo "" -} - -# Parse command line arguments -parse_args() { - while [[ $# -gt 0 ]]; do - case $1 in - -h|--help) - show_usage - exit 0 - ;; - -n|--name) - PROJECT_NAME=$(sanitize_input "$2") - shift 2 - ;; - -t|--template) - PROJECT_TEMPLATE="$2" - shift 2 - ;; - -a|--author) - PROJECT_AUTHOR_NAME=$(sanitize_input "$2") - shift 2 - ;; - -e|--email) - PROJECT_AUTHOR_EMAIL=$(sanitize_input "$2") - shift 2 - ;; - -g|--github) - PROJECT_GITHUB_USERNAME=$(sanitize_input "$2") - shift 2 - ;; - -d|--description) - PROJECT_DESCRIPTION=$(sanitize_input "$2") - shift 2 - ;; - --non-interactive) - NON_INTERACTIVE=true - shift - ;; - --dry-run) - DRY_RUN=true - shift - ;; - *) - print_error "Unknown option: $1" - show_usage - exit 1 - ;; - esac - done -} - -# Cleanup function for error handling -cleanup_on_error() { - local manifest_file="${PROJECT_NAME}.toml" - - if [[ -f "$manifest_file" ]]; then - print_warning "Cleaning up manifest file: $manifest_file" - rm -f "$manifest_file" - fi -} - -# Main function -main() { - # Set up error handling - trap cleanup_on_error ERR - - print_step "NockApp Quick Start Helper" - print_info "This tool helps you create your first NockApp project" - echo "" - - # Parse command line arguments - parse_args "$@" - - # Check if nockup is available - check_nockup - - # Get project details (interactive or from args) - if [[ "$NON_INTERACTIVE" != "true" ]]; then - get_project_details_interactive - fi - - # Validate configuration - if ! validate_config; then - print_error "Configuration validation failed" - if [[ "$NON_INTERACTIVE" == "true" ]]; then - print_info "In non-interactive mode, all required fields must be provided" - show_usage - fi - exit 1 - fi - - echo "" - print_step "Creating your NockApp project..." - print_info "Project: $PROJECT_NAME" - print_info "Template: $PROJECT_TEMPLATE" - print_info "Author: $PROJECT_AUTHOR_NAME" - echo "" - - # Dry run mode - if [[ "$DRY_RUN" == "true" ]]; then - show_dry_run_summary - exit 0 - fi - - # Create manifest file - create_manifest - - # Initialize project with nockup - if initialize_project; then - show_next_steps - else - print_warning "Project creation completed with errors" - print_info "Check the manifest file and try running nockup start manually" - exit 1 - fi -} - -# Check if we're being sourced or executed -if [[ "${BASH_SOURCE[0]:-$0}" == "${0}" ]]; then - main "$@" -fi diff --git a/crates/nockup/rust-toolchain.toml b/crates/nockup/rust-toolchain.toml deleted file mode 100644 index aa37dcd1c..000000000 --- a/crates/nockup/rust-toolchain.toml +++ /dev/null @@ -1,3 +0,0 @@ -[toolchain] -channel = "nightly-2025-02-14" -components = ["miri"] diff --git a/crates/nockup/scripts/scan-deps.py b/crates/nockup/scripts/scan-deps.py new file mode 100644 index 000000000..694524c61 --- /dev/null +++ b/crates/nockup/scripts/scan-deps.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python3 +""" +Scan Hoon files and generate a registry.toml in the typhoon format. + +Usage: + python scan-deps-v2.py --workspace nockchain --root-path hoon \\ + --git-url https://github.com/nockchain/nockchain --ref a19ad4dc \\ + /path/to/nockchain/hoon +""" + +import os +import sys +import argparse +from pathlib import Path +from collections import defaultdict +from typing import Dict, List, Set, Tuple, Optional + +def get_dependencies(file_path: Path) -> List[str]: + """ + Extract dependencies from a Hoon file. + Returns list of dependency names (without paths, e.g., "zeke", "one") + """ + deps = [] + try: + with open(file_path, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + if not line: + continue + # Stop at first non-import line + if not (line.startswith('/') or line.startswith('::')): + break + if line.startswith('::'): # Comment + continue + + parts = line.split() + if not parts: + continue + + prefix = parts[0] + if prefix in ('/+', '/-', '/#'): + # Parse: /+ foo, bar, *baz=qux + dep_str = ' '.join(parts[1:]) + for d in dep_str.split(','): + d = d.strip() + # Remove * prefix + if d.startswith('*'): + d = d[1:] + # Handle renaming: *foo=bar -> bar + if '=' in d: + d = d.split('=')[-1].strip() + if d: + deps.append(d) + elif prefix == '/=': + # Parse old clay syntax: /= name /path/to/file + # Example: /= common /common/v0-v1/table/compute + # Example: /= * /common/zeke + # The path is absolute from workspace root + if len(parts) >= 3: + path = parts[2] + # Strip leading slash + if path.startswith('/'): + path = path[1:] + # The dependency name is the full path from workspace root + # This will be resolved later based on the files dict + if path: + deps.append(path) + except Exception as e: + print(f"Warning: Could not read {file_path}: {e}", file=sys.stderr) + + return deps + +def find_hoon_files(directory: Path) -> Dict[str, Tuple[Path, str, str]]: + """ + Find all .hoon files in directory. + Returns dict: package_key -> (full_path, install_path, filename) + + Package key: subdirectory structure within scan directory + filename + Install path: full path from root_path (includes scan directory name) + + Example: + Scanning: /repo/hoon/common/ + File at: /repo/hoon/common/ztd/eight.hoon + Returns: "ztd/eight" -> (Path(...), "common/ztd", "eight.hoon") + + Package name will be: workspace/ztd/eight + Install path will be: common/ztd + """ + files = {} + + for hoon_file in directory.rglob("*.hoon"): + filename = hoon_file.stem # Without .hoon extension + + # Get path relative to scan directory + rel_dir = hoon_file.parent.relative_to(directory) + rel_dir_str = str(rel_dir).replace(os.sep, '/') if str(rel_dir) != '.' else "" + + # Package key: subdirectory path within scan dir + filename + if rel_dir_str: + package_key = f"{rel_dir_str}/{filename}" + else: + package_key = filename + + # Install path: scan directory name + subdirectory path + scan_dir_name = directory.name + if rel_dir_str: + install_path = f"{scan_dir_name}/{rel_dir_str}" + else: + install_path = scan_dir_name + + files[package_key] = (hoon_file, install_path, hoon_file.name) + + return files + +def resolve_dependency( + dep_name: str, + current_file_dir: str, + files: Dict[str, Tuple[Path, str, str]] +) -> Optional[str]: + """ + Resolve a dependency name to its full key in the files dict. + + Search order: + 1. If dep_name is a full path (contains /), try it directly + 2. Same directory as current file: {current_dir}/{dep_name} + 3. Sibling directories: {parent}/{dep_name} + 4. Root level: {dep_name} + 5. Anywhere in tree (last resort) + + Args: + dep_name: dependency name like "types" or full path like "common/v0-v1/table/compute" + current_file_dir: directory of the file with the import (e.g., "wallet") + files: dict of all files + + Returns: + Full key like "wallet/types" or None if not found + """ + # If it's already a full path (from /= syntax), try it directly + if '/' in dep_name: + if dep_name in files: + return dep_name + # Also try matching just the basename if full path doesn't work + basename = dep_name.split('/')[-1] + dep_name = basename + + # Try same directory first + if current_file_dir: + same_dir_key = f"{current_file_dir}/{dep_name}" + if same_dir_key in files: + return same_dir_key + + # Try root level + if dep_name in files: + return dep_name + + # Try parent directory siblings + if current_file_dir: + parts = current_file_dir.split('/') + if len(parts) > 1: + parent = '/'.join(parts[:-1]) + sibling_key = f"{parent}/{dep_name}" + if sibling_key in files: + return sibling_key + + # Last resort: search all files for matching basename + matches = [k for k in files.keys() if k.endswith(f"/{dep_name}") or k == dep_name] + if len(matches) == 1: + return matches[0] + elif len(matches) > 1: + print(f"Warning: Ambiguous dependency '{dep_name}' from {current_file_dir}, found: {matches}", + file=sys.stderr) + return matches[0] # Return first match + + return None + +def build_dependency_graph(files: Dict[str, Tuple[Path, str, str]], scan_dir: Path) -> Dict[str, List[str]]: + """ + Build dependency graph by scanning all files. + Returns dict: full_key -> list of resolved dependency full_keys + """ + graph = {} + + # Get the basename of the scan directory to handle /common/ prefix stripping + scan_dir_name = scan_dir.name # e.g., "common" + + for full_key, (file_path, file_dir, _) in files.items(): + raw_deps = get_dependencies(file_path) + + # Resolve each dependency to its full key + resolved_deps = [] + for dep_name in raw_deps: + # Strip the scan directory name from the dependency path if present + # E.g., if scanning "common/", strip "common/" from "common/v0-v1/table/compute" + if dep_name.startswith(f"{scan_dir_name}/"): + dep_name = dep_name[len(scan_dir_name)+1:] + + resolved = resolve_dependency(dep_name, file_dir, files) + if resolved: + resolved_deps.append(resolved) + else: + print(f"Warning: Could not resolve dependency '{dep_name}' in {full_key}", + file=sys.stderr) + + graph[full_key] = resolved_deps + + return graph + +def generate_registry_toml( + workspace_name: str, + git_url: str, + ref: str, + description: str, + root_path: str, + files: Dict[str, Tuple[Path, str, str]], + graph: Dict[str, List[str]], + scan_dir: Path +) -> str: + """ + Generate registry TOML in the typhoon format. + + The root_path parameter specifies where meaningful paths begin in the repo. + For example, if root_path = "hoon", then scanning hoon/common/ should produce: + - Package name: workspace/zeke (not workspace/common/zeke) + - Path: common (not hoon/common) + """ + lines = [] + + # Header comment + lines.append("# ============================================================================") + lines.append(f"# {workspace_name} workspace packages") + lines.append("# Generated by scan-deps-v2.py") + lines.append("# ============================================================================") + lines.append("") + + # Workspace definition + lines.append(f"[workspace.{workspace_name}]") + lines.append(f'git_url = "{git_url}"') + lines.append(f'ref = "{ref}"') + lines.append(f'description = "{description}"') + lines.append(f'root_path = "{root_path}"') + lines.append("") + + # Sort files by path for consistent output + sorted_files = sorted(files.items(), key=lambda x: x[0]) + + # Collect aliases while generating packages + aliases_to_generate = [] + + # Package definitions + for package_key, (_, install_path, hoon_filename) in sorted_files: + lines.append("[[package]]") + # Package name: workspace + subdirectory path within scan dir + lines.append(f'name = "{workspace_name}/{package_key}"') + lines.append(f'workspace = "{workspace_name}"') + # Path: full path from root_path (includes scan directory name) + lines.append(f'path = "{install_path}"') + lines.append(f'file = "{hoon_filename}"') + + # Dependencies - use package keys + deps = graph.get(package_key, []) + if deps: + dep_list = ', '.join(f'"{workspace_name}/{d}"' for d in deps) + lines.append(f'dependencies = [{dep_list}]') + else: + lines.append('dependencies = []') + + lines.append("") + + # Collect aliases for special cases + # For ztd/one through ztd/eight, create short aliases + filename_stem = hoon_filename.replace('.hoon', '') + if package_key.startswith('common/ztd/') and filename_stem in ['one', 'two', 'four', 'five', 'six', 'seven', 'eight']: + aliases_to_generate.append((f'{workspace_name}/{filename_stem}', f'{workspace_name}/{package_key}')) + + # Special-case aliases + special_aliases = { + 'zeke': 'common/zeke', + 'zoon': 'common/zoon', + 'zose': 'common/zose', + } + for alias_name, target_path in special_aliases.items(): + full_target_key = f"{workspace_name}/{target_path}" + if full_target_key in [f"{workspace_name}/{k}" for k in files.keys()]: + aliases_to_generate.append((f"{workspace_name}/{alias_name}", full_target_key)) + + # Generate alias sections + if aliases_to_generate: + lines.append("# ============================================================================") + lines.append("# Aliases") + lines.append("# ============================================================================") + lines.append("") + for alias_name, target_name in aliases_to_generate: + lines.append("[[alias]]") + lines.append(f'name = "{alias_name}"') + lines.append(f'target = "{target_name}"') + lines.append("") + + return '\n'.join(lines) + +def main(): + parser = argparse.ArgumentParser( + description='Scan Hoon files and generate registry TOML', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Example: + python scan-deps-v2.py --workspace nockchain --root-path hoon \\ + --git-url https://github.com/nockchain/nockchain --ref a19ad4dc \\ + --description "Nockchain standard library" \\ + /path/to/nockchain/hoon +""") + + parser.add_argument('directory', type=Path, + help='Directory to scan for .hoon files') + parser.add_argument('--workspace', required=True, + help='Workspace name (e.g., "nockchain")') + parser.add_argument('--git-url', required=True, + help='Git repository URL') + parser.add_argument('--ref', required=True, + help='Git ref (tag or commit hash)') + parser.add_argument('--root-path', required=True, + help='Root path in repo (e.g., "hoon", "pkg/arvo")') + parser.add_argument('--description', default='', + help='Workspace description') + parser.add_argument('--output', '-o', type=Path, + help='Output file (default: stdout)') + + args = parser.parse_args() + + if not args.directory.is_dir(): + print(f"Error: {args.directory} is not a directory", file=sys.stderr) + sys.exit(1) + + # Find all Hoon files + files = find_hoon_files(args.directory) + print(f"Found {len(files)} Hoon files", file=sys.stderr) + + # Build dependency graph + graph = build_dependency_graph(files, args.directory) + + # Generate TOML + toml_output = generate_registry_toml( + workspace_name=args.workspace, + git_url=args.git_url, + ref=args.ref, + description=args.description, + root_path=args.root_path, + files=files, + graph=graph, + scan_dir=args.directory + ) + + # Output + if args.output: + args.output.write_text(toml_output) + print(f"Wrote registry to {args.output}", file=sys.stderr) + else: + print(toml_output) + +if __name__ == "__main__": + main() diff --git a/crates/nockup/src/cache.rs b/crates/nockup/src/cache.rs new file mode 100644 index 000000000..0a83934f5 --- /dev/null +++ b/crates/nockup/src/cache.rs @@ -0,0 +1,362 @@ +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +/// Metadata about a cached package +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CachedPackage { + pub name: String, + pub version_spec: String, // e.g., "k414", "commit:abc123", "^1.2.0" + pub commit: String, // Exact commit hash + pub cached_at: u64, // Unix timestamp + pub source_url: String, +} + +/// Cache index tracking all cached packages +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct CacheIndex { + pub packages: HashMap>, +} + +/// Manages the Nockup package cache +pub struct PackageCache { + root: PathBuf, // ~/.nockup/cache/ +} + +impl PackageCache { + /// Create a new PackageCache, creating directories if needed + pub fn new() -> Result { + let home = + dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; + let root = home.join(".nockup").join("cache"); + + // Create cache directories + std::fs::create_dir_all(root.join("git"))?; + std::fs::create_dir_all(root.join("packages"))?; + std::fs::create_dir_all(root.join("registry"))?; + + Ok(Self { root }) + } + + /// Create a PackageCache with custom root (for testing) + pub fn with_root(root: PathBuf) -> Result { + std::fs::create_dir_all(root.join("git"))?; + std::fs::create_dir_all(root.join("packages"))?; + std::fs::create_dir_all(root.join("registry"))?; + + Ok(Self { root }) + } + + /// Get the root cache directory + pub fn root(&self) -> &Path { + &self.root + } + + /// Get the git cache directory (for GitFetcher) + pub fn git_dir(&self) -> PathBuf { + self.root.join("git") + } + + /// Get the packages cache directory + pub fn packages_dir(&self) -> PathBuf { + self.root.join("packages") + } + + /// Get the registry cache directory + pub fn registry_dir(&self) -> PathBuf { + self.root.join("registry") + } + + /// Get the path for a specific package version + /// Format: ~/.nockup/cache/packages/// + pub fn package_path(&self, name: &str, version_spec: &str) -> PathBuf { + let safe_spec = self.sanitize_version_spec(version_spec); + self.packages_dir().join(name).join(safe_spec) + } + + /// Check if a package is cached + pub fn is_cached(&self, name: &str, version_spec: &str) -> bool { + self.package_path(name, version_spec).exists() + } + + /// Cache a package from a git repo path + pub async fn cache_package( + &self, + name: &str, + version_spec: &str, + commit: &str, + source_url: &str, + source_path: &Path, + ) -> Result { + let target_path = self.package_path(name, version_spec); + + // Create parent directory + if let Some(parent) = target_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + // Copy source to cache + self.copy_directory(source_path, &target_path).await?; + + let cached_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .context("System clock is before UNIX_EPOCH")? + .as_secs(); + + // Update cache index + self.add_to_index(CachedPackage { + name: name.to_string(), + version_spec: version_spec.to_string(), + commit: commit.to_string(), + cached_at, + source_url: source_url.to_string(), + }) + .await?; + + Ok(target_path) + } + + /// Load the cache index + pub async fn load_index(&self) -> Result { + let index_path = self.root.join("cache-index.json"); + + if !index_path.exists() { + return Ok(CacheIndex::default()); + } + + let contents = tokio::fs::read_to_string(&index_path).await?; + let index: CacheIndex = + serde_json::from_str(&contents).context("Failed to parse cache index")?; + + Ok(index) + } + + /// Save the cache index + pub async fn save_index(&self, index: &CacheIndex) -> Result<()> { + let index_path = self.root.join("cache-index.json"); + let contents = serde_json::to_string_pretty(index)?; + tokio::fs::write(&index_path, contents).await?; + Ok(()) + } + + /// Add a package to the cache index + async fn add_to_index(&self, package: CachedPackage) -> Result<()> { + let mut index = self.load_index().await?; + + index + .packages + .entry(package.name.clone()) + .or_insert_with(Vec::new) + .push(package); + + self.save_index(&index).await?; + Ok(()) + } + + /// List all cached packages + pub async fn list_cached(&self) -> Result> { + let index = self.load_index().await?; + let mut all_packages: Vec = index.packages.into_values().flatten().collect(); + + all_packages.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(all_packages) + } + + /// Find cached package by name and version spec + pub async fn find_cached( + &self, + name: &str, + version_spec: &str, + ) -> Result> { + let index = self.load_index().await?; + + if let Some(packages) = index.packages.get(name) { + for pkg in packages { + if pkg.version_spec == version_spec { + return Ok(Some(pkg.clone())); + } + } + } + + Ok(None) + } + + /// Clean the cache (remove all cached packages) + pub async fn clean(&self) -> Result<()> { + // Remove packages directory + if self.packages_dir().exists() { + tokio::fs::remove_dir_all(self.packages_dir()).await?; + tokio::fs::create_dir_all(self.packages_dir()).await?; + } + + // Clear index + self.save_index(&CacheIndex::default()).await?; + + Ok(()) + } + + /// Prune old cached packages (keep only latest N versions per package) + pub async fn prune(&self, keep_versions: usize) -> Result<()> { + let mut index = self.load_index().await?; + + for (name, packages) in &mut index.packages { + if packages.len() <= keep_versions { + continue; + } + + // Sort by cached_at timestamp (newest first) + packages.sort_by(|a, b| b.cached_at.cmp(&a.cached_at)); + + // Remove old versions + let to_remove: Vec = packages.drain(keep_versions..).collect(); + + for pkg in to_remove { + let path = self.package_path(&pkg.name, &pkg.version_spec); + if path.exists() { + tokio::fs::remove_dir_all(&path).await?; + } + println!(" Pruned {}@{}", name, pkg.version_spec); + } + } + + self.save_index(&index).await?; + Ok(()) + } + + /// Get cache statistics + pub async fn stats(&self) -> Result { + let index = self.load_index().await?; + + let total_packages = index.packages.values().map(|v| v.len()).sum(); + let unique_packages = index.packages.len(); + + // Calculate total size (approximate) + let total_size = self.calculate_directory_size(&self.packages_dir()).await?; + + Ok(CacheStats { + total_packages, + unique_packages, + total_size_bytes: total_size, + }) + } + + // Private helper methods + + /// Sanitize version spec for use in filesystem path + fn sanitize_version_spec(&self, spec: &str) -> String { + spec.replace(['/', ':', '@'], "_") + .replace('^', "caret_") + .replace('~', "tilde_") + .replace('>', "gt_") + .replace('<', "lt_") + } + + /// Recursively copy a directory + fn copy_directory<'a>( + &'a self, + src: &'a Path, + dst: &'a Path, + ) -> std::pin::Pin> + 'a>> { + Box::pin(async move { + if !src.exists() { + anyhow::bail!("Source directory does not exist: {}", src.display()); + } + + tokio::fs::create_dir_all(dst).await?; + + let mut entries = tokio::fs::read_dir(src).await?; + + while let Some(entry) = entries.next_entry().await? { + let src_path = entry.path(); + let file_name = entry.file_name(); + let dst_path = dst.join(&file_name); + + // Skip .git directories + if file_name == ".git" { + continue; + } + + if src_path.is_dir() { + self.copy_directory(&src_path, &dst_path).await?; + } else { + tokio::fs::copy(&src_path, &dst_path).await?; + } + } + + Ok(()) + }) + } + + /// Calculate total size of a directory (recursive) + fn calculate_directory_size<'a>( + &'a self, + path: &'a Path, + ) -> std::pin::Pin> + 'a>> { + Box::pin(async move { + if !path.exists() { + return Ok(0); + } + + let mut total_size = 0u64; + let mut entries = tokio::fs::read_dir(path).await?; + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + let metadata = tokio::fs::metadata(&path).await?; + + if path.is_dir() { + total_size += self.calculate_directory_size(&path).await?; + } else { + total_size += metadata.len(); + } + } + + Ok(total_size) + }) + } +} + +/// Cache statistics +#[derive(Debug)] +pub struct CacheStats { + pub total_packages: usize, + pub unique_packages: usize, + pub total_size_bytes: u64, +} + +impl CacheStats { + pub fn total_size_mb(&self) -> f64 { + self.total_size_bytes as f64 / (1024.0 * 1024.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_sanitize_version_spec() { + let cache = + PackageCache::with_root(PathBuf::from("/tmp/test")).expect("Failed to init cache"); + + assert_eq!(cache.sanitize_version_spec("k414"), "k414"); + assert_eq!( + cache.sanitize_version_spec("commit:abc123"), + "commit_abc123" + ); + assert_eq!(cache.sanitize_version_spec("^1.2.0"), "caret_1.2.0"); + assert_eq!(cache.sanitize_version_spec("~1.2.3"), "tilde_1.2.3"); + assert_eq!(cache.sanitize_version_spec("@tag:v1.0"), "_tag_v1.0"); + } + + #[test] + fn test_package_path() { + let cache = + PackageCache::with_root(PathBuf::from("/tmp/test")).expect("Failed to init cache"); + let path = cache.package_path("arvo", "k414"); + + assert_eq!(path, PathBuf::from("/tmp/test/packages/arvo/k414")); + } +} diff --git a/crates/nockup/src/cli.rs b/crates/nockup/src/cli.rs index da961652c..f4a54acc4 100644 --- a/crates/nockup/src/cli.rs +++ b/crates/nockup/src/cli.rs @@ -11,41 +11,113 @@ pub struct Cli { #[derive(Subcommand)] pub enum Commands { - /// Initialize nockup cache and download templates - Install, - /// Initialize a new NockApp project from a .toml config file + // Hierarchical commands + /// Project management (build, run, init) + #[command(subcommand)] + Project(ProjectCommand), + + /// Package and dependency management + #[command(subcommand)] + Package(PackageCommand), + + /// Toolchain / channel management + #[command(subcommand)] + Channel(ChannelCommand), + + // Legacy flat commands (backward compatible) + /// Build a NockApp project + #[command(hide = true)] + Build { + #[arg(value_name = "PROJECT")] + project: String, + }, + + /// Initialize a new NockApp project + #[command(hide = true)] Init { - /// Name of the project config file (looks for .toml) - name: String, + #[arg(value_name = "NAME")] + project: String, }, + /// Check for updates to nockup, hoon, and hoonc Update, - /// Build a NockApp project - Build { - /// Path to the project directory + + /// Initialize nockup cache and download templates + Install, + + /// Run a NockApp project + #[command(hide = true)] + Run { project: String, + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, }, + + /// Test Phase 1 infrastructure (temporary demo command) + #[command(hide = true)] + TestPhase1, +} + +#[derive(clap::Subcommand, Debug)] +pub enum ProjectCommand { + /// Build a NockApp project + Build { project: Option }, /// Run a NockApp project Run { - /// Path to the project directory project: String, - /// Additional arguments to pass to the running application - #[arg(last = true)] + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, - /// Manage channels (e.g., set default) - Channel { - #[command(subcommand)] - action: ChannelAction, - }, + /// Initialize a new NockApp project + Init, } -#[derive(Subcommand)] -pub enum ChannelAction { - /// Set the default channel (e.g., stable, nightly) - Set { - channel: String, // e.g., "stable" or "nightly" +#[derive(clap::Subcommand, Debug)] +pub enum PackageCommand { + /// Initialize a new NockApp project + Init { name: Option }, + + /// Add a dependency to nockapp.toml + Add { + /// Package name + name: String, + /// Version specification (e.g., @k409, ^1.2.3, @tag:v1.0.0) + #[arg(short, long)] + version: Option, + }, + + /// Remove a dependency from nockapp.toml + Remove { + /// Package name to remove + name: String, }, - /// Show the current channel + + /// List all dependencies and their installation status + List, + + /// Install dependencies from nockapp.toml + Install, + + /// Update dependencies to latest versions + Update, + + /// Clear the package cache + Purge { + /// Only show what would be deleted without actually deleting + #[arg(short = 'n', long)] + dry_run: bool, + }, + + /// Grab a package (deprecated - use add) + #[command(hide = true)] + Grab { spec: String }, + + /// Generate proxy files for a package + GenerateProxy { url: String, path: Option }, +} + +#[derive(clap::Subcommand, Debug)] +pub enum ChannelCommand { Show, + Set { channel: String }, } diff --git a/crates/nockup/src/commands/build.rs b/crates/nockup/src/commands/build/build.rs similarity index 53% rename from crates/nockup/src/commands/build.rs rename to crates/nockup/src/commands/build/build.rs index f35360f7b..5d37cd243 100644 --- a/crates/nockup/src/commands/build.rs +++ b/crates/nockup/src/commands/build/build.rs @@ -5,7 +5,7 @@ use anyhow::{Context, Result}; use colored::Colorize; use tokio::process::Command; -pub async fn run(project: String) -> Result<()> { +pub async fn run(project: &str) -> Result<()> { let project_dir = Path::new(&project); // Check if project directory exists @@ -13,6 +13,27 @@ pub async fn run(project: String) -> Result<()> { return Err(anyhow::anyhow!("Project directory '{}' not found", project)); } + // Auto-install dependencies if nockapp.toml exists + let nockapp_manifest = project_dir.join("nockapp.toml"); + if nockapp_manifest.exists() { + // Check if dependencies need to be installed + if should_install_dependencies(project_dir).await? { + println!("{} Installing dependencies...", "📦".cyan()); + // Change to project directory to run install + let original_dir = std::env::current_dir()?; + std::env::set_current_dir(project_dir)?; + + // Run package install + let install_result = crate::commands::package::install::run().await; + + // Change back to original directory + std::env::set_current_dir(original_dir)?; + + install_result?; + println!(); + } + } + // Check if it's a valid NockApp project (has manifest.toml) let manifest_path = project_dir.join("manifest.toml"); if !manifest_path.exists() { @@ -49,19 +70,13 @@ pub async fn run(project: String) -> Result<()> { }; // Check number of expected binaries; if more than one, check primary source files. - let binaries: Vec = { - if expected_binaries.is_empty() { - vec![project_dir.join("src").join("main.rs")] - } else if expected_binaries.len() == 1 { - vec![project_dir.join("src").join("main.rs")] - } else { - let mut binaries = Vec::new(); - for bin_name in &expected_binaries { - let bin_path = project_dir.join("src").join(format!("{}.rs", bin_name)); - binaries.push(bin_path); - } - binaries - } + let binaries: Vec = if expected_binaries.len() > 1 { + expected_binaries + .iter() + .map(|bin_name| project_dir.join("src").join(format!("{}.rs", bin_name))) + .collect() + } else { + vec![project_dir.join("src").join("main.rs")] }; // Run cargo build in the project directory @@ -92,10 +107,18 @@ pub async fn run(project: String) -> Result<()> { // If there are multiple binaries, then check at each location by name. for bin_path in &binaries { // if this is main.rs, then load app.hoon - let name = if bin_path.file_name().unwrap() == "main.rs" { + let name = if bin_path + .file_name() + .expect("bin_path should have a file name") + == "main.rs" + { "app".to_string() } else { - bin_path.file_stem().unwrap().to_string_lossy().to_string() + bin_path + .file_stem() + .expect("bin_path should have a file stem") + .to_string_lossy() + .to_string() }; let hoon_app_path = project_dir.join(format!("hoon/app/{}.hoon", name)); println!("Compiling Hoon app file at: {}", hoon_app_path.display()); @@ -112,7 +135,11 @@ pub async fn run(project: String) -> Result<()> { // Run hoonc command from project directory let mut hoonc_command = Command::new("hoonc"); hoonc_command - .arg(hoon_app_path.strip_prefix(project_dir).unwrap()) + .arg( + hoon_app_path + .strip_prefix(project_dir) + .expect("hoon_app_path should be under project_dir"), + ) .current_dir(project_dir) // Run in project directory .stdout(Stdio::inherit()) .stderr(Stdio::inherit()); @@ -132,7 +159,10 @@ pub async fn run(project: String) -> Result<()> { if binaries.len() > 1 { let target_jam = project_dir.join(format!( "{}.jam", - bin_path.file_stem().unwrap().to_string_lossy() + bin_path + .file_stem() + .expect("bin_path should have a file stem") + .to_string_lossy() )); tokio::fs::rename(project_dir.join("out.jam"), &target_jam) .await @@ -152,3 +182,70 @@ pub async fn run(project: String) -> Result<()> { Ok(()) } + +/// Check if dependencies need to be installed +async fn should_install_dependencies(project_dir: &Path) -> Result { + use crate::manifest::{HoonPackage, NockAppLock}; + + // Load the manifest + let manifest_path = project_dir.join("nockapp.toml"); + let manifest = match HoonPackage::load(&manifest_path)? { + Some(m) => m, + None => return Ok(false), // No manifest, no dependencies needed + }; + + // Check if there are any dependencies + let has_deps = manifest + .dependencies + .as_ref() + .map(|deps| !deps.is_empty()) + .unwrap_or(false); + + if !has_deps { + return Ok(false); // No dependencies to install + } + + // Check if lockfile exists + let lock_path = project_dir.join("nockapp.lock"); + if !lock_path.exists() { + return Ok(true); // Lockfile missing, need to install + } + + // Load lockfile + let lockfile = NockAppLock::load(&lock_path)?; + + // Check if all dependencies in manifest are in lockfile + let manifest_deps: std::collections::HashSet<&String> = manifest + .dependencies + .as_ref() + .map(|deps| deps.keys().collect()) + .unwrap_or_default(); + + let lockfile_deps: std::collections::HashSet = lockfile + .package + .iter() + .map(|pkg| pkg.name.clone()) + .collect(); + + // If any manifest dependency is missing from lockfile, need to install + for dep_name in manifest_deps { + if !lockfile_deps.contains(dep_name) { + return Ok(true); + } + } + + // Check if hoon/packages directories exist for all locked packages + let packages_dir = project_dir.join("hoon").join("packages"); + if !packages_dir.exists() { + return Ok(true); // Packages directory missing, need to install + } + + for pkg in &lockfile.package { + let pkg_dir = packages_dir.join(format!("{}@{}", pkg.name, pkg.version)); + if !pkg_dir.exists() { + return Ok(true); // Package directory missing, need to install + } + } + + Ok(false) // Everything looks good, no install needed +} diff --git a/crates/nockup/src/commands/build/init.rs b/crates/nockup/src/commands/build/init.rs new file mode 100644 index 000000000..284c2f30b --- /dev/null +++ b/crates/nockup/src/commands/build/init.rs @@ -0,0 +1,150 @@ +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +use anyhow::{Context, Result}; +use colored::Colorize; +use handlebars::Handlebars; + +use crate::manifest::NockAppManifest; + +pub async fn run() -> Result<()> { + let cwd = std::env::current_dir()?; + let manifest_path = cwd.join("nockapp.toml"); + + if !manifest_path.exists() { + anyhow::bail!( + "No nockapp.toml found in current directory.\n\ + → Create one with your desired name, template, and dependencies,\n\ + → then run `nockup project init` again.\n\n\ + Example: https://github.com/nockchain/nockchain/wiki/Project-Templates" + ); + } + + let manifest = NockAppManifest::load(&manifest_path).context("Failed to parse nockapp.toml")?; + + let project_name = manifest.package.name.trim(); + if project_name.is_empty() { + anyhow::bail!("package.name in nockapp.toml cannot be empty"); + } + + let template_name = manifest.package.template.as_deref().unwrap_or("basic"); + + let template_commit = manifest.package.template_commit.as_deref(); + + println!( + "Initializing new NockApp project '{}' using template '{}'...", + project_name.green(), + template_name.cyan() + ); + + let target_dir = Path::new(project_name); + if target_dir.exists() { + anyhow::bail!( + "Directory '{}' already exists. Remove it or choose a different name.", project_name + ); + } + + // Resolve template directory (supports pinned commit) + let cache_dir = dirs::home_dir() + .ok_or_else(|| anyhow::anyhow!("Could not find home directory"))? + .join(".nockup/templates"); + + let template_src = if let Some(commit) = template_commit { + cache_dir.join(format!("{}-{}", template_name, commit)) + } else { + cache_dir.join(template_name) + }; + + if !template_src.exists() { + anyhow::bail!( + "Template '{}' not found in cache at {}.\n\ + Run `nockup channel update` or check your template-commit hash.", + template_name, + template_src.display() + ); + } + + // Build Handlebars context from manifest (same as your old one, but cleaner) + let context = build_handlebars_context(&manifest)?; + + // Copy and render the template + copy_and_render_template(&template_src, target_dir, &context)?; + + // Write the canonical nockapp.toml into the new project (exact copy of source) + let final_manifest_path = target_dir.join("nockapp.toml"); + manifest.save(&final_manifest_path)?; + + println!("Running dependency installation…"); + // Package install will automatically detect the project directory based on manifest name + crate::commands::package::install::run() + .await + .context("Failed to install dependencies")?; + + println!("\nAll done! Project is ready."); + println!(" cd {}", project_name.cyan()); + println!(" nockup run"); + Ok(()) +} + +fn build_handlebars_context(manifest: &NockAppManifest) -> Result> { + let mut ctx = HashMap::new(); + let p = &manifest.package; + + ctx.insert("name".to_string(), p.name.clone()); + ctx.insert("project_name".to_string(), p.name.clone()); + ctx.insert("version".to_string(), p.version.clone().unwrap_or_default()); + ctx.insert( + "description".to_string(), + p.description.clone().unwrap_or_default(), + ); + ctx.insert( + "author".to_string(), + p.authors.clone().unwrap_or_default().join(", "), + ); + + Ok(ctx) +} + +fn copy_and_render_template( + src_dir: &Path, + dest_dir: &Path, + context: &HashMap, +) -> Result<()> { + let handlebars = Handlebars::new(); + + fs::create_dir_all(dest_dir)?; + + copy_dir_recursive(src_dir, dest_dir, &handlebars, context, dest_dir)?; + Ok(()) +} + +fn copy_dir_recursive( + src_dir: &Path, + dest_dir: &Path, + handlebars: &Handlebars, + context: &HashMap, + project_root: &Path, +) -> Result<()> { + for entry in fs::read_dir(src_dir)? { + let entry = entry?; + let src_path = entry.path(); + let file_name = entry.file_name(); + let dest_path = dest_dir.join(&file_name); + + if src_path.is_dir() { + fs::create_dir_all(&dest_path)?; + copy_dir_recursive(&src_path, &dest_path, handlebars, context, project_root)?; + } else { + let content = fs::read_to_string(&src_path)?; + let rendered = handlebars + .render_template(&content, context) + .with_context(|| format!("Template error in {}", src_path.display()))?; + + fs::write(&dest_path, rendered)?; + let rel = dest_path.strip_prefix(project_root).unwrap_or(&dest_path); + println!(" {} {}", "create".green(), rel.display()); + } + } + Ok(()) +} diff --git a/crates/nockup/src/commands/build/mod.rs b/crates/nockup/src/commands/build/mod.rs new file mode 100644 index 000000000..3d8416c89 --- /dev/null +++ b/crates/nockup/src/commands/build/mod.rs @@ -0,0 +1,19 @@ +#[path = "build.rs"] +mod builder_impl; +pub mod init; +pub mod run; + +use anyhow::Result; + +use crate::cli::ProjectCommand; + +pub async fn run(cmd: ProjectCommand) -> Result<()> { + match cmd { + ProjectCommand::Build { project } => { + let project = project.as_deref().unwrap_or("."); + builder_impl::run(project).await + } + ProjectCommand::Run { project, args } => run::run(project, args).await, + ProjectCommand::Init => init::run().await, + } +} diff --git a/crates/nockup/src/commands/build/run.rs b/crates/nockup/src/commands/build/run.rs new file mode 100644 index 000000000..b260f2d22 --- /dev/null +++ b/crates/nockup/src/commands/build/run.rs @@ -0,0 +1,61 @@ +use std::path::Path; +use std::process::Stdio; + +use anyhow::{Context, Result}; +use colored::Colorize; +use tokio::process::Command; + +pub async fn run(project: String, args: Vec) -> Result<()> { + let project_dir = Path::new(&project); + + // Check if project directory exists + if !project_dir.exists() { + return Err(anyhow::anyhow!("Project directory '{}' not found", project)); + } + + // Check if it's a valid NockApp project (has manifest.toml) + let manifest_path = project_dir.join("manifest.toml"); + if !manifest_path.exists() { + return Err(anyhow::anyhow!( + "Not a NockApp project: '{}' missing manifest.toml", project + )); + } + + // Check if Cargo.toml exists + let cargo_toml = project_dir.join("Cargo.toml"); + if !cargo_toml.exists() { + return Err(anyhow::anyhow!("No Cargo.toml found in '{}'", project)); + } + + println!("{} Running project '{}'...", "🔨".green(), project.cyan()); + + // Run cargo run in the project directory + let mut command = Command::new("cargo"); + command + .arg("run") + .arg("--release") // Run in release mode by default + .current_dir(project_dir) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()); + + // Add separator and pass through additional arguments to the program + if !args.is_empty() { + command.arg("--").args(&args); + } + + let status = command + .status() + .await + .context("Failed to execute cargo run")?; + + if status.success() { + println!("{} Run completed successfully!", "✓".green()); + } else { + return Err(anyhow::anyhow!( + "Run failed with exit code: {}", + status.code().unwrap_or(-1) + )); + } + + Ok(()) +} diff --git a/crates/nockup/src/commands/channel/mod.rs b/crates/nockup/src/commands/channel/mod.rs new file mode 100644 index 000000000..8e83ba674 --- /dev/null +++ b/crates/nockup/src/commands/channel/mod.rs @@ -0,0 +1,13 @@ +pub mod set; +pub mod show; + +use anyhow::Result; + +use crate::cli::ChannelCommand; + +pub async fn run(command: ChannelCommand) -> Result<()> { + match command { + ChannelCommand::Set { channel } => set::run(&channel), + ChannelCommand::Show => show::run(), + } +} diff --git a/crates/nockup/src/commands/channel.rs b/crates/nockup/src/commands/channel/set.rs similarity index 71% rename from crates/nockup/src/commands/channel.rs rename to crates/nockup/src/commands/channel/set.rs index f6365b63e..4c411a848 100644 --- a/crates/nockup/src/commands/channel.rs +++ b/crates/nockup/src/commands/channel/set.rs @@ -2,9 +2,7 @@ use std::path::PathBuf; use anyhow::{Context, Result}; -use crate::cli::ChannelAction; - -fn set_channel(channel: &str) -> Result<()> { +pub fn run(channel: &str) -> Result<()> { // validate that is 'nightly' or 'stable', change later when more are supported if channel != "nightly" && channel != "stable" { return Err(anyhow::anyhow!("Invalid channel: {}", channel)); @@ -19,13 +17,6 @@ fn set_channel(channel: &str) -> Result<()> { Ok(()) } -fn show_channel() -> Result<()> { - let config = get_config()?; - println!("Default channel: {}", config["channel"]); - println!("Architecture: {}", config["architecture"]); - Ok(()) -} - fn get_cache_dir() -> Result { let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; Ok(home.join(".nockup")) @@ -39,10 +30,3 @@ fn get_config() -> Result { toml::de::from_str(&config_str).context("Failed to parse config file")?; Ok(config) } - -pub async fn run(command: ChannelAction) -> Result<()> { - match command { - ChannelAction::Set { channel } => set_channel(&channel), - ChannelAction::Show => show_channel(), - } -} diff --git a/crates/nockup/src/commands/channel/show.rs b/crates/nockup/src/commands/channel/show.rs new file mode 100644 index 000000000..a60d9b970 --- /dev/null +++ b/crates/nockup/src/commands/channel/show.rs @@ -0,0 +1,24 @@ +use std::path::PathBuf; + +use anyhow::{Context, Result}; + +pub fn run() -> Result<()> { + let config = get_config()?; + println!("Default channel: {}", config["channel"]); + println!("Architecture: {}", config["architecture"]); + Ok(()) +} + +fn get_cache_dir() -> Result { + let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; + Ok(home.join(".nockup")) +} + +fn get_config() -> Result { + let cache_dir = get_cache_dir()?; + let config_path = cache_dir.join("config.toml"); + let config_str = std::fs::read_to_string(&config_path).context("Failed to read config file")?; + let config: toml::Value = + toml::de::from_str(&config_str).context("Failed to parse config file")?; + Ok(config) +} diff --git a/crates/nockup/src/commands/common.rs b/crates/nockup/src/commands/common.rs index 6a7c8d94a..3c0c71f5b 100644 --- a/crates/nockup/src/commands/common.rs +++ b/crates/nockup/src/commands/common.rs @@ -1,6 +1,6 @@ use std::fs; use std::io::Read; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Stdio; use anyhow::{anyhow, Context, Result}; @@ -61,7 +61,7 @@ pub fn get_or_create_config() -> Result { Ok(config) } -fn write_default_config(config_path: &PathBuf) -> Result<()> { +fn write_default_config(config_path: &Path) -> Result<()> { let default_config = format!( r#"channel = "stable" architecture = "{}" @@ -72,7 +72,7 @@ architecture = "{}" Ok(()) } -pub async fn download_templates(cache_dir: &PathBuf) -> Result<()> { +pub async fn download_templates(cache_dir: &Path) -> Result<()> { let templates_dir = cache_dir.join("templates"); if has_existing_templates(&templates_dir).await? { @@ -86,7 +86,7 @@ pub async fn download_templates(cache_dir: &PathBuf) -> Result<()> { Ok(()) } -async fn has_existing_templates(templates_dir: &PathBuf) -> Result { +async fn has_existing_templates(templates_dir: &Path) -> Result { if !templates_dir.exists() { return Ok(false); } @@ -112,7 +112,7 @@ async fn has_existing_templates(templates_dir: &PathBuf) -> Result { Ok(false) } -async fn clone_templates(templates_dir: &PathBuf) -> Result<()> { +async fn clone_templates(templates_dir: &Path) -> Result<()> { let commit_id = get_git_commit_id().await?; let commit_file = templates_dir.join("commit.toml"); @@ -146,7 +146,10 @@ async fn clone_templates(templates_dir: &PathBuf) -> Result<()> { } } - let temp_dir = templates_dir.parent().unwrap().join("temp_repo"); + let temp_dir = templates_dir + .parent() + .expect("templates_dir should have a parent") + .join("temp_repo"); if temp_dir.exists() { fs::remove_dir_all(&temp_dir)?; } @@ -198,7 +201,10 @@ async fn clone_templates(templates_dir: &PathBuf) -> Result<()> { )); } - let manifests_dir = templates_dir.parent().unwrap().join("manifests"); + let manifests_dir = templates_dir + .parent() + .expect("templates_dir should have a parent") + .join("manifests"); if manifests_dir.exists() { fs::remove_dir_all(&manifests_dir)?; } @@ -227,7 +233,7 @@ async fn clone_templates(templates_dir: &PathBuf) -> Result<()> { Ok(()) } -fn copy_dir_recursive(src: &PathBuf, dst: &PathBuf) -> Result<()> { +fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { fs::create_dir_all(dst)?; for entry in fs::read_dir(src)? { @@ -246,11 +252,11 @@ fn copy_dir_recursive(src: &PathBuf, dst: &PathBuf) -> Result<()> { Ok(()) } -async fn update_templates(templates_dir: &PathBuf) -> Result<()> { +async fn update_templates(templates_dir: &Path) -> Result<()> { clone_templates(templates_dir).await } -pub async fn download_toolchain_files(cache_dir: &PathBuf) -> Result<()> { +pub async fn download_toolchain_files(cache_dir: &Path) -> Result<()> { let toolchain_dir = cache_dir.join("toolchains"); if has_existing_toolchain_files(&toolchain_dir).await? { @@ -270,7 +276,7 @@ pub async fn download_toolchain_files(cache_dir: &PathBuf) -> Result<()> { Ok(()) } -async fn has_existing_toolchain_files(toolchain_dir: &PathBuf) -> Result { +async fn has_existing_toolchain_files(toolchain_dir: &Path) -> Result { if !toolchain_dir.exists() { return Ok(false); } @@ -284,11 +290,11 @@ async fn has_existing_toolchain_files(toolchain_dir: &PathBuf) -> Result { Ok(false) } -async fn update_toolchain_files(toolchain_dir: &PathBuf) -> Result<()> { +async fn update_toolchain_files(toolchain_dir: &Path) -> Result<()> { clone_toolchain_files(toolchain_dir).await } -async fn clone_toolchain_files(toolchain_dir: &PathBuf) -> Result<()> { +async fn clone_toolchain_files(toolchain_dir: &Path) -> Result<()> { if toolchain_dir.exists() { fs::remove_dir_all(toolchain_dir)?; } @@ -299,8 +305,8 @@ async fn clone_toolchain_files(toolchain_dir: &PathBuf) -> Result<()> { "⬇️".green() ); - async fn get_latest_manifest(channel: &str, toolchain_dir: &PathBuf) -> Result<()> { - let manifest_file = format!("nockchain-manifest.toml"); + async fn get_latest_manifest(channel: &str, toolchain_dir: &Path) -> Result<()> { + let manifest_file = "nockchain-manifest.toml"; let output_file = toolchain_dir.join(format!("channel-nockup-{}.toml", channel)); println!("{} Fetching manifest for {}...", "🔍".yellow(), channel); @@ -431,7 +437,7 @@ pub async fn download_binaries(config: &toml::Value) -> Result<()> { let archive_path = download_file(&archive_url).await?; - verify_checksums(&archive_path, &archive_blake3, &archive_sha1).await?; + verify_checksums(&archive_path, archive_blake3, archive_sha1).await?; println!("{} Blake3 checksum passed.", "✅".green()); println!("{} SHA1 checksum passed.", "✅".green()); @@ -521,7 +527,15 @@ async fn verify_gpg_signature( } let output = Command::new("gpg") - .args(["--verify", signature_path.to_str().unwrap(), archive_path.to_str().unwrap()]) + .args([ + "--verify", + signature_path + .to_str() + .expect("signature_path should be valid UTF-8"), + archive_path + .to_str() + .expect("archive_path should be valid UTF-8"), + ]) .output() .await .context("Failed to execute gpg command")?; @@ -572,8 +586,12 @@ async fn verify_gpg_signature( .args([ "--verify", "--verbose", - signature_path.to_str().unwrap(), - archive_path.to_str().unwrap(), + signature_path + .to_str() + .expect("signature_path should be valid UTF-8"), + archive_path + .to_str() + .expect("archive_path should be valid UTF-8"), ]) .output() .await @@ -670,10 +688,10 @@ async fn download_file(url: &str) -> Result { )); } - let url_filename = url.split('/').last().unwrap_or("download"); + let url_filename = url.split('/').next_back().unwrap_or("download"); let timestamp = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .unwrap() + .expect("SystemTime should be after UNIX_EPOCH") .as_secs(); let filename = format!("nockup_{}_{}", timestamp, url_filename); let temp_file = std::env::temp_dir().join(filename); @@ -706,8 +724,8 @@ async fn verify_checksums( .map_err(|e| anyhow::anyhow!("Invalid hex SHA-1: {}", e))? .try_into() .map_err(|_| anyhow!("Failed to convert to fixed array (length mismatch)"))?; - if computed_sha1.as_slice() != &expected_sha1 { - let expected_hex = hex::encode(&expected_sha1); + if computed_sha1.as_slice() != expected_sha1 { + let expected_hex = hex::encode(expected_sha1); let computed_hex = hex::encode(computed_sha1.as_slice()); return Err(anyhow::anyhow!( "Checksum verification failed: expected {}, got {}", expected_hex, computed_hex @@ -716,7 +734,7 @@ async fn verify_checksums( Ok(()) } -pub async fn write_commit_details(cache_dir: &PathBuf) -> Result<()> { +pub async fn write_commit_details(cache_dir: &Path) -> Result<()> { let status_file = cache_dir.join("status.toml"); let mut config = toml::map::Map::new(); config.insert("commit".into(), toml::Value::Table(toml::map::Map::new())); @@ -731,10 +749,10 @@ pub async fn write_commit_details(cache_dir: &PathBuf) -> Result<()> { } async fn get_git_commit_id() -> Result { - let repo_url = format!("https://api.github.com/repos/nockchain/nockchain/commits/master"); + let repo_url = "https://api.github.com/repos/nockchain/nockchain/commits/master"; let client = reqwest::Client::new(); let response = client - .get(&repo_url) + .get(repo_url) .header("User-Agent", "nockup") .send() .await diff --git a/crates/nockup/src/commands/install.rs b/crates/nockup/src/commands/install.rs index afa0fd892..c5ce1bdd2 100644 --- a/crates/nockup/src/commands/install.rs +++ b/crates/nockup/src/commands/install.rs @@ -1,6 +1,6 @@ use std::fs; use std::io::Read; -use std::path::PathBuf; +use std::path::Path; use anyhow::{Context, Result}; use colored::Colorize; @@ -53,7 +53,7 @@ pub async fn run() -> Result<()> { Ok(()) } -async fn create_cache_structure(cache_dir: &PathBuf) -> Result<()> { +async fn create_cache_structure(cache_dir: &Path) -> Result<()> { println!("{} Creating cache directory structure...", "📁".green()); fs::create_dir_all(cache_dir)?; @@ -68,12 +68,16 @@ async fn create_cache_structure(cache_dir: &PathBuf) -> Result<()> { Ok(()) } -async fn prepend_path_to_shell_rc(bin_dir: &PathBuf) -> Result<()> { +async fn prepend_path_to_shell_rc(bin_dir: &Path) -> Result<()> { let shell = std::env::var("SHELL").unwrap_or_default(); let rc_file = if shell.contains("zsh") { - dirs::home_dir().unwrap().join(".zshrc") + dirs::home_dir() + .expect("home directory should exist") + .join(".zshrc") } else if shell.contains("bash") { - dirs::home_dir().unwrap().join(".bashrc") + dirs::home_dir() + .expect("home directory should exist") + .join(".bashrc") } else { return Ok(()); }; diff --git a/crates/nockup/src/commands/mod.rs b/crates/nockup/src/commands/mod.rs index 43736c438..c8da861f2 100644 --- a/crates/nockup/src/commands/mod.rs +++ b/crates/nockup/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod build; pub mod channel; pub mod common; pub mod init; -pub mod install; +pub mod package; pub mod run; +pub mod test_phase1; pub mod update; diff --git a/crates/nockup/src/commands/package.rs b/crates/nockup/src/commands/package.rs new file mode 100644 index 000000000..97ee6636b --- /dev/null +++ b/crates/nockup/src/commands/package.rs @@ -0,0 +1,29 @@ +pub mod add; +pub mod init; +pub mod install; +pub mod list; +pub mod purge; +pub mod remove; +pub mod update; + +use anyhow::Result; + +use crate::cli::PackageCommand; + +pub async fn run(cmd: PackageCommand) -> Result<()> { + match cmd { + PackageCommand::Init { name } => init::run(name).await, + PackageCommand::Add { name, version } => add::run(name, version).await, + PackageCommand::Remove { name } => remove::run(name).await, + PackageCommand::List => list::run().await, + PackageCommand::Install => install::run().await, + PackageCommand::Update => update::run().await, + PackageCommand::Purge { dry_run } => purge::purge(dry_run).await, + PackageCommand::Grab { .. } => { + anyhow::bail!("`nockup package grab` is deprecated – use `add`") + } + PackageCommand::GenerateProxy { .. } => { + anyhow::bail!("`generate-proxy` coming soon") + } + } +} diff --git a/crates/nockup/src/commands/package/add.rs b/crates/nockup/src/commands/package/add.rs new file mode 100644 index 000000000..840b1b1db --- /dev/null +++ b/crates/nockup/src/commands/package/add.rs @@ -0,0 +1,82 @@ +// src/commands/package/add.rs +use std::env; + +use anyhow::Result; +use colored::Colorize; + +use crate::manifest::HoonPackage; + +/// Add a dependency to nockapp.toml +pub async fn run(package_name: String, version: Option) -> Result<()> { + let cwd = env::current_dir()?; + let manifest_path = cwd.join("nockapp.toml"); + + if !manifest_path.exists() { + anyhow::bail!("No nockapp.toml found in current directory"); + } + + println!( + "{} Adding dependency {}...", + "📦".cyan(), + package_name.yellow() + ); + + // Load existing manifest + let mut manifest = match HoonPackage::load(&manifest_path)? { + Some(m) => m, + None => anyhow::bail!("Failed to load nockapp.toml"), + }; + + // Determine the version spec to use + let version_spec = if let Some(v) = version { + v + } else { + // For registry packages, we could fetch latest version + // For now, prompt user or use a sensible default + println!( + " {} No version specified, using latest available", + "→".cyan() + ); + // For kelvin packages, we might want to determine latest kelvin + // For now, let's default to requiring explicit version + anyhow::bail!( + "Please specify a version for '{}'. \ + Examples: @k409, ^1.2.3, @tag:v1.0.0, @branch:main", + package_name + ); + }; + + // Initialize dependencies map if it doesn't exist + let deps = manifest + .dependencies + .get_or_insert_with(std::collections::BTreeMap::new); + + // Check if package already exists + if deps.contains_key(&package_name) { + anyhow::bail!( + "Package '{}' is already in dependencies. \ + Use 'nockup package remove {}' first if you want to change the version.", + package_name, + package_name + ); + } + + // Add the dependency + use crate::manifest::DependencySpec; + deps.insert(package_name.clone(), DependencySpec::Simple(version_spec)); + + // Save the manifest + manifest.save(&manifest_path)?; + + println!( + "{} Added {} to nockapp.toml", + "✓".green(), + package_name.yellow() + ); + println!( + " Run {} to install the dependency", + "nockup package install".cyan() + ); + + Ok(()) +} diff --git a/crates/nockup/src/commands/package/init.rs b/crates/nockup/src/commands/package/init.rs new file mode 100644 index 000000000..0563f781e --- /dev/null +++ b/crates/nockup/src/commands/package/init.rs @@ -0,0 +1,56 @@ +// src/commands/package/init.rs +use std::env; + +use anyhow::Result; + +use crate::manifest::{HoonPackage, PackageMeta}; + +pub async fn run(name: Option) -> Result<()> { + let cwd = env::current_dir()?; + let dir_name = name + .as_deref() + .or_else(|| cwd.file_name().and_then(|s| s.to_str())) + .unwrap_or("my-hoon-lib"); + + let project_dir = if name.is_some() { + let dir = cwd.join(dir_name); + tokio::fs::create_dir_all(&dir).await?; + dir + } else { + cwd.clone() + }; + + let manifest_path = project_dir.join("hoon.toml"); + + if manifest_path.exists() { + anyhow::bail!("hoon.toml already exists in {}", project_dir.display()); + } + + let pkg = HoonPackage { + package: PackageMeta { + name: dir_name.to_string(), + version: None, + description: None, + authors: None, + license: None, + template: None, + template_commit: None, + }, + dependencies: Some(Default::default()), + }; + + pkg.save(&manifest_path)?; + + // Create minimal src dir + tokio::fs::create_dir_all(project_dir.join("src")).await?; + tokio::fs::write( + project_dir.join("src/lib.hoon"), + "|= *@ ^-(^ +<-)".as_bytes(), + ) + .await?; + + println!("Created library package: {}", dir_name); + println!(" {}", manifest_path.display()); + + Ok(()) +} diff --git a/crates/nockup/src/commands/package/install.rs b/crates/nockup/src/commands/package/install.rs new file mode 100644 index 000000000..a5df5da1e --- /dev/null +++ b/crates/nockup/src/commands/package/install.rs @@ -0,0 +1,626 @@ +// src/commands/package/install.rs +use std::path::{Path, PathBuf}; +use std::{env, fs}; + +use anyhow::{anyhow, Context, Result}; +use colored::Colorize; + +use crate::cache::PackageCache; +use crate::manifest::{HoonPackage, LockSource, LockedPackage, NockAppLock}; +use crate::resolver::Resolver; + +pub async fn run() -> Result<()> { + let cwd = env::current_dir()?; + let manifest_path = cwd.join("nockapp.toml"); + + // Load manifest + let manifest = match HoonPackage::load(&manifest_path)? { + Some(m) => m, + None => anyhow::bail!("No nockapp.toml found in {}", cwd.display()), + }; + + println!( + "{} Installing dependencies for {}", + "📦".cyan(), + manifest.package.name.yellow() + ); + println!(); + + // Determine the project directory based on the package name + let project_dir = cwd.join(&manifest.package.name); + + // Check if project directory exists + if !project_dir.exists() { + anyhow::bail!( + "Project directory '{}' not found. Run `nockup project init` first.", + manifest.package.name + ); + } + + // Initialize resolver + let resolver = Resolver::new()?; + let cache = PackageCache::new()?; + + // Resolve dependency graph + let graph = resolver.resolve(&manifest).await?; + + if graph.packages.is_empty() { + println!("{} No dependencies to install", "✓".green()); + + // Create empty lockfile if needed + let lock_path = project_dir.join("nockapp.lock"); + if !lock_path.exists() { + let lockfile = NockAppLock { + package: Vec::new(), + }; + lockfile.save(&lock_path)?; + println!(" Created empty nockapp.lock"); + } + + return Ok(()); + } + + println!(); + println!("{} Installing packages...", "📥".cyan()); + println!(); + + // Create hoon/packages, hoon/lib, and hoon/sur directories if they don't exist + let hoon_dir = project_dir.join("hoon"); + let packages_dir = hoon_dir.join("packages"); + let lib_dir = hoon_dir.join("lib"); + let sur_dir = hoon_dir.join("sur"); + fs::create_dir_all(&packages_dir).context("Failed to create hoon/packages directory")?; + fs::create_dir_all(&lib_dir).context("Failed to create hoon/lib directory")?; + fs::create_dir_all(&sur_dir).context("Failed to create hoon/sur directory")?; + + // Install packages in topological order + let mut locked_packages = Vec::new(); + + for pkg_name in &graph.install_order { + let pkg = graph + .packages + .get(pkg_name) + .ok_or_else(|| anyhow!("Missing package '{}' in resolved graph", pkg_name))?; + + let version_str = pkg.version_spec.to_canonical_string(); + + // For wildcard/latest versions ("*"), display as "latest" and use commit for cache + let (display_version, cache_version) = if version_str == "*" { + ("latest".to_string(), format!("commit:{}", pkg.commit)) + } else { + (version_str.clone(), version_str.clone()) + }; + + println!( + " {} Installing {}@{}...", + "→".cyan(), + pkg.name.yellow(), + display_version.cyan() + ); + + // Check if already in cache using the cache version + let cached_path = cache.package_path(&pkg.name, &cache_version); + + if !cached_path.exists() { + // This shouldn't happen since resolver already cached it, + // but handle it gracefully + println!( + " {} Package not in cache (this is unexpected)", + "⚠".yellow() + ); + continue; + } + + // Install to hoon/packages/@/ + // Sanitize package name (replace / with -) for use in directory names + let safe_name = sanitize_package_name(&pkg.name); + let install_dir = packages_dir.join(format!("{}@{}", safe_name, display_version)); + + if install_dir.exists() { + println!(" {} Already installed, skipping", "✓".green()); + } else { + // Copy from cache to hoon/packages/ + copy_dir_recursive(cached_path.as_path(), install_dir.as_path()).with_context( + || format!("Failed to install package to {}", install_dir.display()), + )?; + + println!( + " {} Installed to {}", + "✓".green(), + format!("hoon/packages/{}@{}", pkg.name, display_version).cyan() + ); + } + + // Create symlinks for .hoon files + // If install_path is specified (from registry), preserve directory structure + // Otherwise, link to hoon/lib/ + // println!(" {} Creating symlinks...", "🔗".cyan()); + // println!("pkg: {:?}", pkg); + // if pkg.source_file == Some("seq.hoon".to_string()) { + // println!("source_file is seq.hoon"); + // println!("install_dir: {:?}", install_dir); + // println!("lib_dir: {:?}", lib_dir); + // println!("pkg.name: {:?}", pkg.name); + // println!("pkg.source_file: {:?}", pkg.source_file.as_deref()); + // link_package_files(&install_dir, &lib_dir, &pkg.name, pkg.install_path.as_deref(), pkg.source_file.as_deref())?; + // } + // should have install_path AND files + // if let Some(ref install_path) = pkg.install_path { + if let (Some(ref install_path), Some(_)) = (&pkg.install_path, &pkg.source_file) { + println!("install_path: {:?}", install_path); + link_registry_package( + install_dir.as_path(), + hoon_dir.as_path(), + install_path, + &pkg.name, + pkg.source_file.as_deref(), + )?; + } else { + println!("No install_path specified, linking to hoon/lib/ and hoon/sur/"); + link_package_files( + install_dir.as_path(), + lib_dir.as_path(), + sur_dir.as_path(), + &pkg.name, + pkg.source_path.as_deref(), + pkg.source_file.as_deref(), + )?; + } + + // Add to lockfile + locked_packages.push(LockedPackage { + name: pkg.name.clone(), + version: display_version.clone(), + source: LockSource::Git { + url: pkg.source_url.clone(), + commit: pkg.commit.clone(), + path: pkg.source_path.clone(), + }, + }); + } + + println!(); + println!( + "{} Installed {} packages", + "✓".green(), + graph.packages.len() + ); + + // Generate/update lockfile + let lock_path = project_dir.join("nockapp.lock"); + let lockfile = NockAppLock { + package: locked_packages, + }; + + lockfile.save(&lock_path)?; + println!(" Updated nockapp.lock"); + + Ok(()) +} + +/// Sanitize package name for use in directory names (replace / with -) +fn sanitize_package_name(name: &str) -> String { + name.replace('/', "-") +} + +/// Recursively copy a directory +fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { + fs::create_dir_all(dst)?; + + for entry in fs::read_dir(src)? { + let entry = entry?; + let path = entry.path(); + let file_name = entry.file_name(); + let dst_path = dst.join(&file_name); + + if path.is_dir() { + copy_dir_recursive(&path, &dst_path)?; + } else { + fs::copy(&path, &dst_path)?; + } + } + + Ok(()) +} + +/// Create symlinks for registry packages that preserve directory structure +/// For example: +/// - nockchain/common/zose with install_path="common" and file="zose.hoon" +/// creates arcadia/hoon/common/zose.hoon -> ../packages/nockchain-common-zose@latest/zose.hoon +/// - urbit/zuse with install_path="sys" and file="zuse.hoon" +/// creates arcadia/hoon/sys/zuse.hoon -> ../packages/urbit-zuse@latest/zuse.hoon +fn link_registry_package( + package_dir: &Path, + hoon_dir: &Path, + install_path: &str, + package_name: &str, + specific_file: Option<&str>, +) -> Result<()> { + let package_dir_name = package_dir_basename(package_dir)?; + + // Strip "hoon/" prefix from install_path if present (it's already included in hoon_dir) + println!("install_path before stripping: {:?}", install_path); + let relative_path = install_path.strip_prefix("hoon/").unwrap_or(install_path); + println!("relative_path: {:?}", relative_path); + + // Create the target directory structure in hoon/ + let target_dir = hoon_dir.join(relative_path); + fs::create_dir_all(&target_dir) + .with_context(|| format!("Failed to create directory {}", target_dir.display()))?; + println!(" specific_file is {:?}", specific_file); + + if let Some(filename) = specific_file { + // Link only the specific file + let source_file = package_dir.join(filename); + if !source_file.exists() { + anyhow::bail!("Specific file {} not found in package {}", filename, package_name); + } + + let link_path = target_dir.join(filename); + println!(" link_path: {:?}", link_path); + + // Remove existing symlink if it exists + if link_path.exists() || link_path.is_symlink() { + fs::remove_file(&link_path).with_context(|| { + format!("Failed to remove existing symlink {}", link_path.display()) + })?; + } + + // Create relative symlink + // Calculate path from target_dir back to packages/ + // For hoon/common/, we need: ../../packages/package@version/file + let depth = relative_path.split('/').filter(|s| !s.is_empty()).count(); + let mut relative_target = PathBuf::new(); + for _ in 0..depth { + relative_target.push(".."); + } + relative_target.push("packages"); + relative_target.push(Path::new(&package_dir_name)); + relative_target.push(filename); + println!(" relative_target: {:?}", relative_target); + + #[cfg(unix)] + { + std::os::unix::fs::symlink(&relative_target, &link_path).with_context(|| { + format!( + "Failed to create symlink {} -> {}", + link_path.display(), + relative_target.display() + ) + })?; + } + + #[cfg(windows)] + { + std::os::windows::fs::symlink_file(&relative_target, &link_path).with_context( + || { + format!( + "Failed to create symlink {} -> {}", + link_path.display(), + relative_target.display() + ) + }, + )?; + } + + println!( + " {} Linked {} to hoon/{}/", + "🔗".cyan(), + filename.yellow(), + relative_path.cyan() + ); + } else { + // No specific file - link all .hoon files from common library/structure paths + // When there's no specific file, we assume the package follows Urbit desk structure + // and link lib/ files to hoon/lib/, sur/ files to hoon/sur/, etc. + let source_paths = vec![ + ("lib", package_dir.join("lib")), + ("lib", package_dir.join("desk").join("lib")), + ("sur", package_dir.join("desk").join("sur")), + ]; + + let mut found_files = false; + + for (dest_subdir, source_dir) in source_paths { + if !source_dir.exists() { + continue; + } + + // Determine target directory (hoon/lib or hoon/sur) + let dest_dir = hoon_dir.join(dest_subdir); + fs::create_dir_all(&dest_dir) + .with_context(|| format!("Failed to create directory {}", dest_dir.display()))?; + + // Link .hoon files from this directory + for entry in fs::read_dir(&source_dir) + .with_context(|| format!("Failed to read directory {}", source_dir.display()))? + { + let entry = entry?; + let path = entry.path(); + + if path.is_file() { + if let Some(extension) = path.extension() { + if extension == "hoon" { + found_files = true; + let Some(file_name) = path.file_name() else { + continue; + }; + let link_path = dest_dir.join(file_name); + + // Remove existing symlink if it exists + if link_path.exists() || link_path.is_symlink() { + fs::remove_file(&link_path).with_context(|| { + format!( + "Failed to remove existing symlink {}", + link_path.display() + ) + })?; + } + + // Calculate relative path from package_root to the file + let relative_from_package = + path.strip_prefix(package_dir).unwrap_or(&path); + + // Build symlink path from hoon/{dest_subdir}/ to packages/ + // For hoon/lib/, we need: ../packages/package@version/desk/lib/file.hoon + let mut relative_target = PathBuf::new(); + relative_target.push(".."); + relative_target.push("packages"); + relative_target.push(Path::new(&package_dir_name)); + relative_target.push(relative_from_package); + + #[cfg(unix)] + { + std::os::unix::fs::symlink(&relative_target, &link_path) + .with_context(|| { + format!( + "Failed to create symlink {} -> {}", + link_path.display(), + relative_target.display() + ) + })?; + } + + #[cfg(windows)] + { + std::os::windows::fs::symlink_file(&relative_target, &link_path) + .with_context(|| { + format!( + "Failed to create symlink {} -> {}", + link_path.display(), + relative_target.display() + ) + })?; + } + + println!( + " {} Linked {} to hoon/{}/", + "🔗".cyan(), + file_name.to_string_lossy().yellow(), + dest_subdir.cyan() + ); + } + } + } + } + } + + if !found_files { + println!( + " {} No .hoon files found in package {}", + "⚠".yellow(), + package_name.yellow() + ); + } + } + + Ok(()) +} + +/// Create symlinks in hoon/lib/ and hoon/sur/ for .hoon files in the package +/// If `specific_file` is Some, only link that file. Otherwise, link all .hoon files. +fn link_package_files( + package_dir: &Path, + lib_dir: &Path, + sur_dir: &Path, + package_name: &str, + _path_from_root: Option<&str>, + specific_file: Option<&str>, +) -> Result<()> { + let package_dir_name = package_dir_basename(package_dir)?; + println!(" specific_file is {:?}", specific_file); + if let Some(filename) = specific_file { + // Link only the specific file + // The filename may include subdirectories (e.g., "lib/seq.hoon") + // The package is cached with contents of source_path, so we don't prepend it + let source_file = package_dir.join(filename); + println!(" source_file: {:?}", source_file); + if !source_file.exists() { + anyhow::bail!("Specific file {} not found in package {}", filename, package_name); + } + + // Extract just the filename (last component) for the link path in hoon/lib/ + let file_name = PathBuf::from(filename) + .file_name() + .ok_or_else(|| anyhow::anyhow!("Invalid filename: {}", filename))? + .to_os_string(); + let link_path = lib_dir.join(&file_name); + println!(" link_path: {:?}", link_path); + + // Remove existing symlink if it exists + if link_path.exists() || link_path.is_symlink() { + fs::remove_file(&link_path).with_context(|| { + format!("Failed to remove existing symlink {}", link_path.display()) + })?; + } + + // Create relative symlink + // filename may include subdirectories (e.g., "lib/seq.hoon") + let mut relative_target = PathBuf::from("../packages"); + relative_target.push(Path::new(&package_dir_name)); + relative_target.push(Path::new(filename)); + println!(" relative_target: {:?}", relative_target); + + #[cfg(unix)] + { + std::os::unix::fs::symlink(&relative_target, &link_path).with_context(|| { + format!( + "Failed to create symlink {} -> {}", + link_path.display(), + relative_target.display() + ) + })?; + } + + #[cfg(windows)] + { + std::os::windows::fs::symlink_file(&relative_target, &link_path).with_context( + || { + format!( + "Failed to create symlink {} -> {}", + link_path.display(), + relative_target.display() + ) + }, + )?; + } + + println!( + " {} Linked {} to hoon/lib/", + "🔗".cyan(), + filename.yellow() + ); + + return Ok(()); + } + + // Link all .hoon files - check common library directory patterns + let lib_source_dirs = vec![ + package_dir.join("lib"), + package_dir.join("src").join("lib"), + package_dir.join("desk").join("lib"), + ]; + + let sur_source_dirs = vec![ + package_dir.join("sur"), + package_dir.join("src").join("sur"), + package_dir.join("desk").join("sur"), + ]; + + let mut found_files = false; + + // Link lib files + for source_dir in lib_source_dirs { + if !source_dir.exists() { + continue; + } + + // Link .hoon files from this lib directory (non-recursive - only direct children) + link_hoon_files_from_dir(source_dir.as_path(), package_dir, lib_dir, &mut found_files)?; + } + + // Link sur files + for source_dir in sur_source_dirs { + if !source_dir.exists() { + continue; + } + + // Link .hoon files from this sur directory (non-recursive - only direct children) + link_hoon_files_from_dir(source_dir.as_path(), package_dir, sur_dir, &mut found_files)?; + } + + if !found_files { + println!( + " {} No .hoon files found in package {}", + "⚠".yellow(), + package_name.yellow() + ); + } + + Ok(()) +} + +/// Link .hoon files from a lib directory (non-recursive - only direct children) +fn link_hoon_files_from_dir( + source_dir: &Path, + package_root: &Path, + lib_dir: &Path, + found_files: &mut bool, +) -> Result<()> { + let package_dir_name = package_dir_basename(package_root)?; + for entry in fs::read_dir(source_dir) + .with_context(|| format!("Failed to read directory {}", source_dir.display()))? + { + let entry = entry?; + let path = entry.path(); + + // Only process files, not subdirectories + if path.is_file() { + if let Some(extension) = path.extension() { + if extension == "hoon" { + let Some(file_name) = path.file_name() else { + continue; + }; + *found_files = true; + let link_path = lib_dir.join(file_name); + + // Remove existing symlink if it exists + if link_path.exists() || link_path.is_symlink() { + fs::remove_file(&link_path).with_context(|| { + format!("Failed to remove existing symlink {}", link_path.display()) + })?; + } + + // Create relative path from hoon/lib to the file + // Calculate the relative path from package_root to the actual file + let relative_from_package = path.strip_prefix(package_root).unwrap_or(&path); + + let mut relative_target = PathBuf::from("../packages"); + relative_target.push(Path::new(&package_dir_name)); + relative_target.push(relative_from_package); + + #[cfg(unix)] + { + std::os::unix::fs::symlink(&relative_target, &link_path).with_context( + || { + format!( + "Failed to create symlink {} -> {}", + link_path.display(), + relative_target.display() + ) + }, + )?; + } + + #[cfg(windows)] + { + std::os::windows::fs::symlink_file(&relative_target, &link_path) + .with_context(|| { + format!( + "Failed to create symlink {} -> {}", + link_path.display(), + relative_target.display() + ) + })?; + } + + println!( + " {} Linked {} to hoon/lib/", + "🔗".cyan(), + file_name.to_string_lossy().yellow() + ); + } + } + } + // Skip subdirectories - we only want files directly in lib/ + } + + Ok(()) +} + +fn package_dir_basename(package_dir: &Path) -> Result { + package_dir + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .ok_or_else(|| anyhow!("Package directory '{}' has no name", package_dir.display())) +} diff --git a/crates/nockup/src/commands/package/list.rs b/crates/nockup/src/commands/package/list.rs new file mode 100644 index 000000000..43ccbdead --- /dev/null +++ b/crates/nockup/src/commands/package/list.rs @@ -0,0 +1,148 @@ +// src/commands/package/list.rs +use std::env; + +use anyhow::Result; +use colored::Colorize; + +use crate::manifest::{HoonPackage, NockAppLock}; + +/// List all dependencies from nockapp.toml and their installation status +pub async fn run() -> Result<()> { + let cwd = env::current_dir()?; + let manifest_path = cwd.join("nockapp.toml"); + + if !manifest_path.exists() { + anyhow::bail!("No nockapp.toml found in current directory"); + } + + // Load manifest + let manifest = match HoonPackage::load(&manifest_path)? { + Some(m) => m, + None => anyhow::bail!("Failed to load nockapp.toml"), + }; + + // Determine the project directory based on the package name + let project_dir = cwd.join(&manifest.package.name); + + // Check if project directory exists + if !project_dir.exists() { + anyhow::bail!( + "Project directory '{}' not found. Run `nockup project init` first.", + manifest.package.name + ); + } + + // Load lockfile if it exists + let lock_path = project_dir.join("nockapp.lock"); + let lockfile = NockAppLock::load(&lock_path)?; + + println!("{} Package dependencies:", "📦".cyan()); + println!(); + + // Check if there are any dependencies + let deps = match manifest.dependencies { + Some(ref deps) if !deps.is_empty() => deps, + _ => { + println!(" No dependencies found"); + return Ok(()); + } + }; + + // Create a map of installed packages from lockfile + let installed: std::collections::HashMap = lockfile + .package + .iter() + .map(|pkg| (pkg.name.clone(), pkg.version.clone())) + .collect(); + + // List each dependency + for (name, spec) in deps { + let spec_str = match spec { + crate::manifest::DependencySpec::Simple(v) => v.clone(), + crate::manifest::DependencySpec::Version { version } => version.clone(), + crate::manifest::DependencySpec::Full { + version, + tag, + branch, + commit, + .. + } => { + // Determine which version identifier to show + if let Some(v) = version { + v.clone() + } else if let Some(t) = tag { + format!("@tag:{}", t) + } else if let Some(b) = branch { + format!("@branch:{}", b) + } else if let Some(c) = commit { + format!("@commit:{}", &c[..8.min(c.len())]) + } else { + "?".to_string() + } + } + }; + + // Check installation status + if let Some(installed_version) = installed.get(name) { + // Verify the package directory exists + // Package directories use hyphens instead of slashes + let package_dir_name = format!("{}@{}", name.replace('/', "-"), installed_version); + let package_dir = project_dir + .join("hoon") + .join("packages") + .join(package_dir_name); + + if package_dir.exists() { + println!( + " {} {} {} (installed: {})", + "✓".green(), + name.yellow(), + spec_str.cyan(), + installed_version.cyan() + ); + } else { + println!( + " {} {} {} (in lockfile but missing from disk)", + "⚠".yellow(), + name.yellow(), + spec_str.cyan() + ); + } + } else { + println!( + " {} {} {} (not installed)", + "✗".red(), + name.yellow(), + spec_str.cyan() + ); + } + } + + println!(); + + // Show summary + let total = deps.len(); + let installed_count = deps + .keys() + .filter(|name| installed.contains_key(*name)) + .count(); + + if installed_count == total { + println!("{} All {} packages installed", "✓".green(), total); + } else { + println!( + "{} {}/{} packages installed", + "→".cyan(), + installed_count, + total + ); + if installed_count < total { + println!( + " Run {} to install missing packages", + "nockup package install".cyan() + ); + } + } + + Ok(()) +} diff --git a/crates/nockup/src/commands/package/purge.rs b/crates/nockup/src/commands/package/purge.rs new file mode 100644 index 000000000..5fbfee019 --- /dev/null +++ b/crates/nockup/src/commands/package/purge.rs @@ -0,0 +1,86 @@ +use std::fs; +use std::path::PathBuf; + +use anyhow::Result; + +use crate::commands::common::get_cache_dir; + +/// Clear the package cache +pub async fn purge(dry_run: bool) -> Result<()> { + let cache_dir = get_cache_dir()?; + let packages_cache = cache_dir.join("cache").join("packages"); + + if !packages_cache.exists() { + println!("No package cache found at {}", packages_cache.display()); + return Ok(()); + } + + println!("Package cache location: {}", packages_cache.display()); + + // Collect all cached packages + let mut total_size = 0u64; + let mut package_count = 0; + + if let Ok(entries) = fs::read_dir(&packages_cache) { + for entry in entries.flatten() { + if let Ok(_metadata) = entry.metadata() { + total_size += calculate_dir_size(&entry.path())?; + package_count += 1; + + if dry_run { + println!(" Would delete: {}", entry.file_name().to_string_lossy()); + } + } + } + } + + let size_mb = total_size as f64 / 1_048_576.0; + + if dry_run { + println!( + "\nDry run: {} packages would be deleted ({:.2} MB)", + package_count, size_mb + ); + println!("Run without --dry-run to actually delete the cache"); + } else { + if package_count == 0 { + println!("Cache is already empty"); + return Ok(()); + } + + println!("Deleting {} packages ({:.2} MB)...", package_count, size_mb); + fs::remove_dir_all(&packages_cache)?; + fs::create_dir_all(&packages_cache)?; + + // Also clear the cache index + let cache_index = cache_dir.join("cache").join("cache-index.json"); + if cache_index.exists() { + fs::remove_file(&cache_index)?; + println!("Cache index cleared"); + } + + println!("Package cache cleared successfully"); + } + + Ok(()) +} + +/// Calculate the total size of a directory recursively +fn calculate_dir_size(path: &PathBuf) -> Result { + let mut size = 0u64; + + if path.is_dir() { + if let Ok(entries) = fs::read_dir(path) { + for entry in entries.flatten() { + let entry_path = entry.path(); + if entry_path.is_dir() { + size += calculate_dir_size(&entry_path)?; + } else if let Ok(metadata) = entry.metadata() { + size += metadata.len(); + } + } + } + } + + Ok(size) +} diff --git a/crates/nockup/src/commands/package/remove.rs b/crates/nockup/src/commands/package/remove.rs new file mode 100644 index 000000000..347920afe --- /dev/null +++ b/crates/nockup/src/commands/package/remove.rs @@ -0,0 +1,122 @@ +// src/commands/package/remove.rs +use std::{env, fs}; + +use anyhow::{anyhow, Context, Result}; +use colored::Colorize; + +use crate::manifest::HoonPackage; + +/// Remove a dependency from nockapp.toml and clean up installed files +pub async fn run(package_name: String) -> Result<()> { + let cwd = env::current_dir()?; + let manifest_path = cwd.join("nockapp.toml"); + + if !manifest_path.exists() { + anyhow::bail!("No nockapp.toml found in current directory"); + } + + println!( + "{} Removing dependency {}...", + "📦".cyan(), + package_name.yellow() + ); + + // Load existing manifest + let mut manifest = match HoonPackage::load(&manifest_path)? { + Some(m) => m, + None => anyhow::bail!("Failed to load nockapp.toml"), + }; + + // Determine the project directory based on the package name + let project_dir = cwd.join(&manifest.package.name); + + // Check if project directory exists + if !project_dir.exists() { + anyhow::bail!( + "Project directory '{}' not found. Run `nockup project init` first.", + manifest.package.name + ); + } + + // Check if dependencies exist + let deps = manifest + .dependencies + .as_mut() + .ok_or_else(|| anyhow!("No dependencies found in nockapp.toml"))?; + + // Check if package exists + if !deps.contains_key(&package_name) { + anyhow::bail!("Package '{}' not found in dependencies", package_name); + } + + // Remove the dependency + deps.remove(&package_name); + + // Save the manifest + manifest.save(&manifest_path)?; + + println!( + "{} Removed {} from nockapp.toml", + "✓".green(), + package_name.yellow() + ); + + // Clean up installed files + // Note: We don't know the exact version that was installed, so we'll look for any version + let packages_dir = project_dir.join("hoon").join("packages"); + if packages_dir.exists() { + if let Ok(entries) = fs::read_dir(&packages_dir) { + for entry in entries.flatten() { + let dir_name = entry.file_name(); + let dir_name_str = dir_name.to_string_lossy(); + + // Check if directory name starts with "packagename@" + if dir_name_str.starts_with(&format!("{}@", package_name)) { + let package_path = entry.path(); + println!(" {} Removing {}", "🗑".cyan(), dir_name_str.yellow()); + fs::remove_dir_all(&package_path) + .with_context(|| format!("Failed to remove {}", package_path.display()))?; + } + } + } + } + + // Clean up symlinks in hoon/lib + let lib_dir = project_dir.join("hoon").join("lib"); + if lib_dir.exists() { + println!(" {} Cleaning up symlinks in hoon/lib/", "🧹".cyan()); + + if let Ok(entries) = fs::read_dir(&lib_dir) { + for entry in entries.flatten() { + let path = entry.path(); + + // Check if it's a symlink + if path.is_symlink() { + // Read the symlink target + if let Ok(target) = fs::read_link(&path) { + let target_str = target.to_string_lossy(); + + // Check if symlink points to removed package + if target_str.contains(&format!("{}@", package_name)) { + let file_name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + println!(" {} Removing symlink {}", "→".cyan(), file_name.yellow()); + fs::remove_file(&path).with_context(|| { + format!("Failed to remove symlink {}", path.display()) + })?; + } + } + } + } + } + } + + println!( + " Run {} to update dependencies", + "nockup package install".cyan() + ); + + Ok(()) +} diff --git a/crates/nockup/src/commands/package/update.rs b/crates/nockup/src/commands/package/update.rs new file mode 100644 index 000000000..ce66d9d65 --- /dev/null +++ b/crates/nockup/src/commands/package/update.rs @@ -0,0 +1,194 @@ +// src/commands/package/update.rs +use std::collections::HashMap; +use std::env; + +use anyhow::Result; +use colored::Colorize; + +use crate::manifest::{DependencySpec, HoonPackage, LockSource, NockAppLock}; +use crate::resolver::Resolver; + +/// Update dependencies to their latest compatible versions +pub async fn run() -> Result<()> { + let cwd = env::current_dir()?; + let manifest_path = cwd.join("nockapp.toml"); + + // Load manifest + let manifest = match HoonPackage::load(&manifest_path)? { + Some(m) => m, + None => anyhow::bail!("No nockapp.toml found in {}", cwd.display()), + }; + + // Determine the project directory based on the package name + let project_dir = cwd.join(&manifest.package.name); + + // Check if project directory exists + if !project_dir.exists() { + anyhow::bail!( + "Project directory '{}' not found. Run `nockup project init` first.", + manifest.package.name + ); + } + + println!( + "{} Checking for updates to dependencies for {}", + "🔄".cyan(), + manifest.package.name.yellow() + ); + println!(); + + // Check if there are any dependencies + let deps = match &manifest.dependencies { + Some(deps) if !deps.is_empty() => deps, + _ => { + println!("{} No dependencies to update", "✓".green()); + return Ok(()); + } + }; + + // Load existing lockfile if it exists + let lock_path = project_dir.join("nockapp.lock"); + let old_lockfile = NockAppLock::load(&lock_path)?; + let old_versions: HashMap = old_lockfile + .package + .iter() + .map(|pkg| (pkg.name.clone(), pkg.version.clone())) + .collect(); + + // Check each dependency to see if it can/should be updated + let mut updates_available = Vec::new(); + + for (name, spec) in deps { + // Determine if this dependency should be updated + let should_update = match spec { + DependencySpec::Simple(v) => { + // Check if it's a minimum version spec (starts with ^) or "latest" + v.starts_with('^') || v == "*" || v == "latest" + } + DependencySpec::Version { version } => { + version.starts_with('^') || version == "*" || version == "latest" + } + DependencySpec::Full { + branch, + commit, + tag, + version, + .. + } => { + // Only update if using a branch (not a fixed commit or tag) + if branch.is_some() && commit.is_none() && tag.is_none() { + true + } else if let Some(v) = version { + v.starts_with('^') || v == "*" || v == "latest" + } else { + false + } + } + }; + + if should_update { + if let Some(old_version) = old_versions.get(name) { + updates_available.push((name.clone(), old_version.clone())); + } + } + } + + if updates_available.is_empty() { + println!( + "{} All dependencies are up to date (or pinned to fixed versions)", + "✓".green() + ); + return Ok(()); + } + + println!("{} Checking for updates...", "🔍".cyan()); + println!(); + + // Re-resolve dependencies (this will fetch latest commits for branches, etc.) + let resolver = Resolver::new()?; + let new_graph = resolver.resolve(&manifest).await?; + + // Compare old and new versions + let mut has_updates = false; + for (name, old_version) in &updates_available { + if let Some(new_pkg) = new_graph.packages.get(name) { + let new_version = new_pkg.version_spec.to_canonical_string(); + + // For git-based dependencies, compare commits + let old_commit = + if let Some(old_pkg) = old_lockfile.package.iter().find(|p| &p.name == name) { + match &old_pkg.source { + LockSource::Git { commit, .. } => Some(commit.as_str()), + LockSource::Path { .. } => None, + } + } else { + None + }; + let new_commit = Some(new_pkg.commit.as_str()); + + // Compare commits to detect updates + // Note: For Kelvin versions, lower numbers are newer (k408 > k409) + // But we compare commits, not kelvin numbers, so this works correctly + if old_commit != new_commit { + has_updates = true; + + // Check if this is a kelvin version update + let is_kelvin_update = + old_version.starts_with("@k") || old_version.starts_with("k"); + + println!( + " {} {} {} → {}{}", + "↑".green(), + name.yellow(), + old_version.cyan(), + new_version.cyan(), + if is_kelvin_update { + " (kelvin ↓ = newer)" + } else { + "" + } + ); + if let (Some(old_c), Some(new_c)) = (old_commit, new_commit) { + if old_c != new_c { + println!( + " commit: {} → {}", + &old_c[..8.min(old_c.len())], + &new_c[..8.min(new_c.len())] + ); + } + } + } else { + println!( + " {} {} {} (no update available)", + "→".blue(), + name.yellow(), + old_version.cyan() + ); + } + } + } + + if !has_updates { + println!(); + println!( + "{} All updateable dependencies are already at their latest versions", + "✓".green() + ); + return Ok(()); + } + + println!(); + println!( + "{} Running package install to apply updates...", + "📦".cyan() + ); + println!(); + + // Run package install to actually install the updates + crate::commands::package::install::run().await?; + + println!(); + println!("{} Updates applied successfully!", "✓".green()); + + Ok(()) +} diff --git a/crates/nockup/src/commands/test_phase1.rs b/crates/nockup/src/commands/test_phase1.rs new file mode 100644 index 000000000..724dfcae3 --- /dev/null +++ b/crates/nockup/src/commands/test_phase1.rs @@ -0,0 +1,171 @@ +/// Temporary test command to demonstrate Phase 1 infrastructure +/// This will be removed once full resolver is implemented +use anyhow::Result; +use colored::Colorize; + +use crate::cache::PackageCache; +use crate::git_fetcher::{GitFetcher, GitSpec}; +use crate::resolver::VersionSpec; + +pub async fn run() -> Result<()> { + println!("{}", "=== Testing Phase 1 Infrastructure ===".cyan().bold()); + println!(); + + // Initialize cache + println!("{} Initializing package cache...", "📦".green()); + let cache = PackageCache::new()?; + println!( + " Cache directory: {}", + cache.root().display().to_string().cyan() + ); + println!(); + + // Initialize git fetcher + println!("{} Initializing Git fetcher...", "🔧".green()); + let git_fetcher = GitFetcher::new(cache.git_dir()); + println!(); + + // Test 1: Parse version specs + println!("{}", "--- Test 1: Version Spec Parsing ---".yellow().bold()); + let specs = vec!["k409", "commit:abc123def", "tag:v1.2.3", "branch:main", "^1.2.0"]; + + for spec_str in specs { + match VersionSpec::parse(spec_str) { + Ok(spec) => { + println!( + " ✓ Parsed '{}' → {}", + spec_str.cyan(), + spec.to_canonical_string().green() + ); + } + Err(e) => { + println!(" ✗ Failed to parse '{}': {}", spec_str.red(), e); + } + } + } + println!(); + + // Test 2: Fetch a real repository + println!( + "{}", + "--- Test 2: Git Repository Fetching ---".yellow().bold() + ); + println!(" Fetching github.com/urbit/urbit (may take a moment)..."); + + let spec = GitSpec { + url: "https://github.com/urbit/urbit".to_string(), + commit: None, + tag: Some("409k".to_string()), // Using kelvin tag format + branch: None, + path: None, + install_path: None, + file: None, + }; + + match git_fetcher.fetch(&spec).await { + Ok(path) => { + println!( + " {} Fetched to: {}", + "✓".green(), + path.display().to_string().cyan() + ); + + // Check if path exists + if path.exists() { + println!(" {} Repository is cached!", "✓".green()); + } + } + Err(e) => { + println!(" {} Fetch failed: {}", "✗".red(), e); + println!(" (This is expected if git or network is unavailable)"); + } + } + println!(); + + // Test 3: List tags from a repo + println!("{}", "--- Test 3: List Repository Tags ---".yellow().bold()); + println!(" Fetching tags from github.com/urbit/urbit..."); + + match git_fetcher + .list_tags("https://github.com/urbit/urbit") + .await + { + Ok(tags) => { + let display_count = 10.min(tags.len()); + println!( + " {} Found {} tags (showing first {}):", + "✓".green(), + tags.len(), + display_count + ); + for tag in tags.iter().take(display_count) { + println!(" - {}", tag.cyan()); + } + } + Err(e) => { + println!(" {} Failed to list tags: {}", "✗".red(), e); + println!(" (This is expected if git or network is unavailable)"); + } + } + println!(); + + // Test 4: Cache operations + println!("{}", "--- Test 4: Cache Operations ---".yellow().bold()); + + match cache.stats().await { + Ok(stats) => { + println!(" {} Cache Statistics:", "📊".cyan()); + println!( + " Total packages: {}", + stats.total_packages.to_string().green() + ); + println!( + " Unique packages: {}", + stats.unique_packages.to_string().green() + ); + println!(" Total size: {:.2} MB", stats.total_size_mb()); + } + Err(e) => { + println!(" {} Failed to get cache stats: {}", "✗".red(), e); + } + } + println!(); + + // Test 5: Package spec parsing + println!("{}", "--- Test 5: Package Spec Parsing ---".yellow().bold()); + let package_specs = vec!["arvo@k414", "lagoon@^0.2.0", "sequent@commit:abc123"]; + + for spec_str in package_specs { + match crate::resolver::parse_package_spec(spec_str) { + Ok((name, version)) => { + println!( + " ✓ Parsed '{}' → name={}, version={}", + spec_str.cyan(), + name.green(), + version.to_canonical_string().yellow() + ); + } + Err(e) => { + println!(" ✗ Failed to parse '{}': {}", spec_str.red(), e); + } + } + } + println!(); + + println!("{}", "=== Phase 1 Tests Complete ===".cyan().bold()); + println!(); + println!("The following modules are ready:"); + println!( + " {} GitFetcher - Fetch repos, resolve tags/branches", + "✓".green() + ); + println!(" {} PackageCache - Store and manage packages", "✓".green()); + println!( + " {} VersionSpec Parser - Parse all version formats", + "✓".green() + ); + println!(); + println!("Next: Implement full dependency resolver in Phase 2"); + + Ok(()) +} diff --git a/crates/nockup/src/commands/update.rs b/crates/nockup/src/commands/update.rs index ed9698013..2cabb6bc2 100644 --- a/crates/nockup/src/commands/update.rs +++ b/crates/nockup/src/commands/update.rs @@ -1,34 +1,140 @@ -use anyhow::Result; +use std::fs; + +use anyhow::{Context, Result}; use colored::Colorize; use super::common; pub async fn run() -> Result<()> { + run_update(false).await +} + +/// Run update with optional initial setup +/// If `is_initial_install` is true, also creates cache structure, sets up config, and updates PATH +pub async fn run_update(is_initial_install: bool) -> Result<()> { let cache_dir = common::get_cache_dir()?; - println!("{} Setting up nockup cache directory...", "🚀".green()); + if is_initial_install { + println!("{} Setting up nockup cache directory...", "🚀".green()); + } else { + println!("{} Updating nockup...", "🔄".green()); + } + println!( "{} Cache location: {}", "📁".blue(), cache_dir.display().to_string().cyan() ); + // Create cache directory structure (only for initial install) + if is_initial_install { + create_cache_structure(&cache_dir).await?; + } + // Download or update templates common::download_templates(&cache_dir).await?; // Download toolchain files common::download_toolchain_files(&cache_dir).await?; + // Set up or get config + let config = if is_initial_install { + let config_path = cache_dir.join("config.toml"); + let mut config = common::get_or_create_config()?; + println!("📝 Config installed at: {}", config_path.display()); + config["channel"] = toml::Value::String("stable".into()); + config["architecture"] = toml::Value::String(common::get_target_identifier()); + fs::write(&config_path, toml::to_string(&config)?) + .context("Failed to write config file")?; + config + } else { + common::get_config()? + }; + // Write commit details to status file common::write_commit_details(&cache_dir).await?; - // Get existing config - let config = common::get_config()?; - // Download binaries for current channel common::download_binaries(&config).await?; - println!("{} Update complete!", "✅".green()); + // Prepend cache bin directory to PATH (only for initial install) + if is_initial_install { + prepend_path_to_shell_rc(&cache_dir.join("bin")).await?; + } + + if is_initial_install { + println!("{} Setup complete!", "✅".green()); + println!( + "{} Templates are now available in: {}", + "📂".blue(), + cache_dir.join("templates").display().to_string().cyan() + ); + println!( + "{} Binaries are now available in: {}", + "🛠".blue(), + cache_dir.join("bin").display().to_string().cyan() + ); + } else { + println!("{} Update complete!", "✅".green()); + } + + Ok(()) +} + +async fn create_cache_structure(cache_dir: &std::path::Path) -> Result<()> { + let templates_dir = cache_dir.join("templates"); + let bin_dir = cache_dir.join("bin"); + + tokio::fs::create_dir_all(&templates_dir) + .await + .context("Failed to create templates directory")?; + + tokio::fs::create_dir_all(&bin_dir) + .await + .context("Failed to create bin directory")?; + + println!( + "{} Created {}", + "✓".green(), + templates_dir.display().to_string().cyan() + ); + println!( + "{} Created {}", + "✓".green(), + bin_dir.display().to_string().cyan() + ); + + Ok(()) +} + +async fn prepend_path_to_shell_rc(bin_dir: &std::path::Path) -> Result<()> { + let home = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("Could not find home directory"))?; + + let shell_rcs = vec![home.join(".bashrc"), home.join(".zshrc"), home.join(".profile")]; + + let path_line = format!("export PATH=\"{}:$PATH\"", bin_dir.display()); + + for rc_path in shell_rcs { + if rc_path.exists() { + let content = tokio::fs::read_to_string(&rc_path) + .await + .context("Failed to read shell RC file")?; + + if !content.contains(&path_line) { + let mut updated_content = content; + updated_content.push_str(&format!("\n# Added by nockup\n{}\n", path_line)); + tokio::fs::write(&rc_path, updated_content) + .await + .context("Failed to write to shell RC file")?; + + println!( + "{} Updated {}", + "✓".green(), + rc_path.display().to_string().cyan() + ); + } + } + } Ok(()) } diff --git a/crates/nockup/src/git_fetcher.rs b/crates/nockup/src/git_fetcher.rs new file mode 100644 index 000000000..a94e1ddc8 --- /dev/null +++ b/crates/nockup/src/git_fetcher.rs @@ -0,0 +1,326 @@ +use std::path::{Path, PathBuf}; +use std::process::Stdio; + +use anyhow::{Context, Result}; +use tokio::process::Command; + +/// Specification for a Git repository to fetch +#[derive(Debug, Clone)] +pub struct GitSpec { + pub url: String, + pub commit: Option, + pub tag: Option, + pub branch: Option, + pub path: Option, // Subdir within repo to fetch from (e.g., "pkg/arvo/sys") + pub install_path: Option, // Subdir to install to (e.g., "sys") + pub file: Option, // Specific file to extract (e.g., "zuse.hoon") +} + +/// Handles Git repository fetching and management +pub struct GitFetcher { + cache_dir: PathBuf, // ~/.nockup/cache/git/ +} + +impl GitFetcher { + /// Create a new GitFetcher with the given cache directory + pub fn new(cache_dir: PathBuf) -> Self { + Self { cache_dir } + } + + /// Fetch a repository according to the spec, returning the local path + pub async fn fetch(&self, spec: &GitSpec) -> Result { + // Determine target ref (commit > tag > branch > default) + let target_ref = self.determine_target_ref(spec).await?; + + // Create cache path based on URL and commit hash + let repo_path = self.get_repo_cache_path(&spec.url, &target_ref); + + // Check if already cached + if repo_path.exists() { + return Ok(repo_path); + } + + // Clone the repository + self.clone_repo(spec, &repo_path, &target_ref).await?; + + Ok(repo_path) + } + + /// Resolve a tag or branch to a commit hash + pub async fn resolve_ref(&self, url: &str, ref_name: &str) -> Result { + // Use git ls-remote to get commit hash without cloning + let output = Command::new("git") + .args(["ls-remote", url, ref_name]) + .output() + .await + .context("Failed to run git ls-remote")?; + + if !output.status.success() { + anyhow::bail!( + "Failed to resolve ref '{}' in {}: {}", + ref_name, + url, + String::from_utf8_lossy(&output.stderr) + ); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let commit = stdout + .lines() + .next() + .and_then(|line| line.split_whitespace().next()) + .ok_or_else(|| anyhow::anyhow!("No commit found for ref '{}'", ref_name))?; + + Ok(commit.to_string()) + } + + /// Get commit hash for HEAD of a branch + pub async fn resolve_branch(&self, url: &str, branch: &str) -> Result { + let ref_name = format!("refs/heads/{}", branch); + self.resolve_ref(url, &ref_name).await + } + + /// Get commit hash for a tag + pub async fn resolve_tag(&self, url: &str, tag: &str) -> Result { + let ref_name = format!("refs/tags/{}", tag); + self.resolve_ref(url, &ref_name).await + } + + /// Checkout a specific commit in an already-cloned repo + pub async fn checkout_commit(&self, repo_path: &Path, commit: &str) -> Result<()> { + let output = Command::new("git") + .args(["checkout", commit]) + .current_dir(repo_path) + .output() + .await + .context("Failed to checkout commit")?; + + if !output.status.success() { + anyhow::bail!( + "Failed to checkout commit {}: {}", + commit, + String::from_utf8_lossy(&output.stderr) + ); + } + + Ok(()) + } + + /// Fetch a subdirectory from a repo using sparse checkout + pub async fn fetch_subdir(&self, spec: &GitSpec, subdir: &str) -> Result { + let target_ref = self.determine_target_ref(spec).await?; + let repo_path = self.get_repo_cache_path(&spec.url, &target_ref); + + if repo_path.exists() { + return Ok(repo_path.join(subdir)); + } + + // Clone with sparse checkout + self.clone_sparse(spec, &repo_path, &target_ref, subdir) + .await?; + + Ok(repo_path.join(subdir)) + } + + // Private helper methods + + /// Determine which ref to use (commit > tag > branch > default) + async fn determine_target_ref(&self, spec: &GitSpec) -> Result { + if let Some(ref commit) = spec.commit { + // If commit is specified, use it directly + Ok(commit.clone()) + } else if let Some(ref tag) = spec.tag { + // Resolve tag to commit + self.resolve_tag(&spec.url, tag).await + } else if let Some(ref branch) = spec.branch { + // Resolve branch to commit + self.resolve_branch(&spec.url, branch).await + } else { + // Default to HEAD of main/master + match self.resolve_branch(&spec.url, "main").await { + Ok(commit) => Ok(commit), + Err(_) => self.resolve_branch(&spec.url, "master").await, + } + } + } + + /// Generate cache path from URL and commit hash + fn get_repo_cache_path(&self, url: &str, commit: &str) -> PathBuf { + // Hash the URL to create a safe directory name + let url_hash = self.hash_url(url); + + // Short commit hash (first 12 chars) + let short_commit = &commit[..commit.len().min(12)]; + + self.cache_dir.join(url_hash).join(short_commit) + } + + /// Hash a URL to create a safe directory name + fn hash_url(&self, url: &str) -> String { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + + let mut hasher = DefaultHasher::new(); + url.hash(&mut hasher); + format!("{:x}", hasher.finish()) + } + + /// Clone a repository (full clone with depth=1 for efficiency) + async fn clone_repo(&self, spec: &GitSpec, target_path: &Path, commit: &str) -> Result<()> { + // Create parent directory + if let Some(parent) = target_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + // Clone with depth=1 for the specific commit (if possible) + // Note: Some git servers don't support fetching arbitrary commits with depth=1, + // so we do a full clone and then checkout + let output = Command::new("git") + .arg("clone") + .arg(&spec.url) + .arg(target_path.as_os_str()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .await + .context("Failed to clone repository")?; + + if !output.status.success() { + anyhow::bail!( + "Failed to clone {}: {}", + spec.url, + String::from_utf8_lossy(&output.stderr) + ); + } + + // Checkout the specific commit + self.checkout_commit(target_path, commit).await?; + + Ok(()) + } + + /// Clone with sparse checkout for a specific subdirectory + async fn clone_sparse( + &self, + spec: &GitSpec, + target_path: &Path, + commit: &str, + subdir: &str, + ) -> Result<()> { + // Create parent directory + if let Some(parent) = target_path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + + // Initialize repo + Command::new("git") + .args(["init"]) + .current_dir(target_path) + .output() + .await?; + + // Configure sparse checkout + Command::new("git") + .args(["config", "core.sparseCheckout", "true"]) + .current_dir(target_path) + .output() + .await?; + + // Set sparse checkout paths + let sparse_file = target_path.join(".git/info/sparse-checkout"); + tokio::fs::write(&sparse_file, format!("{}\n", subdir)).await?; + + // Add remote + Command::new("git") + .args(["remote", "add", "origin", &spec.url]) + .current_dir(target_path) + .output() + .await?; + + // Fetch and checkout + Command::new("git") + .args(["fetch", "--depth=1", "origin", commit]) + .current_dir(target_path) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .output() + .await?; + + self.checkout_commit(target_path, commit).await?; + + Ok(()) + } + + /// List all tags in a remote repository + pub async fn list_tags(&self, url: &str) -> Result> { + let output = Command::new("git") + .args(["ls-remote", "--tags", url]) + .output() + .await + .context("Failed to list tags")?; + + if !output.status.success() { + anyhow::bail!( + "Failed to list tags for {}: {}", + url, + String::from_utf8_lossy(&output.stderr) + ); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let tags: Vec = stdout + .lines() + .filter_map(|line| { + line.split_whitespace() + .nth(1) + .and_then(|ref_name| ref_name.strip_prefix("refs/tags/")) + .map(|tag| tag.to_string()) + }) + .collect(); + + Ok(tags) + } + + /// Check if git is available on the system + pub async fn check_git_available() -> Result<()> { + let output = Command::new("git") + .arg("--version") + .output() + .await + .context("Git command not found. Please install git.")?; + + if !output.status.success() { + anyhow::bail!("Git is installed but not working correctly"); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hash_url() { + let fetcher = GitFetcher::new(PathBuf::from("/tmp/cache")); + let hash1 = fetcher.hash_url("https://github.com/urbit/urbit"); + let hash2 = fetcher.hash_url("https://github.com/urbit/urbit"); + let hash3 = fetcher.hash_url("https://github.com/different/repo"); + + assert_eq!(hash1, hash2, "Same URL should produce same hash"); + assert_ne!( + hash1, hash3, + "Different URLs should produce different hashes" + ); + } + + #[test] + fn test_get_repo_cache_path() { + let fetcher = GitFetcher::new(PathBuf::from("/tmp/cache")); + let path = fetcher.get_repo_cache_path("https://github.com/urbit/urbit", "abc123def456789"); + + assert!(path.to_string_lossy().contains("/tmp/cache")); + assert!(path.to_string_lossy().contains("abc123def456")); + } +} diff --git a/crates/nockup/src/lib.rs b/crates/nockup/src/lib.rs new file mode 100644 index 000000000..2cd4cea0c --- /dev/null +++ b/crates/nockup/src/lib.rs @@ -0,0 +1,8 @@ +pub mod cache; +pub mod cli; +pub mod commands; +pub mod git_fetcher; +pub mod lib_manager; +pub mod manifest; +pub mod resolver; +pub mod version; diff --git a/crates/nockup/src/lib_manager.rs b/crates/nockup/src/lib_manager.rs index 1795a6794..f1e3ef172 100644 --- a/crates/nockup/src/lib_manager.rs +++ b/crates/nockup/src/lib_manager.rs @@ -134,13 +134,10 @@ fn validate_library_spec(spec: &LibrarySpec) -> Result<()> { } // Ensure mutually exclusive directory/file - match (&spec.directory, &spec.file) { - (Some(_), Some(_)) => { - return Err(anyhow::anyhow!( - "Library spec cannot have both 'directory' and 'file' specified. Please use only one." - )); - } - _ => {} // Valid: can have neither, or exactly one + if let (Some(_), Some(_)) = (&spec.directory, &spec.file) { + return Err(anyhow::anyhow!( + "Library spec cannot have both 'directory' and 'file' specified. Please use only one." + )); } // Validate URL is GitHub (for now) @@ -166,7 +163,7 @@ fn get_library_cache_dir() -> Result { async fn fetch_library_repo( cache_dir: &Path, - lib_name: &str, + _lib_name: &str, spec: &LibrarySpec, ) -> Result { // Create a unique directory name based on URL and commit/branch @@ -188,11 +185,11 @@ async fn fetch_library_repo( println!(" ⬇️ Cloning repository..."); let mut git_cmd = Command::new("git"); - git_cmd.args(&["clone", &spec.url]); + git_cmd.args(["clone", &spec.url]); // If branch specified, clone that branch if let Some(branch) = &spec.branch { - git_cmd.args(&["--branch", branch]); + git_cmd.args(["--branch", branch]); } git_cmd.arg(&repo_cache_dir); @@ -209,7 +206,7 @@ async fn fetch_library_repo( // If commit specified, checkout that commit if let Some(commit) = &spec.commit { let checkout_output = Command::new("git") - .args(&["checkout", commit]) + .args(["checkout", commit]) .current_dir(&repo_cache_dir) .output() .context("Failed to checkout commit")?; @@ -267,17 +264,20 @@ fn find_library_source_dir(repo_dir: &Path, spec: &LibrarySpec) -> Result Result<()> { // Always use flattened approach - copy contents directly to appropriate directories - let project_hoon_dir = dest_lib_dir.parent().unwrap(); // Get /hoon from /hoon/lib + let project_hoon_dir = dest_lib_dir + .parent() + .expect("dest_lib_dir should have a parent directory"); // Get /hoon from /hoon/lib copy_top_level_library(source_dir, project_hoon_dir, source_dir)?; Ok(()) } +#[allow(dead_code)] fn ensure_directory_exists(dir: &Path) -> Result<()> { fs::create_dir_all(dir) .with_context(|| format!("Failed to create directory '{}'", dir.display())) @@ -299,7 +299,9 @@ fn copy_single_file(repo_dir: &Path, project_lib_dir: &Path, file_path: &str) -> .ok_or_else(|| anyhow::anyhow!("Invalid file path: {}", file_path))?; // Get the project's /hoon directory (parent of /hoon/lib) - let project_hoon_dir = project_lib_dir.parent().unwrap(); + let project_hoon_dir = project_lib_dir + .parent() + .expect("project_lib_dir should have a parent directory"); // Determine destination directory based on file path let dest_dir = if file_path.contains("/lib/") { @@ -353,15 +355,13 @@ fn copy_top_level_library(src_dir: &Path, dest_dir: &Path, root_src: &Path) -> R format!("Failed to create directory '{}'", dest_subdir.display()) })?; copy_directory_contents(&src_path, &dest_subdir, root_src)?; - } else { - if should_copy_file(&src_path) { - let dest_path = dest_dir.join(&file_name); - fs::copy(&src_path, &dest_path) - .with_context(|| format!("Failed to copy file '{}'", src_path.display()))?; + } else if should_copy_file(&src_path) { + let dest_path = dest_dir.join(&file_name); + fs::copy(&src_path, &dest_path) + .with_context(|| format!("Failed to copy file '{}'", src_path.display()))?; - let relative_src = src_path.strip_prefix(root_src).unwrap_or(&src_path); - println!(" copy {}", relative_src.display()); - } + let relative_src = src_path.strip_prefix(root_src).unwrap_or(&src_path); + println!(" copy {}", relative_src.display()); } } diff --git a/crates/nockup/src/main.rs b/crates/nockup/src/main.rs index ecb7bfed8..cbb7ea059 100644 --- a/crates/nockup/src/main.rs +++ b/crates/nockup/src/main.rs @@ -1,29 +1,46 @@ use std::process; use clap::Parser; - -mod cli; -mod commands; -mod lib_manager; -mod version; - -use cli::*; +use colored::Colorize; +use nockup::cli::*; +use nockup::{commands, version}; #[tokio::main] async fn main() { let cli = Cli::parse(); let result = match cli.command { - None => { - // No subcommand provided - show version info - version::show_version_info().await + // Hierarchical commands + Some(Commands::Project(cmd)) => commands::build::run(cmd).await, + Some(Commands::Package(cmd)) => commands::package::run(cmd).await, + Some(Commands::Channel(cmd)) => commands::channel::run(cmd).await, + + // Legacy flat commands (backward compatible) + Some(Commands::Build { project }) => { + commands::build::run(ProjectCommand::Build { + project: Some(project), + }) + .await } - Some(Commands::Install) => commands::install::run().await, - Some(Commands::Init { name }) => commands::init::run(name).await, + Some(Commands::Init { project }) => commands::init::run(project).await, Some(Commands::Update) => commands::update::run().await, - Some(Commands::Build { project }) => commands::build::run(project).await, - Some(Commands::Run { project, args }) => commands::run::run(project, args).await, - Some(Commands::Channel { action }) => commands::channel::run(action).await, + // Some(Commands::Init { name: _ }) => { + // eprintln!("{}", "warning: `nockup init` is now `nockup package init`".yellow()); + // commands::package::run(PackageCommand::Init{ name: name }).await + // } + Some(Commands::Install) => { + eprintln!( + "{}", + "warning: `nockup install` is now `nockup update`".yellow() + ); + commands::package::run(PackageCommand::Install).await + } + Some(Commands::Run { project, args }) => { + commands::build::run(ProjectCommand::Run { project, args }).await + } + Some(Commands::TestPhase1) => commands::test_phase1::run().await, + + None => version::show_version_info().await, }; if let Err(e) = result { diff --git a/crates/nockup/src/manifest.rs b/crates/nockup/src/manifest.rs new file mode 100644 index 000000000..cce5a0164 --- /dev/null +++ b/crates/nockup/src/manifest.rs @@ -0,0 +1,148 @@ +use std::collections::BTreeMap; +use std::path::Path; + +use anyhow::Result; +use serde::{Deserialize, Serialize}; +use toml; + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct HoonPackage { + pub package: PackageMeta, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dependencies: Option>, +} + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct PackageMeta { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub authors: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub license: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub template: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub template_commit: Option, +} + +#[derive(Debug, Serialize, Deserialize, Default)] +pub struct NockAppManifest { + pub package: PackageMeta, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub template: Option, + + #[serde(default, skip_serializing_if = "Option::is_none")] + pub template_commit: Option, + + #[serde(default)] + pub dependencies: BTreeMap, + + // Optional local section (rare) + #[serde(default, skip_serializing_if = "Option::is_none")] + pub build: Option, +} + +impl NockAppManifest { + pub fn load(path: &Path) -> Result { + if !path.exists() { + anyhow::bail!("Manifest file not found: {}", path.display()); + } + let content = std::fs::read_to_string(path)?; + let manifest = toml::from_str(&content)?; + Ok(manifest) + } + + pub fn save(&self, path: &Path) -> Result<()> { + let content = toml::to_string_pretty(self)?; + std::fs::write(path, content)?; + Ok(()) + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(untagged)] +pub enum DependencySpec { + // "1.0" + Simple(String), + // version = "1.0" + Version { + version: String, + }, + // "k409" etc. + Full { + version: Option, + git: Option, + commit: Option, + tag: Option, + branch: Option, + path: Option, + files: Option>, // Specific files to extract (e.g., ["seq", "test"]) + kelvin: Option, + }, +} + +// nockapp.lock format – always exact commit hashes +#[derive(Debug, Serialize, Deserialize)] +pub struct NockAppLock { + pub package: Vec, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct LockedPackage { + pub name: String, + // k414", "commit:abc123", "^1.0", etc. + pub version: String, + pub source: LockSource, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum LockSource { + #[serde(rename = "git")] + Git { + url: String, + commit: String, + path: Option, + }, + #[serde(rename = "path")] + Path { path: String }, +} + +impl HoonPackage { + pub fn load(path: &Path) -> Result> { + if !path.exists() { + return Ok(None); + } + let content = std::fs::read_to_string(path)?; + let pkg = toml::from_str(&content)?; + Ok(Some(pkg)) + } + + pub fn save(&self, path: &Path) -> Result<()> { + let content = toml::to_string_pretty(self)?; + std::fs::write(path, content)?; + Ok(()) + } +} + +impl NockAppLock { + pub fn load(path: &Path) -> Result { + if path.exists() { + let content = std::fs::read_to_string(path)?; + Ok(toml::from_str(&content)?) + } else { + Ok(NockAppLock { package: vec![] }) + } + } + + pub fn save(&self, path: &Path) -> Result<()> { + let content = toml::to_string_pretty(self)?; + std::fs::write(path, content)?; + Ok(()) + } +} diff --git a/crates/nockup/src/resolver/engine.rs b/crates/nockup/src/resolver/engine.rs new file mode 100644 index 000000000..ce54e1367 --- /dev/null +++ b/crates/nockup/src/resolver/engine.rs @@ -0,0 +1,407 @@ +use std::collections::HashMap; +use std::path::Path; + +use anyhow::{Context, Result}; +use colored::Colorize; + +use crate::cache::PackageCache; +use crate::git_fetcher::{GitFetcher, GitSpec}; +use crate::manifest::{DependencySpec, HoonPackage}; +use crate::resolver::types::{ResolvedGraph, ResolvedPackage}; +use crate::resolver::{registry, VersionSpec}; + +/// Main dependency resolver +pub struct Resolver { + cache: PackageCache, + git_fetcher: GitFetcher, +} + +impl Resolver { + /// Create a new resolver + pub fn new() -> Result { + let cache = PackageCache::new()?; + let git_fetcher = GitFetcher::new(cache.git_dir()); + + Ok(Self { cache, git_fetcher }) + } + + /// Resolve all dependencies in a manifest + pub async fn resolve(&self, manifest: &HoonPackage) -> Result { + println!("{} Resolving dependencies...", "📦".cyan()); + + let mut graph = ResolvedGraph::new(); + let mut visited = std::collections::HashSet::new(); + let mut to_resolve = Vec::new(); + + // Get dependencies from manifest + let dependencies = match manifest.dependencies.as_ref() { + Some(deps) if !deps.is_empty() => deps, + _ => { + println!(" No dependencies to resolve"); + return Ok(graph); + } + }; + + // Queue initial dependencies + for (name, spec) in dependencies { + to_resolve.push((name.clone(), spec.clone())); + } + + // Resolve dependencies recursively + while let Some((name, spec)) = to_resolve.pop() { + // Skip if already resolved + if visited.contains(&name) { + continue; + } + visited.insert(name.clone()); + + println!(" {} Resolving {}...", "→".cyan(), name.yellow()); + + // Check cache first + if let Some(cached) = self.check_cache(&name, &spec).await? { + println!(" {} Found in cache", "✓".green()); + graph.add_package(cached); + + // Queue transitive dependencies + let deps = registry::get_dependencies(&name).await; + for dep in deps { + if !visited.contains(&dep) { + // Use "latest" for transitive dependencies + to_resolve + .push((dep.clone(), DependencySpec::Simple("latest".to_string()))); + } + } + continue; + } + + // Resolve from source + let resolved = self + .resolve_dependency(&name, &spec) + .await + .with_context(|| format!("Failed to resolve dependency '{}'", name))?; + + graph.add_package(resolved); + + // Queue transitive dependencies + let deps = registry::get_dependencies(&name).await; + for dep in deps { + if !visited.contains(&dep) { + // Use "latest" for transitive dependencies + to_resolve.push((dep.clone(), DependencySpec::Simple("latest".to_string()))); + } + } + } + + // Compute installation order (topological sort) + graph.compute_install_order()?; + + println!("{} Resolved {} packages", "✓".green(), graph.packages.len()); + + Ok(graph) + } + + /// Resolve a single dependency + async fn resolve_dependency( + &self, + name: &str, + spec: &DependencySpec, + ) -> Result { + // Convert DependencySpec to GitSpec + let git_spec = self.dep_spec_to_git_spec(spec, name).await?; + + // Fetch the repository + println!( + " {} Fetching from {}...", + "⬇".cyan(), + git_spec.url.cyan() + ); + let repo_path = self + .git_fetcher + .fetch(&git_spec) + .await + .context("Failed to fetch git repository")?; + + // Determine exact commit + let commit = self.get_exact_commit(&git_spec).await?; + + println!( + " {} Commit: {}", + "→".cyan(), + commit.chars().take(12).collect::().yellow() + ); + + // Determine the source directory to cache + let source_dir = if let Some(ref subpath) = git_spec.path { + repo_path.join(subpath) + } else { + repo_path.clone() + }; + + // Verify source directory exists + if !source_dir.exists() { + anyhow::bail!( + "Source path {} does not exist in repository", + source_dir.display() + ); + } + + // If a specific file is requested, verify it exists + if let Some(ref filename) = git_spec.file { + let file_path = source_dir.join(filename); + if !file_path.exists() { + anyhow::bail!( + "Specific file {} not found at {}", + filename, + source_dir.display() + ); + } + } + + // Check for transitive dependencies (look for hoon.toml in fetched repo) + let transitive_deps = self + .load_transitive_deps(repo_path.as_path(), &git_spec) + .await?; + + if !transitive_deps.is_empty() { + println!( + " {} Found {} transitive dependencies", + "→".cyan(), + transitive_deps.len() + ); + } + + // Cache the package (always cache the full source directory) + let version_spec = self.spec_to_version_spec(spec)?; + let version_str = version_spec.to_canonical_string(); + + // For wildcard versions ("*" or "latest"), cache using the commit hash instead + // so that the cache lookup will work correctly + let cache_version_str = if version_str == "*" { + format!("commit:{}", commit) + } else { + version_str.clone() + }; + + println!(" {} Caching to packages cache...", "💾".cyan()); + + self.cache + .cache_package( + name, &cache_version_str, &commit, &git_spec.url, &source_dir, + ) + .await?; + + Ok(ResolvedPackage { + name: name.to_string(), + version_spec, + commit, + source_url: git_spec.url.clone(), + source_path: git_spec.path.clone(), + install_path: git_spec.install_path.clone(), + source_file: git_spec.file.clone(), + dependencies: transitive_deps, + }) + } + + /// Check if package is already in cache + async fn check_cache( + &self, + name: &str, + spec: &DependencySpec, + ) -> Result> { + let version_spec = self.spec_to_version_spec(spec)?; + let version_str = version_spec.to_canonical_string(); + + if let Some(cached) = self.cache.find_cached(name, &version_str).await? { + // Reconstruct the GitSpec to get source_path and source_file + let git_spec = self.dep_spec_to_git_spec(spec, name).await?; + + return Ok(Some(ResolvedPackage { + name: name.to_string(), + version_spec, + commit: cached.commit, + source_url: cached.source_url, + source_path: git_spec.path, + install_path: git_spec.install_path, + source_file: git_spec.file, + dependencies: HashMap::new(), // TODO: Store in cache metadata + })); + } + + Ok(None) + } + + /// Convert DependencySpec to GitSpec + async fn dep_spec_to_git_spec(&self, spec: &DependencySpec, name: &str) -> Result { + match spec { + DependencySpec::Simple(version) => { + // Try to look up in registry + if let Some(entry) = registry::lookup(name).await { + // Parse the version spec to extract tag/branch/commit + let version_spec = VersionSpec::parse(version)?; + let (tag, branch) = match version_spec { + VersionSpec::Kelvin(k) => (Some(format!("{}k", k)), None), + VersionSpec::Tag(t) => (Some(t), None), + VersionSpec::Branch(b) => (None, Some(b)), + VersionSpec::Semver(ref req) if req == &semver::VersionReq::STAR => { + // "latest" or "*" means use the default branch + (None, None) + } + VersionSpec::Semver(_) => (Some(version.clone()), None), + VersionSpec::Commit(_) => { + // For commits, we'll let get_exact_commit handle it + (None, None) + } + }; + Ok(registry::to_git_spec(&entry, tag, branch)) + } else { + anyhow::bail!( + "Package '{}' not found in registry. \ + Use full git spec with 'git' field.", + name + ) + } + } + DependencySpec::Version { version } => { + // Try to look up in registry + if let Some(entry) = registry::lookup(name).await { + let version_spec = VersionSpec::parse(version)?; + let (tag, branch) = match version_spec { + VersionSpec::Kelvin(k) => (Some(format!("{}k", k)), None), + VersionSpec::Tag(t) => (Some(t), None), + VersionSpec::Branch(b) => (None, Some(b)), + VersionSpec::Semver(ref req) if req == &semver::VersionReq::STAR => { + // "latest" or "*" means use the default branch + (None, None) + } + VersionSpec::Semver(_) => (Some(version.clone()), None), + VersionSpec::Commit(_) => (None, None), + }; + Ok(registry::to_git_spec(&entry, tag, branch)) + } else { + anyhow::bail!( + "Package '{}' not found in registry. \ + Use full git spec with 'git' field.", + name + ) + } + } + DependencySpec::Full { + git, + commit, + tag, + branch, + path, + files, + .. + } => { + let url = git.as_ref().ok_or_else(|| { + anyhow::anyhow!("Git URL is required (registry not yet implemented)") + })?; + + // If files is specified with exactly one entry, use it as the specific file + // If files has multiple entries, we'll need to handle that differently (TODO) + let file = files.as_ref().and_then(|f| { + if f.len() == 1 { + Some(format!("{}.hoon", f[0])) + } else { + None + } + }); + + Ok(GitSpec { + url: url.clone(), + commit: commit.clone(), + tag: tag.clone(), + branch: branch.clone(), + path: path.clone(), + install_path: None, // Don't auto-set for manifest packages; let install.rs handle it + file, + }) + } + } + } + + /// Get exact commit hash for a GitSpec + async fn get_exact_commit(&self, spec: &GitSpec) -> Result { + if let Some(ref commit) = spec.commit { + // Already have exact commit + return Ok(commit.clone()); + } + + if let Some(ref tag) = spec.tag { + // Resolve tag to commit + return self.git_fetcher.resolve_tag(&spec.url, tag).await; + } + + if let Some(ref branch) = spec.branch { + // Resolve branch to commit + return self.git_fetcher.resolve_branch(&spec.url, branch).await; + } + + // Default: resolve main/master + match self.git_fetcher.resolve_branch(&spec.url, "main").await { + Ok(commit) => Ok(commit), + Err(_) => self.git_fetcher.resolve_branch(&spec.url, "master").await, + } + } + + /// Load transitive dependencies from a fetched package + async fn load_transitive_deps( + &self, + repo_path: &Path, + git_spec: &GitSpec, + ) -> Result> { + // Check for hoon.toml in the fetched repo + let manifest_path = if let Some(ref subdir) = git_spec.path { + repo_path.join(subdir).join("hoon.toml") + } else { + repo_path.join("hoon.toml") + }; + + if !manifest_path.exists() { + // No transitive dependencies + return Ok(HashMap::new()); + } + + // Load and parse manifest + match HoonPackage::load(&manifest_path)? { + Some(pkg) => Ok(pkg.dependencies.unwrap_or_default().into_iter().collect()), + None => Ok(HashMap::new()), + } + } + + /// Convert DependencySpec to VersionSpec for caching + fn spec_to_version_spec(&self, spec: &DependencySpec) -> Result { + match spec { + DependencySpec::Simple(s) => VersionSpec::parse(s), + DependencySpec::Version { version } => VersionSpec::parse(version), + DependencySpec::Full { + version, + commit, + tag, + branch, + kelvin, + .. + } => { + // Priority: commit > tag > kelvin > branch > version + if let Some(c) = commit { + return Ok(VersionSpec::Commit(c.clone())); + } + if let Some(t) = tag { + return Ok(VersionSpec::Tag(t.clone())); + } + if let Some(k) = kelvin { + return VersionSpec::parse(k); + } + if let Some(b) = branch { + return Ok(VersionSpec::Branch(b.clone())); + } + if let Some(v) = version { + return VersionSpec::parse(v); + } + + anyhow::bail!("DependencySpec has no version information") + } + } + } +} diff --git a/crates/nockup/src/resolver/mod.rs b/crates/nockup/src/resolver/mod.rs new file mode 100644 index 000000000..48d7f463c --- /dev/null +++ b/crates/nockup/src/resolver/mod.rs @@ -0,0 +1,8 @@ +mod engine; +pub mod registry; +pub mod spec_parser; +pub mod types; + +pub use engine::Resolver; +pub use spec_parser::{parse_package_spec, VersionSpec}; +pub use types::{ResolvedGraph, ResolvedPackage}; diff --git a/crates/nockup/src/resolver/registry.rs b/crates/nockup/src/resolver/registry.rs new file mode 100644 index 000000000..9538f87f9 --- /dev/null +++ b/crates/nockup/src/resolver/registry.rs @@ -0,0 +1,315 @@ +/// Package registry system using typhoon registry format +/// Fetches registry from https://github.com/sigilante/typhoon +use std::collections::HashMap; +use std::sync::RwLock; + +use anyhow::{anyhow, Context, Result}; +use once_cell::sync::Lazy; +use serde::Deserialize; + +use crate::git_fetcher::GitSpec; + +#[derive(Debug, Clone)] +pub struct RegistryEntry { + pub git_url: String, + pub path: Option, // Path in repo to fetch from (e.g., "pkg/arvo/sys") + pub install_path: Option, // Path to install to (e.g., "sys") + pub file: Option, // Specific file to extract (e.g., "zuse.hoon") +} + +/// Typhoon registry TOML format structures +#[derive(Debug, Clone, Deserialize)] +pub struct RegistryToml { + #[serde(default)] + pub workspace: HashMap, + #[serde(default)] + pub package: Vec, + #[serde(default)] + pub alias: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Workspace { + pub git_url: String, + #[serde(rename = "ref")] + pub git_ref: String, + pub description: Option, + pub root_path: String, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Package { + pub name: String, + pub workspace: String, + pub path: String, + pub file: String, + #[serde(default)] + pub dependencies: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct Alias { + pub name: String, + pub target: String, +} + +/// Well-known packages registry +static REGISTRY: Lazy> = Lazy::new(|| { + let mut m = HashMap::new(); + + // Standard Urbit libraries from urbit/urbit - single files + // path: where to fetch from in repo (e.g., "pkg/arvo/sys") + // install_path: where to install to (e.g., "sys") + m.insert( + "urbit/zuse", + RegistryEntry { + git_url: "https://github.com/urbit/urbit".to_string(), + path: Some("pkg/arvo/sys".to_string()), + install_path: Some("sys".to_string()), + file: Some("zuse.hoon".to_string()), + }, + ); + + m.insert( + "urbit/lull", + RegistryEntry { + git_url: "https://github.com/urbit/urbit".to_string(), + path: Some("pkg/arvo/sys".to_string()), + install_path: Some("sys".to_string()), + file: Some("lull.hoon".to_string()), + }, + ); + + m.insert( + "urbit/hoon", + RegistryEntry { + git_url: "https://github.com/urbit/urbit".to_string(), + path: Some("pkg/arvo/sys".to_string()), + install_path: Some("sys".to_string()), + file: Some("hoon.hoon".to_string()), + }, + ); + + m.insert( + "urbit/arvo", + RegistryEntry { + git_url: "https://github.com/urbit/urbit".to_string(), + path: Some("pkg/arvo/sys".to_string()), + install_path: Some("sys".to_string()), + file: Some("arvo.hoon".to_string()), + }, + ); + + // Urbit lib files - also single files + // These install to "lib/" directory + m.insert( + "map", + RegistryEntry { + git_url: "https://github.com/urbit/urbit".to_string(), + path: Some("pkg/arvo/lib".to_string()), + install_path: Some("lib".to_string()), + file: Some("map.hoon".to_string()), + }, + ); + + m.insert( + "bits", + RegistryEntry { + git_url: "https://github.com/urbit/urbit".to_string(), + path: Some("pkg/arvo/lib".to_string()), + install_path: Some("lib".to_string()), + file: Some("bits.hoon".to_string()), + }, + ); + + m.insert( + "list", + RegistryEntry { + git_url: "https://github.com/urbit/urbit".to_string(), + path: Some("pkg/arvo/lib".to_string()), + install_path: Some("lib".to_string()), + file: Some("list.hoon".to_string()), + }, + ); + + m.insert( + "maplist", + RegistryEntry { + git_url: "https://github.com/urbit/urbit".to_string(), + path: Some("pkg/arvo/lib".to_string()), + install_path: Some("lib".to_string()), + file: Some("maplist.hoon".to_string()), + }, + ); + + m.insert( + "math", + RegistryEntry { + git_url: "https://github.com/urbit/urbit".to_string(), + path: Some("pkg/arvo/lib".to_string()), + install_path: Some("lib".to_string()), + file: Some("math.hoon".to_string()), + }, + ); + + m.insert( + "mapset", + RegistryEntry { + git_url: "https://github.com/urbit/urbit".to_string(), + path: Some("pkg/arvo/lib".to_string()), + install_path: Some("lib".to_string()), + file: Some("mapset.hoon".to_string()), + }, + ); + + m.insert( + "set", + RegistryEntry { + git_url: "https://github.com/urbit/urbit".to_string(), + path: Some("pkg/arvo/lib".to_string()), + install_path: Some("lib".to_string()), + file: Some("set.hoon".to_string()), + }, + ); + + m.insert( + "tiny", + RegistryEntry { + git_url: "https://github.com/urbit/urbit".to_string(), + path: Some("pkg/arvo/lib".to_string()), + install_path: Some("lib".to_string()), + file: Some("tiny.hoon".to_string()), + }, + ); + + // Nockchain packages - no file restriction, will use all .hoon files + m.insert( + "nockchain", + RegistryEntry { + git_url: "https://github.com/nockchain/nockchain".to_string(), + path: None, + install_path: None, + file: None, + }, + ); + + m +}); + +/// Cached online registry +static ONLINE_REGISTRY: Lazy>> = Lazy::new(|| RwLock::new(None)); + +const REGISTRY_URL: &str = + "https://raw.githubusercontent.com/sigilante/typhoon/master/registry.toml"; + +/// Fetch and parse the online registry (blocking - use spawn_blocking in async context) +fn fetch_registry_sync() -> Result { + let response = + reqwest::blocking::get(REGISTRY_URL).context("Failed to fetch registry from GitHub")?; + + let content = response + .text() + .context("Failed to read registry response")?; + + let registry: RegistryToml = + toml::from_str(&content).context("Failed to parse registry TOML")?; + + Ok(registry) +} + +/// Get the online registry (with caching) - async wrapper around blocking fetch +async fn get_online_registry() -> Result { + // Try to read from cache first + { + let cache = ONLINE_REGISTRY + .read() + .map_err(|err| anyhow!("Failed to read registry cache: {err}"))?; + if let Some(ref registry) = *cache { + return Ok(registry.clone()); + } + } + + // Fetch and cache (spawn blocking task to avoid blocking async runtime) + let registry = tokio::task::spawn_blocking(fetch_registry_sync) + .await + .context("Failed to spawn blocking task")? + .context("Failed to fetch registry")?; + + { + let mut cache = ONLINE_REGISTRY + .write() + .map_err(|err| anyhow!("Failed to write registry cache: {err}"))?; + *cache = Some(registry.clone()); + } + + Ok(registry) +} + +/// Resolve an alias to its target package name +fn resolve_alias(name: &str, registry: &RegistryToml) -> String { + for alias in ®istry.alias { + if alias.name == name { + return alias.target.clone(); + } + } + name.to_string() +} + +/// Look up a package in the registry (tries online registry first, falls back to hardcoded) +pub async fn lookup(name: &str) -> Option { + // Try online registry first + if let Ok(registry) = get_online_registry().await { + // Resolve aliases + let resolved_name = resolve_alias(name, ®istry); + + // Find the package + if let Some(package) = registry.package.iter().find(|p| p.name == resolved_name) { + // Look up workspace info + if let Some(workspace) = registry.workspace.get(&package.workspace) { + // Concatenate root_path + path to get full repository path for fetching + // e.g., root_path="pkg/arvo", path="sys" -> fetch from "pkg/arvo/sys" + // But install_path is just "sys" (the package path) + let entry = RegistryEntry { + git_url: workspace.git_url.clone(), + path: Some(format!("{}/{}", workspace.root_path, package.path)), + install_path: Some(package.path.clone()), + file: Some(package.file.clone()), + }; + return Some(entry); + } + } + } + + // Fall back to hardcoded registry + REGISTRY.get(name).cloned() +} + +/// Get the dependencies of a package from the registry +pub async fn get_dependencies(name: &str) -> Vec { + // Try online registry first + if let Ok(registry) = get_online_registry().await { + // Resolve aliases + let resolved_name = resolve_alias(name, ®istry); + + // Find the package + if let Some(package) = registry.package.iter().find(|p| p.name == resolved_name) { + return package.dependencies.clone(); + } + } + + // No dependencies found + Vec::new() +} + +/// Convert a registry entry to a GitSpec with version info +pub fn to_git_spec(entry: &RegistryEntry, tag: Option, branch: Option) -> GitSpec { + GitSpec { + url: entry.git_url.clone(), + commit: None, + tag, + branch, + path: entry.path.clone(), + install_path: entry.install_path.clone(), + file: entry.file.clone(), + } +} diff --git a/crates/nockup/src/resolver/spec_parser.rs b/crates/nockup/src/resolver/spec_parser.rs new file mode 100644 index 000000000..555eb6be4 --- /dev/null +++ b/crates/nockup/src/resolver/spec_parser.rs @@ -0,0 +1,340 @@ +use anyhow::Result; +use semver::VersionReq; + +use crate::manifest::DependencySpec; + +/// Parsed version specification +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VersionSpec { + /// Kelvin version (e.g., @k414) + Kelvin(u32), + + /// Exact commit hash (e.g., @commit:abc123def) + Commit(String), + + /// Git tag (e.g., @tag:v1.2.3) + Tag(String), + + /// Git branch (e.g., @branch:main) + Branch(String), + + /// Semver requirement (e.g., ^1.2.0, ~1.2.3, >=2.0.0) + Semver(VersionReq), +} + +impl VersionSpec { + /// Parse a version spec string + /// + /// Supported formats: + /// - `@k414` or `k414` → Kelvin(414) + /// - `@commit:abc123` or `commit:abc123` → Commit("abc123") + /// - `@tag:v1.2.3` or `tag:v1.2.3` → Tag("v1.2.3") + /// - `@branch:main` or `branch:main` → Branch("main") + /// - `latest` or `*` → Semver(STAR) (always latest) + /// - `^1.2.0`, `~1.2.3`, `>=2.0.0`, `1.2.3` → Semver(...) + pub fn parse(input: &str) -> Result { + // Trim whitespace and remove leading @ if present + let input = input.trim().trim_start_matches('@'); + + // Handle "latest" as an alias for "*" + if input == "latest" { + return Ok(VersionSpec::Semver(VersionReq::STAR)); + } + + // Try kelvin format (with optional ^ prefix for minimum version) + // ^k409 or k409 + let kelvin_input = input.strip_prefix('^').unwrap_or(input); + if let Some(kelvin_str) = kelvin_input.strip_prefix('k') { + if let Ok(kelvin) = kelvin_str.parse::() { + return Ok(VersionSpec::Kelvin(kelvin)); + } + } + + // Try explicit type prefixes + if let Some(commit) = input.strip_prefix("commit:") { + return Ok(VersionSpec::Commit(commit.to_string())); + } + + if let Some(tag) = input.strip_prefix("tag:") { + return Ok(VersionSpec::Tag(tag.to_string())); + } + + if let Some(branch) = input.strip_prefix("branch:") { + return Ok(VersionSpec::Branch(branch.to_string())); + } + + // Try semver parsing + match VersionReq::parse(input) { + Ok(req) => Ok(VersionSpec::Semver(req)), + Err(e) => { + anyhow::bail!( + "Invalid version spec '{}': not a kelvin, commit, tag, branch, or semver. Error: {}", + input, + e + ) + } + } + } + + /// Check if this spec matches a given version string + pub fn matches(&self, version: &str) -> bool { + match self { + VersionSpec::Kelvin(k) => { + // Check if version is k matching our kelvin + version + .trim_start_matches('@') + .strip_prefix('k') + .and_then(|s| s.parse::().ok()) + .map(|v| v == *k) + .unwrap_or(false) + } + VersionSpec::Commit(c) => { + // Match exact commit or prefix + version.starts_with(c) || c.starts_with(version) + } + VersionSpec::Tag(t) => { + // Match exact tag + version == t || version == format!("@{}", t) + } + VersionSpec::Branch(b) => { + // Match exact branch name + version == b || version == format!("@{}", b) + } + VersionSpec::Semver(req) => { + // Parse version and check semver match + if let Ok(ver) = semver::Version::parse(version.trim_start_matches('v')) { + req.matches(&ver) + } else { + false + } + } + } + } + + /// Convert to a DependencySpec for use in manifests + pub fn to_dependency_spec(&self, git_url: Option) -> DependencySpec { + match self { + VersionSpec::Kelvin(k) => DependencySpec::Full { + version: None, + git: git_url, + commit: None, + tag: None, + branch: None, + path: None, + files: None, + kelvin: Some(format!("k{}", k)), + }, + VersionSpec::Commit(c) => DependencySpec::Full { + version: None, + git: git_url, + commit: Some(c.clone()), + tag: None, + branch: None, + path: None, + files: None, + kelvin: None, + }, + VersionSpec::Tag(t) => DependencySpec::Full { + version: None, + git: git_url, + commit: None, + tag: Some(t.clone()), + branch: None, + path: None, + files: None, + kelvin: None, + }, + VersionSpec::Branch(b) => DependencySpec::Full { + version: None, + git: git_url, + commit: None, + tag: None, + branch: Some(b.clone()), + path: None, + files: None, + kelvin: None, + }, + VersionSpec::Semver(req) => DependencySpec::Full { + version: Some(req.to_string()), + git: git_url, + commit: None, + tag: None, + branch: None, + path: None, + files: None, + kelvin: None, + }, + } + } + + /// Get a canonical string representation + pub fn to_canonical_string(&self) -> String { + match self { + VersionSpec::Kelvin(k) => format!("k{}", k), + VersionSpec::Commit(c) => format!("commit:{}", c), + VersionSpec::Tag(t) => format!("tag:{}", t), + VersionSpec::Branch(b) => format!("branch:{}", b), + VersionSpec::Semver(req) => req.to_string(), + } + } + + /// Check if this is an exact version (commit or tag), not a range + pub fn is_exact(&self) -> bool { + matches!(self, VersionSpec::Commit(_) | VersionSpec::Tag(_)) + } +} + +/// Parse a package spec in the form "name@version" +pub fn parse_package_spec(input: &str) -> Result<(String, VersionSpec)> { + if let Some((name, version_str)) = input.split_once('@') { + let name = name.trim().to_string(); + let version = VersionSpec::parse(version_str)?; + Ok((name, version)) + } else { + anyhow::bail!("Invalid package spec '{}': expected format 'name@version'", input) + } +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + + use super::*; + + #[test] + fn test_parse_kelvin() { + let spec = VersionSpec::parse("k414").unwrap(); + assert_eq!(spec, VersionSpec::Kelvin(414)); + + let spec = VersionSpec::parse("@k414").unwrap(); + assert_eq!(spec, VersionSpec::Kelvin(414)); + + let spec = VersionSpec::parse("k417").unwrap(); + assert_eq!(spec, VersionSpec::Kelvin(417)); + } + + #[test] + fn test_parse_commit() { + let spec = VersionSpec::parse("commit:abc123def").unwrap(); + assert_eq!(spec, VersionSpec::Commit("abc123def".to_string())); + + let spec = VersionSpec::parse("@commit:abc123").unwrap(); + assert_eq!(spec, VersionSpec::Commit("abc123".to_string())); + } + + #[test] + fn test_parse_tag() { + let spec = VersionSpec::parse("tag:v1.2.3").unwrap(); + assert_eq!(spec, VersionSpec::Tag("v1.2.3".to_string())); + + let spec = VersionSpec::parse("@tag:v2.0").unwrap(); + assert_eq!(spec, VersionSpec::Tag("v2.0".to_string())); + } + + #[test] + fn test_parse_branch() { + let spec = VersionSpec::parse("branch:main").unwrap(); + assert_eq!(spec, VersionSpec::Branch("main".to_string())); + + let spec = VersionSpec::parse("@branch:develop").unwrap(); + assert_eq!(spec, VersionSpec::Branch("develop".to_string())); + } + + #[test] + fn test_parse_semver() { + let spec = VersionSpec::parse("^1.2.0").unwrap(); + assert!(matches!(spec, VersionSpec::Semver(_))); + + let spec = VersionSpec::parse("~1.2.3").unwrap(); + assert!(matches!(spec, VersionSpec::Semver(_))); + + let spec = VersionSpec::parse(">=2.0.0").unwrap(); + assert!(matches!(spec, VersionSpec::Semver(_))); + + let spec = VersionSpec::parse("1.2.3").unwrap(); + assert!(matches!(spec, VersionSpec::Semver(_))); + } + + #[test] + fn test_matches_kelvin() { + let spec = VersionSpec::Kelvin(414); + + assert!(spec.matches("k414")); + assert!(spec.matches("@k414")); + assert!(!spec.matches("k415")); + assert!(!spec.matches("414")); + } + + #[test] + fn test_matches_commit() { + let spec = VersionSpec::Commit("abc123def".to_string()); + + assert!(spec.matches("abc123def456")); + assert!(spec.matches("abc123def")); + assert!(!spec.matches("def456abc")); + } + + #[test] + fn test_matches_tag() { + let spec = VersionSpec::Tag("v1.2.3".to_string()); + + assert!(spec.matches("v1.2.3")); + assert!(spec.matches("@v1.2.3")); + assert!(!spec.matches("v1.2.4")); + } + + #[test] + fn test_matches_semver() { + let spec = VersionSpec::parse("^1.2.0").unwrap(); + + assert!(spec.matches("1.2.0")); + assert!(spec.matches("1.2.5")); + assert!(spec.matches("1.9.0")); + assert!(!spec.matches("2.0.0")); + assert!(!spec.matches("1.1.9")); + + // Test with 'v' prefix + assert!(spec.matches("v1.2.3")); + } + + #[test] + fn test_parse_package_spec() { + let (name, version) = parse_package_spec("arvo@k414").unwrap(); + assert_eq!(name, "arvo"); + assert_eq!(version, VersionSpec::Kelvin(414)); + + let (name, version) = parse_package_spec("lagoon@^0.2.0").unwrap(); + assert_eq!(name, "lagoon"); + assert!(matches!(version, VersionSpec::Semver(_))); + + let (name, version) = parse_package_spec("sequent@commit:abc123").unwrap(); + assert_eq!(name, "sequent"); + assert_eq!(version, VersionSpec::Commit("abc123".to_string())); + } + + #[test] + fn test_to_canonical_string() { + assert_eq!(VersionSpec::Kelvin(414).to_canonical_string(), "k414"); + assert_eq!( + VersionSpec::Commit("abc123".to_string()).to_canonical_string(), + "commit:abc123" + ); + assert_eq!( + VersionSpec::Tag("v1.2.3".to_string()).to_canonical_string(), + "tag:v1.2.3" + ); + assert_eq!( + VersionSpec::Branch("main".to_string()).to_canonical_string(), + "branch:main" + ); + } + + #[test] + fn test_is_exact() { + assert!(VersionSpec::Commit("abc123".to_string()).is_exact()); + assert!(VersionSpec::Tag("v1.0.0".to_string()).is_exact()); + assert!(!VersionSpec::Kelvin(414).is_exact()); + assert!(!VersionSpec::Branch("main".to_string()).is_exact()); + assert!(!VersionSpec::parse("^1.2.0").unwrap().is_exact()); + } +} diff --git a/crates/nockup/src/resolver/types.rs b/crates/nockup/src/resolver/types.rs new file mode 100644 index 000000000..7b163be20 --- /dev/null +++ b/crates/nockup/src/resolver/types.rs @@ -0,0 +1,93 @@ +use std::collections::HashMap; + +use crate::manifest::DependencySpec; +use crate::resolver::VersionSpec; + +/// A resolved package with exact commit and dependencies +#[derive(Debug, Clone)] +pub struct ResolvedPackage { + pub name: String, + pub version_spec: VersionSpec, // Original spec from manifest + pub commit: String, // Exact commit hash + pub source_url: String, + pub source_path: Option, // Subdir within repo to fetch from (e.g., "pkg/arvo/sys") + pub install_path: Option, // Subdir to install to (e.g., "sys") + pub source_file: Option, // Specific file to extract (if any) + pub dependencies: HashMap, // Transitive deps +} + +/// A resolved dependency graph +#[derive(Debug)] +pub struct ResolvedGraph { + pub packages: HashMap, + pub install_order: Vec, // Topological sort for installation +} + +impl ResolvedGraph { + /// Create a new empty graph + pub fn new() -> Self { + Self { + packages: HashMap::new(), + install_order: Vec::new(), + } + } + + /// Add a package to the graph + pub fn add_package(&mut self, package: ResolvedPackage) { + self.packages.insert(package.name.clone(), package); + } + + /// Compute topological installation order + /// Simple approach: no cycles allowed, packages with no deps come first + pub fn compute_install_order(&mut self) -> anyhow::Result<()> { + let mut visited = HashMap::new(); + let mut order = Vec::new(); + + for name in self.packages.keys() { + self.visit_package(name, &mut visited, &mut order)?; + } + + self.install_order = order; + Ok(()) + } + + fn visit_package( + &self, + name: &str, + visited: &mut HashMap, + order: &mut Vec, + ) -> anyhow::Result<()> { + // Check if already processed + if let Some(&done) = visited.get(name) { + if !done { + anyhow::bail!("Circular dependency detected involving package '{}'", name); + } + return Ok(()); + } + + // Mark as being visited (for cycle detection) + visited.insert(name.to_string(), false); + + // Visit dependencies first + if let Some(pkg) = self.packages.get(name) { + for dep_name in pkg.dependencies.keys() { + // Only visit if we have this dependency in our graph + if self.packages.contains_key(dep_name) { + self.visit_package(dep_name, visited, order)?; + } + } + } + + // Mark as done + visited.insert(name.to_string(), true); + order.push(name.to_string()); + + Ok(()) + } +} + +impl Default for ResolvedGraph { + fn default() -> Self { + Self::new() + } +} diff --git a/crates/nockup/src/version.rs b/crates/nockup/src/version.rs index f91a170d2..64b8b351d 100644 --- a/crates/nockup/src/version.rs +++ b/crates/nockup/src/version.rs @@ -4,6 +4,8 @@ use anyhow::{Context, Result}; use colored::Colorize; use tokio::process::Command as TokioCommand; +use crate::commands::common; + pub async fn show_version_info() -> Result<()> { // Show nockup version println!("nockup version {}", env!("FULL_VERSION")); @@ -89,7 +91,7 @@ fn extract_version_string(version_line: &str) -> String { // Look for a word that looks like a version (starts with digit or 'v'). for word in &words { - if word.chars().next().map_or(false, |c| c.is_ascii_digit()) { + if word.chars().next().is_some_and(|c| c.is_ascii_digit()) { return word.to_string(); } if word.starts_with('v') && word.len() > 1 { @@ -109,6 +111,18 @@ fn get_cache_dir() -> Result { fn get_config() -> Result { let cache_dir = get_cache_dir()?; let config_path = cache_dir.join("config.toml"); + if !config_path.exists() { + let mut table = toml::map::Map::new(); + table.insert( + "channel".to_string(), + toml::Value::String("stable".to_string()), + ); + table.insert( + "architecture".to_string(), + toml::Value::String(common::get_target_identifier()), + ); + return Ok(toml::Value::Table(table)); + } let config_str = std::fs::read_to_string(&config_path)?; let config: toml::Value = toml::de::from_str(&config_str)?; Ok(config) diff --git a/crates/nockup/tests/cli_tests.rs b/crates/nockup/tests/cli_tests.rs index 7d3204f51..f9215b059 100644 --- a/crates/nockup/tests/cli_tests.rs +++ b/crates/nockup/tests/cli_tests.rs @@ -1,9 +1,18 @@ use std::process::Command; +#[allow(deprecated)] +use assert_cmd::cargo::cargo_bin; use assert_cmd::prelude::*; use predicates::prelude::*; use tempfile::TempDir; +// Helper to get the cargo bin path +// Note: cargo_bin function is deprecated but the macro isn't exposed through prelude +fn nockup_bin() -> std::path::PathBuf { + #[allow(deprecated)] + cargo_bin("nockup") +} + #[cfg(test)] mod cli_input_validation_tests { use super::*; @@ -11,7 +20,7 @@ mod cli_input_validation_tests { // Test basic command structure #[test] fn test_no_args_shows_version() { - let mut cmd = Command::cargo_bin("nockup").unwrap(); + let mut cmd = Command::new(nockup_bin()); cmd.assert() .success() .stdout(predicate::str::contains("version")); @@ -19,7 +28,7 @@ mod cli_input_validation_tests { #[test] fn test_help_command() { - let mut cmd = Command::cargo_bin("nockup").unwrap(); + let mut cmd = Command::new(nockup_bin()); cmd.arg("help"); cmd.assert() .success() @@ -28,7 +37,7 @@ mod cli_input_validation_tests { #[test] fn test_invalid_command() { - let mut cmd = Command::cargo_bin("nockup").unwrap(); + let mut cmd = Command::new(nockup_bin()); cmd.arg("invalid-command"); cmd.assert() .failure() @@ -39,8 +48,8 @@ mod cli_input_validation_tests { // Test install command validation #[test] fn test_install_with_invalid_flags() { - let mut cmd = Command::cargo_bin("nockup").unwrap(); - cmd.args(&["install", "--invalid-flag"]); + let mut cmd = Command::new(nockup_bin()); + cmd.args(["install", "--invalid-flag"]); cmd.assert() .failure() .stderr(predicate::str::contains("unexpected argument")); @@ -48,34 +57,34 @@ mod cli_input_validation_tests { // Test start command validation #[test] - fn test_start_without_project_name() { - let mut cmd = Command::cargo_bin("nockup").unwrap(); - cmd.arg("start"); + fn test_init_without_project_name() { + let mut cmd = Command::new(nockup_bin()); + cmd.arg("init"); cmd.assert() .failure() .stderr(predicate::str::contains("required")); } #[test] - fn test_start_with_empty_project_name() { - let mut cmd = Command::cargo_bin("nockup").unwrap(); - cmd.args(&["start", ""]); + fn test_init_with_empty_project_name() { + let mut cmd = Command::new(nockup_bin()); + cmd.args(["init", ""]); cmd.assert().failure().stderr(predicate::str::contains( "Error: Project configuration file '.toml' not found", )); } #[test] - fn test_start_with_valid_project_names() { + fn test_init_with_valid_project_names() { let valid_names = vec!["myproject", "my-project", "my_project", "project123"]; for name in valid_names { - let temp_dir = TempDir::new().unwrap(); - let mut cmd = Command::cargo_bin("nockup").unwrap(); - cmd.current_dir(temp_dir.path()).args(&["start", name]); + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let mut cmd = Command::new(nockup_bin()); + cmd.current_dir(temp_dir.path()).args(["init", name]); // This might fail due to missing cache, but shouldn't fail on name validation - let output = cmd.output().unwrap(); + let output = cmd.output().expect("Failed to run command"); let stderr = String::from_utf8_lossy(&output.stderr); assert!(!stderr.contains("invalid project name")); } @@ -84,7 +93,7 @@ mod cli_input_validation_tests { // Test build command validation #[test] fn test_build_without_project_name() { - let mut cmd = Command::cargo_bin("nockup").unwrap(); + let mut cmd = Command::new(nockup_bin()); cmd.arg("build"); cmd.assert() .failure() @@ -93,10 +102,10 @@ mod cli_input_validation_tests { #[test] fn test_build_nonexistent_project() { - let temp_dir = TempDir::new().unwrap(); - let mut cmd = Command::cargo_bin("nockup").unwrap(); + let temp_dir = TempDir::new().expect("Failed to create temp dir"); + let mut cmd = Command::new(nockup_bin()); cmd.current_dir(temp_dir.path()) - .args(&["build", "nonexistent-project"]); + .args(["build", "nonexistent-project"]); cmd.assert().failure().stderr( predicate::str::contains("Project directory") .and(predicate::str::contains("not found")), @@ -106,7 +115,7 @@ mod cli_input_validation_tests { // Test run command validation #[test] fn test_run_without_project_name() { - let mut cmd = Command::cargo_bin("nockup").unwrap(); + let mut cmd = Command::new(nockup_bin()); cmd.arg("run"); cmd.assert() .failure() @@ -116,7 +125,7 @@ mod cli_input_validation_tests { // Test channel command validation #[test] fn test_channel_without_subcommand() { - let mut cmd = Command::cargo_bin("nockup").unwrap(); + let mut cmd = Command::new(nockup_bin()); cmd.arg("channel"); cmd.assert() .failure() @@ -124,9 +133,9 @@ mod cli_input_validation_tests { } #[test] - fn test_channel_list_with_extra_args() { - let mut cmd = Command::cargo_bin("nockup").unwrap(); - cmd.args(&["channel", "list", "extra-arg"]); + fn test_channel_show_with_extra_args() { + let mut cmd = Command::new(nockup_bin()); + cmd.args(["channel", "show", "extra-arg"]); cmd.assert() .failure() .stderr(predicate::str::contains("unexpected argument")); @@ -134,8 +143,8 @@ mod cli_input_validation_tests { #[test] fn test_channel_set_without_channel_name() { - let mut cmd = Command::cargo_bin("nockup").unwrap(); - cmd.args(&["channel", "set"]); + let mut cmd = Command::new(nockup_bin()); + cmd.args(["channel", "set"]); cmd.assert() .failure() .stderr(predicate::str::contains("required")); @@ -143,8 +152,8 @@ mod cli_input_validation_tests { #[test] fn test_channel_set_invalid_channel() { - let mut cmd = Command::cargo_bin("nockup").unwrap(); - cmd.args(&["channel", "set", "invalid-channel"]); + let mut cmd = Command::new(nockup_bin()); + cmd.args(["channel", "set", "invalid-channel"]); cmd.assert() .failure() .stderr(predicate::str::contains("Invalid channel")); @@ -155,51 +164,25 @@ mod cli_input_validation_tests { let channels = vec!["stable", "nightly"]; for channel in channels { - let mut cmd = Command::cargo_bin("nockup").unwrap(); - cmd.args(&["channel", "set", channel]); + let mut cmd = Command::new(nockup_bin()); + cmd.args(["channel", "set", channel]); // This might fail due to missing cache, but shouldn't fail on channel validation - let output = cmd.output().unwrap(); + let output = cmd.output().expect("Failed to run command"); let stderr = String::from_utf8_lossy(&output.stderr); assert!(!stderr.contains("invalid channel")); } } - // // Test path validation - // #[test] - // fn test_start_in_existing_directory() { - // let temp_dir = TempDir::new().unwrap(); - // let project_path = temp_dir.path().join("existing-project"); - // std::fs::create_dir_all(&project_path).unwrap(); - // std::fs::write(project_path.join("dummy.txt"), "exists").unwrap(); - - // let mut cmd = Command::cargo_bin("nockup").unwrap(); - // // copy the local default-manifest.toml file to tempdir - // std::fs::copy("default-manifest.toml", temp_dir.path().join("default-manifest.toml")).unwrap(); - // cmd.current_dir(temp_dir.path()) - // .args(&["start", "default-manifest"]); - // cmd.assert() - // .success() - // .stdout(predicate::str::contains("Project 'arcadia' created successfully")); - // // new command - // cmd.current_dir(temp_dir.path()) - // .args(&["start", "default-manifest"]); - // cmd.assert() - // .failure() - // .stderr(predicate::str::contains("already exists. Please choose")); - // // Clear ./default-manifest - // std::fs::remove_dir_all(temp_dir.path().join("default-manifest")).unwrap(); - // } - // Test configuration file validation (if manifest is required) #[test] fn test_build_without_manifest() { - let temp_dir = TempDir::new().unwrap(); + let temp_dir = TempDir::new().expect("Failed to create temp dir"); let project_dir = temp_dir.path().join("test-project"); - std::fs::create_dir_all(&project_dir).unwrap(); + std::fs::create_dir_all(&project_dir).expect("Failed to create project dir"); - let mut cmd = Command::cargo_bin("nockup").unwrap(); - cmd.current_dir(&project_dir).args(&["build", "."]); + let mut cmd = Command::new(nockup_bin()); + cmd.current_dir(&project_dir).args(["build", "."]); cmd.assert().failure().stderr(predicate::str::contains( "Error: Not a NockApp project: '.' missing manifest.toml", )); diff --git a/crates/nockvm/rust/ibig/Cargo.toml b/crates/nockvm/rust/ibig/Cargo.toml index 77b06b0a3..d28da5236 100644 --- a/crates/nockvm/rust/ibig/Cargo.toml +++ b/crates/nockvm/rust/ibig/Cargo.toml @@ -3,7 +3,7 @@ name = "ibig" version = "0.3.6" authors = ["Tomek Czajka "] edition = "2021" -rust-version = "1.56.0" +rust-version = "1.87.0" description = "A big integer library with good performance" keywords = ["bigint", "bignum", "mathematics", "modular", "modulo"] categories = ["mathematics", "no-std"] @@ -13,12 +13,14 @@ homepage = "https://github.com/tczajka/ibig-rs" readme = "README.md" exclude = ["generate_coverage.sh"] -[lints.clippy] -missing_safety_doc = "allow" - [package.metadata.docs.rs] all-features = true +[lib] +bench = false +test = false +doctest = false + [features] default = ["std", "rand", "num-traits"] @@ -28,23 +30,23 @@ std = [] [dependencies.cfg-if] workspace = true -[dependencies.static_assertions] -workspace = true - [dependencies.num-traits] default-features = false optional = true -workspace = true +version = "0.2" [dependencies.rand] default-features = false optional = true -workspace = true +version = "0.9.2" [dependencies.serde] default-features = false features = ["derive"] optional = true +version = "1.0.217" + +[dependencies.static_assertions] workspace = true [dev-dependencies.criterion] @@ -54,20 +56,15 @@ features = ["html_reports"] [dev-dependencies.rand] workspace = true -[dev-dependencies.serde_test] -version = "1.0.130" +[lints.clippy] +missing_safety_doc = "allow" -[lib] -bench = false -test = false -doctest = false +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = [ + 'cfg(force_bits, values("16","32","64"))', +] } [[bench]] name = "benchmarks" required-features = ["rand"] harness = false - -[lints.rust] -unexpected_cfgs = { level = "warn", check-cfg = [ - 'cfg(force_bits, values("16","32","64"))', -] } diff --git a/crates/nockvm/rust/ibig/benches/benchmarks.rs b/crates/nockvm/rust/ibig/benches/benchmarks.rs index 7b51ee256..1926bbb93 100644 --- a/crates/nockvm/rust/ibig/benches/benchmarks.rs +++ b/crates/nockvm/rust/ibig/benches/benchmarks.rs @@ -2,6 +2,8 @@ //! //! Note: these don't work on 16-bit machines. +#![allow(deprecated)] + use std::fmt::Write; use criterion::{ diff --git a/crates/nockvm/rust/ibig/src/buffer.rs b/crates/nockvm/rust/ibig/src/buffer.rs index 9859caa93..56b9d8c1c 100644 --- a/crates/nockvm/rust/ibig/src/buffer.rs +++ b/crates/nockvm/rust/ibig/src/buffer.rs @@ -131,7 +131,7 @@ impl Buffer { /// Panics if there is not enough capacity. pub(crate) fn push_zeros(&mut self, n: usize) { assert!(n <= self.capacity() - self.len()); - self.0.extend(iter::repeat(0).take(n)); + self.0.extend(iter::repeat_n(0, n)); } /// Insert `n` zeros in front. @@ -141,7 +141,7 @@ impl Buffer { /// Panics if there is not enough capacity. pub(crate) fn push_zeros_front(&mut self, n: usize) { assert!(n <= self.capacity() - self.len()); - self.0.splice(..0, iter::repeat(0).take(n)); + self.0.splice(..0, iter::repeat_n(0, n)); } /// Pop the most significant `Word`. @@ -283,6 +283,7 @@ impl<'a> Extend<&'a Word> for Buffer { mod tests { use alloc::alloc; use core::alloc::Layout; + use core::ptr::NonNull; use super::*; use crate::memory::Stack; @@ -299,7 +300,7 @@ mod tests { impl Stack for TestStack { unsafe fn alloc_layout(&mut self, layout: Layout) -> *mut u64 { if layout.size() == 0 { - layout.dangling().as_ptr() + NonNull::::dangling().as_ptr() } else { let ptr = alloc::alloc(layout); ptr as *mut u64 diff --git a/crates/nockvm/rust/ibig/src/fmt/non_power_two.rs b/crates/nockvm/rust/ibig/src/fmt/non_power_two.rs index 9e81d6c91..81da32eb6 100644 --- a/crates/nockvm/rust/ibig/src/fmt/non_power_two.rs +++ b/crates/nockvm/rust/ibig/src/fmt/non_power_two.rs @@ -191,7 +191,7 @@ impl PreparedLarge { } // 2 * prev.len() is at most 1 larger than number.len(). // It won't overflow because UBig::MAX_LEN is even. - const_assert!(UBig::MAX_LEN % 2 == 0); + const_assert!(UBig::MAX_LEN.is_multiple_of(2)); let new = prev * prev; if new > *number { break; diff --git a/crates/nockvm/rust/ibig/src/lib.rs b/crates/nockvm/rust/ibig/src/lib.rs index cfcc5acb6..5a7af26b5 100644 --- a/crates/nockvm/rust/ibig/src/lib.rs +++ b/crates/nockvm/rust/ibig/src/lib.rs @@ -61,6 +61,8 @@ //! * `serde`: serialization and deserialization. #![cfg_attr(not(feature = "std"), no_std)] +// Allow internal uses of deprecated pow APIs and lifetime syntax compat noise; host code should prefer num-bigint. +#![allow(deprecated, mismatched_lifetime_syntaxes, unused_unsafe)] extern crate alloc; diff --git a/crates/nockvm/rust/ibig/src/modular/convert.rs b/crates/nockvm/rust/ibig/src/modular/convert.rs index c8722bbb9..9a0ce22c7 100644 --- a/crates/nockvm/rust/ibig/src/modular/convert.rs +++ b/crates/nockvm/rust/ibig/src/modular/convert.rs @@ -218,7 +218,7 @@ impl<'a> ModuloLarge<'a> { } } } - vec.extend(iter::repeat(0).take(modulus.len() - vec.len())); + vec.extend(iter::repeat_n(0, modulus.len() - vec.len())); ModuloLarge::new(vec, ring) } } diff --git a/crates/nockvm/rust/ibig/src/mul/karatsuba.rs b/crates/nockvm/rust/ibig/src/mul/karatsuba.rs index 388788b7b..5eb2e1c1a 100644 --- a/crates/nockvm/rust/ibig/src/mul/karatsuba.rs +++ b/crates/nockvm/rust/ibig/src/mul/karatsuba.rs @@ -74,7 +74,7 @@ pub(crate) fn add_signed_mul_same_len( debug_assert!(b.len() == n && c.len() == 2 * n); debug_assert!(n >= MIN_LEN); - let mid = (n + 1) / 2; + let mid = n.div_ceil(2); let (a_lo, a_hi) = a.split_at(mid); let (b_lo, b_hi) = b.split_at(mid); diff --git a/crates/nockvm/rust/ibig/src/mul/toom_3.rs b/crates/nockvm/rust/ibig/src/mul/toom_3.rs index 15740973e..86534b84e 100644 --- a/crates/nockvm/rust/ibig/src/mul/toom_3.rs +++ b/crates/nockvm/rust/ibig/src/mul/toom_3.rs @@ -110,7 +110,7 @@ pub(crate) fn add_signed_mul_same_len( // t2 = (V(1) + V(-1))/2 // Split into 3 parts. Note: a2, b2 may be shorter. - let n3 = (n + 2) / 3; + let n3 = n.div_ceil(3); let n3_short = n - 2 * n3; let (a0, a12) = a.split_at(n3); diff --git a/crates/nockvm/rust/murmur3/Cargo.toml b/crates/nockvm/rust/murmur3/Cargo.toml index c3d566a1d..057e8a911 100644 --- a/crates/nockvm/rust/murmur3/Cargo.toml +++ b/crates/nockvm/rust/murmur3/Cargo.toml @@ -8,8 +8,12 @@ keywords = ["hash", "murmur3", "murmur"] license = "MIT/Apache-2.0" edition = "2021" +[features] +default = [] +c-ffi = ["murmur3-sys"] + [dependencies] +murmur3-sys = { path = "./murmur3-sys", optional = true } [dev-dependencies] -murmur3-sys = { path = "./murmur3-sys" } quickcheck = "1.0.3" diff --git a/crates/nockvm/rust/murmur3/murmur3-sys/Cargo.toml b/crates/nockvm/rust/murmur3/murmur3-sys/Cargo.toml index 97a94df10..41b2c2c08 100644 --- a/crates/nockvm/rust/murmur3/murmur3-sys/Cargo.toml +++ b/crates/nockvm/rust/murmur3/murmur3-sys/Cargo.toml @@ -3,8 +3,10 @@ name = "murmur3-sys" version = "0.1.0" authors = ["Stu Small "] edition = "2018" +license = "MIT OR Apache-2.0" [dependencies] [build-dependencies] bindgen = "0.69.1" +cc = "1.0" diff --git a/crates/nockvm/rust/murmur3/murmur3-sys/build.rs b/crates/nockvm/rust/murmur3/murmur3-sys/build.rs index 6e9ec5df0..a313e20b6 100644 --- a/crates/nockvm/rust/murmur3/murmur3-sys/build.rs +++ b/crates/nockvm/rust/murmur3/murmur3-sys/build.rs @@ -1,39 +1,24 @@ -extern crate bindgen; +fn main() { + let out_dir = std::env::var("OUT_DIR").expect("No out dir"); -use std::env; -use std::path::PathBuf; -use std::process::Command; + // If LIBCLANG_SO_PATH is set (by Bazel), derive LIBCLANG_PATH from it + // This is needed for hermetic Nix builds where libclang is in the Nix store + if let Ok(libclang_so_path) = std::env::var("LIBCLANG_SO_PATH") { + if let Some(parent) = std::path::Path::new(&libclang_so_path).parent() { + std::env::set_var("LIBCLANG_PATH", parent); + } + } -fn main() { - let out_dir = env::var("OUT_DIR").expect("No out dir"); - println!("cargo:rustc-link-lib=murmur3"); - println!("cargo:rustc-link-search=native={}", &out_dir); + // Compile the C library + cc::Build::new().file("murmur3.c").compile("murmur3"); + // Generate Rust bindings let bindings = bindgen::Builder::default() .header("murmur3.h") .generate() .expect("Unable to generate bindings"); - let target_os = std::env::var_os("CARGO_CFG_TARGET_OS").expect("No target OS"); - if target_os == "linux" { - assert!(Command::new("make") - .arg("shared") - .status() - .expect("Building C lib failed") - .success()); - } else if target_os == "macos" { - assert!(Command::new("make") - .arg("shared-mac") - .status() - .expect("Building C lib failed") - .success()); - } else { - panic!("Unsupported OS: {:?}", target_os); - } - - // Write the bindings to the $OUT_DIR/bindings.rs file. - let out_path = PathBuf::from(out_dir); bindings - .write_to_file(out_path.join("bindings.rs")) + .write_to_file(std::path::Path::new(&out_dir).join("bindings.rs")) .expect("Couldn't write bindings!"); } diff --git a/crates/nockvm/rust/murmur3/tests/quickcheck.rs b/crates/nockvm/rust/murmur3/tests/quickcheck.rs index 28197ad5d..68f4d6f11 100644 --- a/crates/nockvm/rust/murmur3/tests/quickcheck.rs +++ b/crates/nockvm/rust/murmur3/tests/quickcheck.rs @@ -5,6 +5,7 @@ // license , at your // option. All files in the project carrying such notice may not be copied, // modified, or distributed except according to those terms. +#![cfg(feature = "c-ffi")] #[macro_use] extern crate quickcheck; diff --git a/crates/nockvm/rust/nockvm/Cargo.toml b/crates/nockvm/rust/nockvm/Cargo.toml index b810ed5fb..689aa9124 100644 --- a/crates/nockvm/rust/nockvm/Cargo.toml +++ b/crates/nockvm/rust/nockvm/Cargo.toml @@ -3,60 +3,59 @@ name = "nockvm" version = "0.1.0" authors = ["Edward Amsden "] edition = "2021" +license = "MIT OR Apache-2.0" -[lints.clippy] -missing_safety_doc = "allow" +# run with e.g. 'cargo build --features check_forwarding,check_acyclic' +[features] +default = ["mmap"] +nock_opcode_census = [] +malloc = [] +mmap = [] +# Dangerous +no_check_oom = [] +# FOR DEBUGGING MEMORY ISSUES ONLY +check_all = ["check_acyclic", "check_forwarding", "check_junior"] +check_acyclic = [] +check_forwarding = [] +check_junior = [] +sham_hints = [] +stop_for_debug = [] +hint_dont = [] # Please keep these alphabetized [dependencies] -ibig.workspace = true -murmur3.workspace = true -nockvm_crypto = { workspace = true } -nockvm_macros.workspace = true bincode = { workspace = true, features = ["serde"] } -bytes = { workspace = true, features = ["serde"] } bitvec = { workspace = true } +bytes = { workspace = true, features = ["serde"] } either = { workspace = true } +ibig.workspace = true intmap = { workspace = true } -json = { workspace = true } lazy_static = { workspace = true } libc = { workspace = true } memmap2 = { workspace = true } +murmur3.workspace = true +nockvm_crypto = { workspace = true } +nockvm_macros.workspace = true num-derive = { workspace = true } num-traits = { workspace = true } rand = { workspace = true } serde = { workspace = true } -signal-hook = { workspace = true } slotmap = { workspace = true } static_assertions = { workspace = true } thiserror = { workspace = true } tracing.workspace = true tracing-core.workspace = true -[dev-dependencies] -criterion = { workspace = true } - [build-dependencies] autotools = "0.2" cc = "1.0" -# run with e.g. 'cargo build --features check_forwarding,check_acyclic' -[features] -default = ["mmap"] -nock_opcode_census = [] -malloc = [] -mmap = [] -# Dangerous -no_check_oom = [] -# FOR DEBUGGING MEMORY ISSUES ONLY -check_all = ["check_acyclic", "check_forwarding", "check_junior"] -check_acyclic = [] -check_forwarding = [] -check_junior = [] -sham_hints = [] -stop_for_debug = [] -hint_dont = [] +[dev-dependencies] +criterion = { workspace = true } + +[lints.clippy] +missing_safety_doc = "allow" [[bench]] name = "hoonc_hotspots" diff --git a/crates/nockvm/rust/nockvm/src/hamt.rs b/crates/nockvm/rust/nockvm/src/hamt.rs index 17e8abeda..a9f6d4fb9 100644 --- a/crates/nockvm/rust/nockvm/src/hamt.rs +++ b/crates/nockvm/rust/nockvm/src/hamt.rs @@ -305,7 +305,7 @@ impl Hamt { } /// Borrowing iterator for Hamt, the type name is a portmanteau of Hamt, iterator, and hamster. - pub fn iter(&self) -> Hamsterator { + pub fn iter(&self) -> Hamsterator<'_, T> { Hamsterator::new(self) } diff --git a/crates/nockvm/rust/nockvm/src/interpreter.rs b/crates/nockvm/rust/nockvm/src/interpreter.rs index 7a67e703f..838c69d78 100644 --- a/crates/nockvm/rust/nockvm/src/interpreter.rs +++ b/crates/nockvm/rust/nockvm/src/interpreter.rs @@ -1087,26 +1087,24 @@ pub fn interpret(context: &mut Context, mut subject: Noun, formula: Noun) -> Res loop { let running_status = context.running_status.load(Ordering::SeqCst); - if running_status < NockCancelToken::RUNNING_IDLE { - if context - .running_status - .compare_exchange( - running_status, - running_status + 1, - Ordering::SeqCst, - Ordering::SeqCst, - ) - .is_ok() - { - break; - } - } else if running_status == NockCancelToken::RUNNING_IDLE { + + // Already idle - no action needed + if running_status == NockCancelToken::RUNNING_IDLE { break; - } else if context + } + + // Adjust status: increment if below idle (cancellation pending), decrement if above (multiple runners) + let new_status = if running_status < NockCancelToken::RUNNING_IDLE { + running_status + 1 + } else { + running_status - 1 + }; + + if context .running_status .compare_exchange( running_status, - running_status - 1, + new_status, Ordering::SeqCst, Ordering::SeqCst, ) @@ -1153,7 +1151,7 @@ mod cold_paths { if vale.tail { stack.pop::(); *subject = vale.subject; - try_or_bail!(push_formula(stack, res.clone(), true)); + try_or_bail!(push_formula(stack, *res, true)); return OK_CONTINUE; } else { vale.todo = Todo2::RestoreSubject; @@ -1220,7 +1218,7 @@ mod cold_paths { Todo11S::Done => { opcode_tick!(WORK11S); if let Some(found) = - hint::match_post_nock(context, *subject, sint.tag, None, sint.body, res.clone()) + hint::match_post_nock(context, *subject, sint.tag, None, sint.body, *res) { *res = found; } @@ -1248,17 +1246,17 @@ mod cold_paths { Todo12::ComputePath => { opcode_tick!(WORK12); scry.todo = Todo12::Scry; - scry.reff = res.clone(); + scry.reff = *res; try_or_bail!(push_formula(&mut context.stack, scry.path, false)); return OK_CONTINUE; } Todo12::Scry => { if let Some(cell) = context.scry_stack.cell() { - scry.path = res.clone(); + scry.path = *res; let scry_stack = context.scry_stack; let scry_handler = cell.head(); let scry_gate = scry_handler.as_cell()?; - let payload = T(&mut context.stack, &[scry.reff, res.clone()]); + let payload = T(&mut context.stack, &[scry.reff, *res]); let scry_core = T( &mut context.stack, &[scry_gate.head(), payload, scry_gate.tail().as_cell()?.tail()], diff --git a/crates/nockvm/rust/nockvm/src/jets/set.rs b/crates/nockvm/rust/nockvm/src/jets/set.rs index c427ea785..6d4c3c3e0 100644 --- a/crates/nockvm/rust/nockvm/src/jets/set.rs +++ b/crates/nockvm/rust/nockvm/src/jets/set.rs @@ -164,7 +164,7 @@ mod tests { if unsafe { tree.raw_equals(&D(0)) } { return false; } - let (value, left, right) = decompose(tree).unwrap(); + let (value, left, right) = decompose(tree).expect("tree should be valid set node"); if unsafe { value.raw_equals(&elem) } { return true; } @@ -295,8 +295,10 @@ mod tests { for val in perm { let noun_val = D(val); - jet_tree = put_iter(&mut context.stack, jet_tree, noun_val).unwrap(); - rec_tree = put_recursive(&mut context.stack, rec_tree, noun_val).unwrap(); + jet_tree = put_iter(&mut context.stack, jet_tree, noun_val) + .expect("put_iter should succeed"); + rec_tree = put_recursive(&mut context.stack, rec_tree, noun_val) + .expect("put_recursive should succeed"); } assert_eq!(tree_height(jet_tree), tree_height(rec_tree)); @@ -316,8 +318,10 @@ mod tests { seed = seed.wrapping_mul(6364136223846793005).wrapping_add(1); let value = (seed >> 32) & 0xFFFF; let noun_val = D(value); - jet_tree = put_iter(&mut context.stack, jet_tree, noun_val).unwrap(); - rec_tree = put_recursive(&mut context.stack, rec_tree, noun_val).unwrap(); + jet_tree = put_iter(&mut context.stack, jet_tree, noun_val) + .expect("put_iter should succeed"); + rec_tree = put_recursive(&mut context.stack, rec_tree, noun_val) + .expect("put_recursive should succeed"); } assert_eq!(tree_height(jet_tree), tree_height(rec_tree)); diff --git a/crates/nockvm/rust/nockvm/src/lib.rs b/crates/nockvm/rust/nockvm/src/lib.rs index 5fafe52ac..b1b9b4879 100644 --- a/crates/nockvm/rust/nockvm/src/lib.rs +++ b/crates/nockvm/rust/nockvm/src/lib.rs @@ -1,6 +1,7 @@ #![feature(cold_path)] -#![feature(cfg_boolean_literals)] #![allow(dead_code)] +#![allow(clippy::needless_return)] +#![allow(clippy::should_implement_trait)] extern crate lazy_static; extern crate num_derive; diff --git a/crates/nockvm/rust/nockvm/src/mem.rs b/crates/nockvm/rust/nockvm/src/mem.rs index b0e907d58..0f71dc7e4 100644 --- a/crates/nockvm/rust/nockvm/src/mem.rs +++ b/crates/nockvm/rust/nockvm/src/mem.rs @@ -423,8 +423,16 @@ impl NockStack { // Types of size: word (words: usize) /// Check if an allocation or pointer retrieval indicates an invalid request or an invalid state pub(crate) fn alloc_would_oom_(&self, alloc: Allocation, words: usize) { + // When the fast path is enabled, make the parameters count as used to avoid + // unused-variable warnings while still short-circuiting. #[cfg(feature = "no_check_oom")] - return; + { + let _ = (&alloc, words); + } + + if cfg!(feature = "no_check_oom") { + return; + } let _memory_state = self.memory_state(Some(words)); if self.pc && !alloc.alloc_type.allowed_when_pc() { panic_any(self.cannot_alloc_in_pc(Some(words))); @@ -622,9 +630,17 @@ impl NockStack { self.frame_offset = new_frame_offset; self.stack_offset = new_frame_offset; self.alloc_offset = new_alloc_offset; - self.least_space = new_frame_offset - .checked_sub(new_alloc_offset) - .expect("Uncaught OOM in flip_top_frame west->east"); + self.least_space = match new_frame_offset.checked_sub(new_alloc_offset) { + Some(space) => space, + None => panic_any(self.out_of_memory( + Allocation { + orientation: ArenaOrientation::West, + alloc_type: AllocationType::FlipTopFrame, + pc: self.pc, + }, + Some(size), + )), + }; self.pc = false; assert!(!self.is_west()); @@ -653,9 +669,17 @@ impl NockStack { self.frame_offset = new_frame_offset; self.stack_offset = new_frame_offset; self.alloc_offset = new_alloc_offset; - self.least_space = new_alloc_offset - .checked_sub(new_frame_offset) - .expect("Uncaught OOM in flip_top_frame east->west"); + self.least_space = match new_alloc_offset.checked_sub(new_frame_offset) { + Some(space) => space, + None => panic_any(self.out_of_memory( + Allocation { + orientation: ArenaOrientation::East, + alloc_type: AllocationType::FlipTopFrame, + pc: self.pc, + }, + Some(size), + )), + }; self.pc = false; assert!(self.is_west()); @@ -1030,9 +1054,12 @@ impl NockStack { }; // Update the space low-water-mark - let new_space = new_alloc_offset - .checked_sub(self.stack_offset) - .expect("Uncaught OOM in raw_alloc_west"); + let new_space = match new_alloc_offset.checked_sub(self.stack_offset) { + Some(space) => space, + None => panic_any( + self.out_of_memory(self.get_alloc_config(AllocationType::Alloc), Some(words)), + ), + }; self.least_space = new_space.min(self.least_space); // Derive pointer from the new offset @@ -1062,10 +1089,12 @@ impl NockStack { None => panic!("Alloc offset overflow in East frame"), }; - let new_space = self - .stack_offset - .checked_sub(new_alloc_offset) - .expect("Uncaught OOM in raw_alloc_east"); + let new_space = match self.stack_offset.checked_sub(new_alloc_offset) { + Some(space) => space, + None => panic_any( + self.out_of_memory(self.get_alloc_config(AllocationType::Alloc), Some(words)), + ), + }; self.least_space = new_space.min(self.least_space); // Check that the new offset is within bounds diff --git a/crates/nockvm/rust/nockvm/src/noun.rs b/crates/nockvm/rust/nockvm/src/noun.rs index 941649246..01e1787ac 100644 --- a/crates/nockvm/rust/nockvm/src/noun.rs +++ b/crates/nockvm/rust/nockvm/src/noun.rs @@ -27,13 +27,13 @@ pub(crate) const DIRECT_MASK: u64 = !(u64::MAX >> 1); pub const DIRECT_MAX: u64 = u64::MAX >> 1; /** Tag for an indirect atom. */ -pub(crate) const INDIRECT_TAG: u64 = u64::MAX & DIRECT_MASK; +pub(crate) const INDIRECT_TAG: u64 = DIRECT_MASK; /** Tag mask for an indirect atom. */ pub(crate) const INDIRECT_MASK: u64 = !(u64::MAX >> 2); /** Tag for a cell. */ -pub(crate) const CELL_TAG: u64 = u64::MAX & INDIRECT_MASK; +pub(crate) const CELL_TAG: u64 = INDIRECT_MASK; /** Tag mask for a cell. */ pub(crate) const CELL_MASK: u64 = !(u64::MAX >> 3); @@ -57,7 +57,7 @@ pub(crate) const CELL_MASK: u64 = !(u64::MAX >> 3); */ /** Tag for a forwarding pointer */ -const FORWARDING_TAG: u64 = u64::MAX & CELL_MASK; +const FORWARDING_TAG: u64 = CELL_MASK; /** Tag mask for a forwarding pointer */ const FORWARDING_MASK: u64 = CELL_MASK; @@ -731,7 +731,7 @@ impl Cell { pub unsafe fn new_raw_mut(allocator: &mut A) -> (Cell, *mut CellMemory) { let memory = allocator.alloc_cell(); assert!( - memory as usize % std::mem::align_of::() == 0, + (memory as usize).is_multiple_of(std::mem::align_of::()), "Memory is not aligned, {} {}", memory as usize, std::mem::align_of::() @@ -819,7 +819,7 @@ impl fmt::Debug for FullDebugCell<'_> { Ok(()) } - do_fmt(&*self.0, true, f)?; + do_fmt(self.0, true, f)?; Ok(()) } } @@ -1766,16 +1766,25 @@ mod tests { let cell = Cell::new(&mut context.stack, D(1), D(2)); // axis 1 returns the whole cell - assert_eq!( - unsafe { cell.slot(1).unwrap().raw_equals(&cell.as_noun()) }, - true - ); + assert!(unsafe { + cell.slot(1) + .expect("slot(1) should exist") + .raw_equals(&cell.as_noun()) + }); // axis 2 returns head - assert_eq!(unsafe { cell.slot(2).unwrap().raw_equals(&D(1)) }, true); + assert!(unsafe { + cell.slot(2) + .expect("slot(2) should exist") + .raw_equals(&D(1)) + }); // axis 3 returns tail - assert_eq!(unsafe { cell.slot(3).unwrap().raw_equals(&D(2)) }, true); + assert!(unsafe { + cell.slot(3) + .expect("slot(3) should exist") + .raw_equals(&D(2)) + }); } #[test] @@ -1786,10 +1795,18 @@ mod tests { let cell = Cell::new(&mut context.stack, D(1), inner.as_noun()); // axis 6 = 110 binary = tail then head = head of tail = 3 - assert_eq!(unsafe { cell.slot(6).unwrap().raw_equals(&D(3)) }, true); + assert!(unsafe { + cell.slot(6) + .expect("slot(6) should exist") + .raw_equals(&D(3)) + }); // axis 7 = 111 binary = tail then tail = tail of tail = 4 - assert_eq!(unsafe { cell.slot(7).unwrap().raw_equals(&D(4)) }, true); + assert!(unsafe { + cell.slot(7) + .expect("slot(7) should exist") + .raw_equals(&D(4)) + }); // axis 4 = 100 binary = head then stop = should fail (head is atom) assert!(cell.slot(4).is_err()); @@ -1797,7 +1814,12 @@ mod tests { // cell2 = [[3 4] 2] let cell2 = Cell::new(&mut context.stack, inner.as_noun(), D(2)); // axis 5 = 101 binary = head then tail = tail of head = 4 - assert_eq!(unsafe { cell2.slot(5).unwrap().raw_equals(&D(4)) }, true); + assert!(unsafe { + cell2 + .slot(5) + .expect("slot(5) should exist") + .raw_equals(&D(4)) + }); } #[test] diff --git a/crates/nockvm/rust/nockvm/src/serialization.rs b/crates/nockvm/rust/nockvm/src/serialization.rs index d63a27d0e..aab773119 100644 --- a/crates/nockvm/rust/nockvm/src/serialization.rs +++ b/crates/nockvm/rust/nockvm/src/serialization.rs @@ -789,7 +789,7 @@ mod tests { fn test_cell_construction() { let mut stack = setup_stack(); let (cell, cell_mem_ptr) = unsafe { Cell::new_raw_mut(&mut stack) }; - unsafe { assert!(cell_mem_ptr as *const CellMemory == cell.to_raw_pointer()) }; + unsafe { assert!(std::ptr::eq(cell_mem_ptr, cell.to_raw_pointer())) }; } #[test] diff --git a/crates/nockvm/rust/nockvm/src/trace/filter.rs b/crates/nockvm/rust/nockvm/src/trace/filter.rs index c4b9c8f3f..ee6cfc4fc 100644 --- a/crates/nockvm/rust/nockvm/src/trace/filter.rs +++ b/crates/nockvm/rust/nockvm/src/trace/filter.rs @@ -82,6 +82,6 @@ impl TraceFilter for IntervalFilter { fn should_trace(&mut self, _: Noun) -> bool { let c = self.cnt; self.cnt += 1; - c % self.interval == 0 + c.is_multiple_of(self.interval) } } diff --git a/crates/nockvm/rust/nockvm/src/trace/json.rs b/crates/nockvm/rust/nockvm/src/trace/json.rs deleted file mode 100644 index b8594ddca..000000000 --- a/crates/nockvm/rust/nockvm/src/trace/json.rs +++ /dev/null @@ -1,180 +0,0 @@ -use std::fs::{create_dir_all, File}; -use std::io::{Error, Write}; -use std::result::Result; -use std::time::Instant; - -use ::json::object; - -use super::*; -use crate::mem::NockStack; -use crate::noun::Noun; - -#[derive(Clone, Copy)] -struct TraceData { - pub start: Instant, - pub path: Noun, -} - -pub struct JsonBackend { - pub file: File, - pub pid: u32, - pub process_start: Instant, -} - -impl TraceBackend for JsonBackend { - fn append_trace(&mut self, stack: &mut NockStack, path: Noun) { - TraceStack::push_on_stack( - stack, - TraceData { - start: Instant::now(), - path, - }, - ); - } - - unsafe fn write_nock_trace( - &mut self, - stack: &mut NockStack, - trace_stack: *const TraceStack, - ) -> Result<(), Error> { - let mut trace_stack = trace_stack as *const TraceStack; - let now = Instant::now(); - - while !trace_stack.is_null() { - let ts = (*trace_stack) - .start - .saturating_duration_since(self.process_start) - .as_micros() as f64; - let dur = now - .saturating_duration_since((*trace_stack).start) - .as_micros() as f64; - - // Don't write out traces less than 33us - // (same threshhold used in vere) - if dur < 33.0 { - trace_stack = (*trace_stack).next; - continue; - } - - let pc = path_to_cord(stack, (*trace_stack).path); - let pc_len = met3_usize(pc); - let pc_bytes = &pc.as_ne_bytes()[0..pc_len]; - let pc_str = match std::str::from_utf8(pc_bytes) { - Ok(valid) => valid, - Err(error) => { - let (valid, _) = pc_bytes.split_at(error.valid_up_to()); - unsafe { std::str::from_utf8_unchecked(valid) } - } - }; - - let obj = object! { - "cat" => "nock", - "name" => pc_str, - "ph" => "X", - "pid" => self.pid, - "tid" => 1, - "ts" => ts, - "dur" => dur, - }; - obj.write(&mut self.file)?; - self.file.write_all(",\n".as_bytes())?; - - trace_stack = (*trace_stack).next; - } - - Ok(()) - } - - fn write_serf_trace(&mut self, name: &str, start: Instant) -> Result<(), Error> { - let ts = start - .saturating_duration_since(self.process_start) - .as_micros() as f64; - let dur = Instant::now().saturating_duration_since(start).as_micros() as f64; - - let obj = object! { - "cat" => "event", - "name" => name, - "ph" => "X", - "pid" => self.pid, - "tid" => 1, - "ts" => ts, - "dur" => dur, - }; - obj.write(&mut self.file)?; - self.file.write_all(",\n".as_bytes())?; - - Ok(()) - } - - fn write_metadata(&mut self) -> Result<(), Error> { - self.file.write_all("[ ".as_bytes())?; - - (object! { - "name" => "process_name", - "ph" => "M", - "pid" => self.pid, - "args" => object! { "name" => "urbit", }, - }) - .write(&mut self.file)?; - self.file.write_all(",\n".as_bytes())?; - - (object! { - "name" => "thread_name", - "ph" => "M", - "pid" => self.pid, - "tid" => 1, - "args" => object! { "name" => "Event Processing", }, - }) - .write(&mut self.file)?; - self.file.write_all(",\n".as_bytes())?; - - (object! { - "name" => "thread_sort_index", - "ph" => "M", - "pid" => self.pid, - "tid" => 1, - "args" => object! { "sort_index" => 1, }, - }) - .write(&mut self.file)?; - self.file.write_all(",\n".as_bytes())?; - - Ok(()) - } -} - -pub fn create_trace_file(pier_path: PathBuf) -> Result { - let mut trace_dir_path = pier_path.clone(); - trace_dir_path.push(".urb"); - trace_dir_path.push("put"); - trace_dir_path.push("trace"); - create_dir_all(&trace_dir_path)?; - - let trace_path: PathBuf; - let mut trace_idx = 0u32; - loop { - let mut prospective_path = trace_dir_path.clone(); - prospective_path.push(format!("{trace_idx}.json")); - - if prospective_path.exists() { - trace_idx += 1; - } else { - trace_path = prospective_path.clone(); - break; - } - } - - let file = File::create(trace_path)?; - let process_start = Instant::now(); - let pid = std::process::id(); - - let backend = Box::new(JsonBackend { - file, - pid, - process_start, - }); - - Ok(TraceInfo { - backend, - filter: None, - }) -} diff --git a/crates/nockvm/rust/nockvm/src/trace/mod.rs b/crates/nockvm/rust/nockvm/src/trace/mod.rs index 21ab558b1..a7f136b5e 100644 --- a/crates/nockvm/rust/nockvm/src/trace/mod.rs +++ b/crates/nockvm/rust/nockvm/src/trace/mod.rs @@ -1,5 +1,4 @@ use std::io::Error; -use std::path::PathBuf; use std::ptr::NonNull; use std::result::Result; use std::time::Instant; @@ -15,9 +14,6 @@ use crate::mem::NockStack; use crate::mug::met3_usize; use crate::noun::{Atom, DirectAtom, IndirectAtom, Noun}; -mod json; -pub use json::*; - mod tracing_backend; pub use tracing_backend::*; @@ -97,15 +93,6 @@ impl TraceInfo { } } -impl From for TraceInfo { - fn from(backend: JsonBackend) -> Self { - Self { - backend: Box::new(backend), - filter: None, - } - } -} - /// Write metadata to trace file pub fn write_metadata(info: &mut TraceInfo) -> Result<(), Error> { info.backend.write_metadata() diff --git a/crates/nockvm/rust/nockvm/src/trace/tracing_backend.rs b/crates/nockvm/rust/nockvm/src/trace/tracing_backend.rs index aa41efe0f..f5ed949e8 100644 --- a/crates/nockvm/rust/nockvm/src/trace/tracing_backend.rs +++ b/crates/nockvm/rust/nockvm/src/trace/tracing_backend.rs @@ -122,7 +122,7 @@ impl TracingBackend { impl Drop for TracingBackend { fn drop(&mut self) { - let mut entries = GLOBAL_ENTRIES.lock().unwrap(); + let mut entries = GLOBAL_ENTRIES.lock().expect("GLOBAL_ENTRIES lock poisoned"); if entries.as_ref().map(|v| v.len()).unwrap_or(0) <= self.entries.len() { *entries = Some(core::mem::take(&mut self.entries)); } @@ -153,7 +153,7 @@ impl TraceBackend for TracingBackend { self.subscriber = Some(dispatcher::get_default(Clone::clone)); } - let subscriber = self.subscriber.as_ref().unwrap(); + let subscriber = self.subscriber.as_ref().expect("subscriber should be set"); let id = if let Some(entry) = self.entries.get(chum) { entry.id.clone() @@ -191,11 +191,11 @@ impl TraceBackend for TracingBackend { .expect("No subscriber with a trace stack"); loop { - let id = Id::from_u64((*trace_stack).span_id); + let id = Id::from_u64((&*trace_stack).span_id); subscriber.exit(&id); - trace_stack = (*trace_stack).next; + trace_stack = (&*trace_stack).next; if trace_stack.is_null() { break Ok(()); diff --git a/crates/nockvm/rust/nockvm/src/unifying_equality.rs b/crates/nockvm/rust/nockvm/src/unifying_equality.rs index 14f908198..d0dafd329 100644 --- a/crates/nockvm/rust/nockvm/src/unifying_equality.rs +++ b/crates/nockvm/rust/nockvm/src/unifying_equality.rs @@ -3,7 +3,7 @@ use libc::{c_void, memcmp}; use crate::mem::{NockStack, ALLOC, FRAME, STACK}; use crate::noun::Noun; -use crate::{assert_acyclic, assert_no_forwarding_pointers, assert_no_junior_pointers}; +use crate::{assert_acyclic, assert_no_forwarding_pointers}; #[cfg(feature = "check_junior")] #[macro_export] @@ -446,8 +446,8 @@ unsafe fn senior_pointer_first( let arena_end = arena_start.add(stack.get_size()); let mut frame_pointer: *const u64 = stack.get_frame_pointer(); - let mut stack_pointer: *const u64 = stack.get_stack_pointer() as *const u64; - let mut alloc_pointer: *const u64 = stack.get_alloc_pointer() as *const u64; + let mut stack_pointer: *const u64 = stack.get_stack_pointer(); + let mut alloc_pointer: *const u64 = stack.get_alloc_pointer(); let prev_stack_pointer = *(stack.prev_stack_pointer_pointer()) as *const u64; let mut low_pointer; diff --git a/crates/nockvm/rust/nockvm_crypto/Cargo.toml b/crates/nockvm/rust/nockvm_crypto/Cargo.toml index 2d3f4441f..aa7f71fdb 100644 --- a/crates/nockvm/rust/nockvm_crypto/Cargo.toml +++ b/crates/nockvm/rust/nockvm_crypto/Cargo.toml @@ -2,6 +2,7 @@ name = "nockvm_crypto" version = "0.1.0" edition = "2021" +license = "MIT OR Apache-2.0" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/crates/nockvm/rust/nockvm_macros/Cargo.toml b/crates/nockvm/rust/nockvm_macros/Cargo.toml index 071521de6..fb7a87d49 100644 --- a/crates/nockvm/rust/nockvm_macros/Cargo.toml +++ b/crates/nockvm/rust/nockvm_macros/Cargo.toml @@ -2,7 +2,7 @@ name = "nockvm_macros" version = "0.1.0" edition = "2021" - +license = "MIT OR Apache-2.0" [lib] proc-macro = true diff --git a/crates/noun-serde-derive/Cargo.toml b/crates/noun-serde-derive/Cargo.toml index b556aeeca..2e2510381 100644 --- a/crates/noun-serde-derive/Cargo.toml +++ b/crates/noun-serde-derive/Cargo.toml @@ -2,17 +2,18 @@ name = "noun-serde-derive" version = "0.1.0" edition = "2021" +license = "MIT OR Apache-2.0" [lib] proc-macro = true [dependencies] -tracing.workspace = true nockvm = { workspace = true } proc-macro2 = { workspace = true } quote = { workspace = true } syn = { workspace = true, features = ["full"] } +tracing.workspace = true [dev-dependencies] nockapp.workspace = true diff --git a/crates/noun-serde-derive/src/lib.rs b/crates/noun-serde-derive/src/lib.rs index a62cd7b05..b30be405d 100644 --- a/crates/noun-serde-derive/src/lib.rs +++ b/crates/noun-serde-derive/src/lib.rs @@ -192,7 +192,7 @@ pub fn derive_noun_encode(input: TokenStream) -> TokenStream { let field_encoders = match data.fields { Fields::Named(fields) => { let field_encoders = fields.named.iter().enumerate().map(|(i, field)| { - let field_name = field.ident.as_ref().unwrap(); + let field_name = field.ident.as_ref().expect("named field must have ident"); let field_var = format_ident!("field_{}", i); quote! { let #field_var = ::noun_serde::NounEncode::to_noun(&self.#field_name, allocator); @@ -204,7 +204,13 @@ pub fn derive_noun_encode(input: TokenStream) -> TokenStream { quote! { ::nockvm::noun::D(0) } } else if fields.named.len() == 1 { // Single field: just return the field itself - let field_name = fields.named.first().unwrap().ident.as_ref().unwrap(); + let field_name = fields + .named + .first() + .expect("field must exist") + .ident + .as_ref() + .expect("named field must have ident"); quote! { ::noun_serde::NounEncode::to_noun(&self.#field_name, allocator) } @@ -305,7 +311,7 @@ pub fn derive_noun_encode(input: TokenStream) -> TokenStream { let field_names: Vec<_> = fields .named .iter() - .map(|f| f.ident.as_ref().unwrap()) + .map(|f| f.ident.as_ref().expect("named field must have ident")) .collect(); if is_tagged { @@ -429,7 +435,7 @@ pub fn derive_noun_decode(input: TokenStream) -> TokenStream { let field_names: Vec<_> = fields .named .iter() - .map(|f| f.ident.as_ref().unwrap()) + .map(|f| f.ident.as_ref().expect("named field must have ident")) .collect(); let field_types: Vec<_> = fields.named.iter().map(|f| &f.ty).collect(); @@ -467,8 +473,8 @@ pub fn derive_noun_decode(input: TokenStream) -> TokenStream { let field = fields .named .iter() - .find(|f| f.ident.as_ref().unwrap().to_string() == name.to_string()) - .unwrap(); + .find(|f| f.ident.as_ref().expect("named field must have ident") == *name) + .expect("field must exist"); // Check for custom axis let custom_axis = parse_axis_attr(&field.attrs); @@ -671,7 +677,7 @@ pub fn derive_noun_decode(input: TokenStream) -> TokenStream { match &variant.fields { Fields::Named(fields) => { let field_names: Vec<_> = fields.named.iter() - .map(|f| f.ident.as_ref().unwrap()) + .map(|f| f.ident.as_ref().expect("named field must have ident")) .collect(); let field_types: Vec<_> = fields.named.iter() @@ -688,8 +694,8 @@ pub fn derive_noun_decode(input: TokenStream) -> TokenStream { let field_name_str = name.to_string(); // Get the corresponding field let field = fields.named.iter().find(|f| { - f.ident.as_ref().unwrap().to_string() == name.to_string() - }).unwrap(); + f.ident.as_ref().expect("named field must have ident") == *name + }).expect("field must exist"); // Check for custom axis let custom_axis = parse_axis_attr(&field.attrs); @@ -746,8 +752,8 @@ pub fn derive_noun_decode(input: TokenStream) -> TokenStream { let field_decoders = field_names.iter().zip(field_types.iter()).enumerate() .map(|(i, (name, ty))| { let field = fields.named.iter().find(|f| { - f.ident.as_ref().unwrap().to_string() == name.to_string() - }).unwrap(); + f.ident.as_ref().expect("named field must have ident") == *name + }).expect("field must exist"); let custom_axis = parse_axis_attr(&field.attrs); diff --git a/crates/noun-serde-derive/tests/struct_encoding_test.rs b/crates/noun-serde-derive/tests/struct_encoding_test.rs index 51bacfe2e..887b8ad44 100644 --- a/crates/noun-serde-derive/tests/struct_encoding_test.rs +++ b/crates/noun-serde-derive/tests/struct_encoding_test.rs @@ -1,3 +1,4 @@ +#![allow(clippy::unwrap_used)] use nockvm::mem::NockStack; use noun_serde::{NounDecode, NounEncode}; diff --git a/crates/noun-serde-derive/tests/wallet_types_derived.rs b/crates/noun-serde-derive/tests/wallet_types_derived.rs index 4450f4339..2a8b58215 100644 --- a/crates/noun-serde-derive/tests/wallet_types_derived.rs +++ b/crates/noun-serde-derive/tests/wallet_types_derived.rs @@ -1,3 +1,4 @@ +#![allow(clippy::unwrap_used)] use std::collections::{HashMap, HashSet}; #[allow(unused)] @@ -6,7 +7,7 @@ use nockvm::noun::FullDebugCell; use noun_serde::{NounDecode, NounEncode}; #[derive(Debug, Clone, PartialEq, NounEncode, NounDecode)] -enum Key { +pub enum Key { Pub(u64), Prv(u64), } diff --git a/crates/noun-serde/Cargo.toml b/crates/noun-serde/Cargo.toml index 3eea5d859..4597add10 100644 --- a/crates/noun-serde/Cargo.toml +++ b/crates/noun-serde/Cargo.toml @@ -2,11 +2,12 @@ name = "noun-serde" version = "0.1.0" edition = "2021" +license = "MIT OR Apache-2.0" [dependencies] +bytes.workspace = true +ibig.workspace = true nockvm.workspace = true noun-serde-derive.workspace = true thiserror.workspace = true tracing.workspace = true -ibig.workspace = true -bytes.workspace = true diff --git a/crates/noun-serde/src/lib.rs b/crates/noun-serde/src/lib.rs index 2674a0fa0..a3055fc28 100644 --- a/crates/noun-serde/src/lib.rs +++ b/crates/noun-serde/src/lib.rs @@ -1,3 +1,6 @@ +// Allow unwrap in test code - standard practice for test assertions +#![cfg_attr(test, allow(clippy::unwrap_used))] + use std::collections::{BTreeMap, HashMap, HashSet}; use std::str; @@ -9,7 +12,6 @@ use nockvm::ext::{make_tas, AtomExt}; use nockvm::jets::util::BAIL_FAIL; use nockvm::jets::JetErr; #[allow(unused_imports)] -#[allow(unused_imports)] use nockvm::noun::{Atom, FullDebugCell, Noun, NounAllocator, Slots, D, T}; use nockvm::noun::{NO, YES}; pub use noun_serde_derive::{NounDecode, NounEncode}; @@ -406,7 +408,7 @@ impl NounDecode for Vec { let item = T::from_noun(&cell.head())?; result.push(item); current_tail = Some(cell.tail()); - current = current_tail.as_ref().unwrap(); + current = current_tail.as_ref().expect("current_tail was just set"); } if let Ok(atom) = current.as_atom() { @@ -495,7 +497,7 @@ where let mid = entries.len() / 2; let (k, v) = &entries[mid]; - let node = T(allocator, &[(*k).clone(), (*v).clone()]); + let node = T(allocator, &[**k, **v]); let left = build_tree(allocator, &entries[..mid]); let right = build_tree(allocator, &entries[mid + 1..]); diff --git a/crates/noun-serde/src/wallet.rs b/crates/noun-serde/src/wallet.rs index bd855a7b9..265c0fd60 100644 --- a/crates/noun-serde/src/wallet.rs +++ b/crates/noun-serde/src/wallet.rs @@ -293,23 +293,13 @@ impl NounDecode for Effect { } /// A mask tracking which fields of a spend have been set -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Default)] pub struct SpendMask { pub signature: bool, pub seeds: bool, pub fee: bool, } -impl Default for SpendMask { - fn default() -> Self { - SpendMask { - signature: false, - seeds: false, - fee: false, - } - } -} - impl NounEncode for SpendMask { fn to_noun(&self, allocator: &mut A) -> Noun { let mut current = D(self.fee as u64); @@ -338,21 +328,12 @@ impl NounDecode for SpendMask { } /// A mask tracking which fields of an input have been set -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Default)] pub struct InputMask { pub note: bool, pub spend: SpendMask, } -impl Default for InputMask { - fn default() -> Self { - InputMask { - note: false, - spend: SpendMask::default(), - } - } -} - impl NounEncode for InputMask { fn to_noun(&self, allocator: &mut A) -> Noun { let note_noun = D(self.note as u64); @@ -378,7 +359,7 @@ pub struct PreSeed { pub mask: SeedMask, } -#[derive(Debug, Clone, PartialEq)] +#[derive(Debug, Clone, PartialEq, Default)] pub struct SeedMask { pub output_source: bool, pub recipient: bool, @@ -387,18 +368,6 @@ pub struct SeedMask { pub parent_hash: bool, } -impl Default for SeedMask { - fn default() -> Self { - SeedMask { - output_source: false, - recipient: false, - timelock_intent: false, - gift: false, - parent_hash: false, - } - } -} - impl NounEncode for SeedMask { fn to_noun(&self, allocator: &mut A) -> Noun { let mut current = D(self.parent_hash as u64); @@ -785,7 +754,7 @@ impl NounEncode for WalletState { .iter() .rev() { - current = T(allocator, &[noun.clone(), current]); + current = T(allocator, &[*noun, current]); } current } @@ -808,7 +777,7 @@ impl NounEncode for Trek { impl NounDecode for Trek { fn from_noun(noun: &Noun) -> Result { - let mut current = noun.clone(); + let mut current = *noun; let mut parts = Vec::new(); while let Ok(cell) = current.as_cell() { let part = cell.head().as_atom()?.into_string()?; diff --git a/crates/noun-serde/tests/serde.rs b/crates/noun-serde/tests/serde.rs index cf8c0c497..e35345db4 100644 --- a/crates/noun-serde/tests/serde.rs +++ b/crates/noun-serde/tests/serde.rs @@ -1,3 +1,5 @@ +#![allow(clippy::unwrap_used)] + #[cfg(test)] mod tests { use std::collections::HashSet; diff --git a/crates/raw-tx-checker/Cargo.toml b/crates/raw-tx-checker/Cargo.toml index 34dca59a3..75c9bdb98 100644 --- a/crates/raw-tx-checker/Cargo.toml +++ b/crates/raw-tx-checker/Cargo.toml @@ -2,13 +2,14 @@ name = "raw-tx-checker" version = "0.1.0" edition = "2021" +license = "MIT OR Apache-2.0" [dependencies] anyhow = { workspace = true } clap = { workspace = true, features = ["derive"] } -tracing = { workspace = true } -nockvm = { workspace = true } -zkvm-jetpack = { workspace = true } -noun-serde = { workspace = true } nockchain-math = { workspace = true } nockchain-types = { workspace = true } +nockvm = { workspace = true } +noun-serde = { workspace = true } +tracing = { workspace = true } +zkvm-jetpack = { workspace = true } diff --git a/crates/zkvm-jetpack/Cargo.toml b/crates/zkvm-jetpack/Cargo.toml index 244576faf..25488cec9 100644 --- a/crates/zkvm-jetpack/Cargo.toml +++ b/crates/zkvm-jetpack/Cargo.toml @@ -2,6 +2,7 @@ name = "zkvm-jetpack" version.workspace = true edition.workspace = true +license = "MIT OR Apache-2.0" [dependencies] argon2.workspace = true @@ -19,7 +20,6 @@ num-traits.workspace = true once_cell.workspace = true quickcheck.workspace = true rayon.workspace = true -smallvec.workspace = true strum.workspace = true tracing.workspace = true diff --git a/crates/zkvm-jetpack/src/form/math/prover.rs b/crates/zkvm-jetpack/src/form/math/prover.rs index 2c8d1b22a..444f6248c 100644 --- a/crates/zkvm-jetpack/src/form/math/prover.rs +++ b/crates/zkvm-jetpack/src/form/math/prover.rs @@ -24,6 +24,7 @@ pub fn precompute_ntts( Ok(()) } +#[allow(clippy::too_many_arguments)] pub fn compute_deep( trace_polys: HoonList, trace_openings: &[Felt], diff --git a/crates/zkvm-jetpack/src/jets/base_jets.rs b/crates/zkvm-jetpack/src/jets/base_jets.rs index 18fcdc9a0..6f4c193da 100644 --- a/crates/zkvm-jetpack/src/jets/base_jets.rs +++ b/crates/zkvm-jetpack/src/jets/base_jets.rs @@ -167,7 +167,9 @@ pub fn based_noun(n: Noun) -> bool { return based(n); } - let n_cell = n.as_cell().unwrap(); + let n_cell = n + .as_cell() + .expect("n should be a cell since it's not an atom"); let res1 = based_noun(n_cell.head()); if !res1 { return false; diff --git a/crates/zkvm-jetpack/src/jets/bp_jets.rs b/crates/zkvm-jetpack/src/jets/bp_jets.rs index 6fbc27830..eadfe3459 100644 --- a/crates/zkvm-jetpack/src/jets/bp_jets.rs +++ b/crates/zkvm-jetpack/src/jets/bp_jets.rs @@ -56,7 +56,7 @@ pub fn bpadd_jet(context: &mut Context, subject: Noun) -> Result { let res_len = std::cmp::max(bp_poly.len(), bq_poly.len()); let (res, res_poly): (IndirectAtom, &mut [Belt]) = - new_handle_mut_slice(&mut context.stack, Some(res_len as usize)); + new_handle_mut_slice(&mut context.stack, Some(res_len)); bpadd(bp_poly.0, bq_poly.0, res_poly); let res_cell = finalize_poly(&mut context.stack, Some(res_poly.len()), res); @@ -91,7 +91,7 @@ pub fn bpsub_jet(context: &mut Context, subject: Noun) -> Result { let res_len = std::cmp::max(p_poly.len(), q_poly.len()); let (res, res_poly): (IndirectAtom, &mut [Belt]) = - new_handle_mut_slice(&mut context.stack, Some(res_len as usize)); + new_handle_mut_slice(&mut context.stack, Some(res_len)); bpsub(p_poly.0, q_poly.0, res_poly); let res_cell = finalize_poly(&mut context.stack, Some(res_poly.len()), res); diff --git a/crates/zkvm-jetpack/src/jets/cheetah_jets.rs b/crates/zkvm-jetpack/src/jets/cheetah_jets.rs index 6b11ac649..0792c06e4 100644 --- a/crates/zkvm-jetpack/src/jets/cheetah_jets.rs +++ b/crates/zkvm-jetpack/src/jets/cheetah_jets.rs @@ -96,7 +96,7 @@ pub fn batch_verify_affine_jet(context: &mut Context, subject: Noun) -> Result Result { - let left = ch_scal_big(&sig, &A_GEN)?; - let right = ch_neg(&ch_scal_big(&chal, &pubkey)?); + let left = ch_scal_big(sig, &A_GEN)?; + let right = ch_neg(&ch_scal_big(chal, &pubkey)?); let sum = ch_add(&left, &right)?; if sum.x == F6_ZERO { return Err(BAIL_FAIL); @@ -153,7 +153,7 @@ mod tests { fn test_b58_roundtrip() { for x in ["32KVTmv3ofSyACq9nC1Hgnk4Jt8rs2hj1cvDZWC1EQuiYFMDg8MaLtF3ntafJbEUH5XPV1pK3K4xkxfjRPAWprBb7LYCVv4HF7817Bwh9M9xAdmgrPt77j4xejihNFd9h5Eo", "2Xu6FtvopCS69Ko2YnC99B9SVVZ7PLoVn7WvEdDpJKRxW1pmj51uBQdYfADEbRUFYwG55Wi2Qwa3f6Y6WTev5jLcvfJFDEr2Wwt8rViQeLsz1XwEPah5pxtwHTm2nmecjJNW"] { - let point = CheetahPoint::from_base58(&x).unwrap(); + let point = CheetahPoint::from_base58(x).unwrap(); let x_round = point.into_base58().unwrap(); assert_eq!(x, x_round) } @@ -161,20 +161,19 @@ mod tests { #[test] fn test_cheetah_point_from_b58() { - for expected_point in [A_GEN] { - // Create a known CheetahPoint with specific x and y coordinates - // Encode the bytes to base58 - let b58_str = expected_point.into_base58().unwrap(); + let expected_point = A_GEN; + // Create a known CheetahPoint with specific x and y coordinates + // Encode the bytes to base58 + let b58_str = expected_point.into_base58().unwrap(); - // Now test decoding - let decoded_point = - CheetahPoint::from_base58(&b58_str).expect("Failed to decode valid base58 string"); + // Now test decoding + let decoded_point = + CheetahPoint::from_base58(&b58_str).expect("Failed to decode valid base58 string"); - // Check if the decoded point matches our expected point - assert_eq!(decoded_point.x.0, expected_point.x.0); - assert_eq!(decoded_point.y.0, expected_point.y.0); - assert_eq!(decoded_point.inf, expected_point.inf); - } + // Check if the decoded point matches our expected point + assert_eq!(decoded_point.x.0, expected_point.x.0); + assert_eq!(decoded_point.y.0, expected_point.y.0); + assert_eq!(decoded_point.inf, expected_point.inf); // Test error cases @@ -340,7 +339,7 @@ mod tests { let a_gen_noun = A_GEN.to_noun(&mut context.stack); - let n = A(&mut context.stack, &*G_ORDER); + let n = A(&mut context.stack, &G_ORDER); let sample = T(&mut context.stack, &[n, a_gen_noun]); let exp_noun = A_ID.to_noun(&mut context.stack); diff --git a/crates/zkvm-jetpack/src/jets/compute_table_jets_v2.rs b/crates/zkvm-jetpack/src/jets/compute_table_jets_v2.rs index 6b31a8757..69a98def0 100644 --- a/crates/zkvm-jetpack/src/jets/compute_table_jets_v2.rs +++ b/crates/zkvm-jetpack/src/jets/compute_table_jets_v2.rs @@ -445,7 +445,7 @@ fn get_opcode(row: &[u64]) -> Result { } else if grab_belt(row, OP9_IDX).0 == 1 { Ok(9) } else { - return Err(BAIL_EXIT); + Err(BAIL_EXIT) } } diff --git a/crates/zkvm-jetpack/src/jets/mega_jets.rs b/crates/zkvm-jetpack/src/jets/mega_jets.rs index 6bd49339f..48172f42c 100644 --- a/crates/zkvm-jetpack/src/jets/mega_jets.rs +++ b/crates/zkvm-jetpack/src/jets/mega_jets.rs @@ -141,7 +141,7 @@ pub fn mp_substitute_mega_jet(context: &mut Context, subject: Noun) -> Result { let com_noun = com_map_opt .as_ref() .and_then(|m| m.get(stack, D(idx as u64))) - .ok_or_else(|| BAIL_EXIT)?; + .ok_or(BAIL_EXIT)?; let Ok(com_slice) = BPolySlice::try_from(com_noun) else { return Err(BAIL_FAIL); }; diff --git a/crates/zkvm-jetpack/src/jets/proof_gen_jets.rs b/crates/zkvm-jetpack/src/jets/proof_gen_jets.rs index 3ecdedb43..4e6579016 100644 --- a/crates/zkvm-jetpack/src/jets/proof_gen_jets.rs +++ b/crates/zkvm-jetpack/src/jets/proof_gen_jets.rs @@ -223,19 +223,19 @@ impl TryFrom for MPDenseConstraints { let [boundary, row, transition, terminal, extra] = noun.uncell()?; let boundary: Vec = HoonList::try_from(boundary)? - .map(|n| ConstraintData::try_from(n)) + .map(ConstraintData::try_from) .collect::, _>>()?; let row: Vec = HoonList::try_from(row)? - .map(|n| ConstraintData::try_from(n)) + .map(ConstraintData::try_from) .collect::, _>>()?; let transition: Vec = HoonList::try_from(transition)? - .map(|n| ConstraintData::try_from(n)) + .map(ConstraintData::try_from) .collect::, _>>()?; let terminal: Vec = HoonList::try_from(terminal)? - .map(|n| ConstraintData::try_from(n)) + .map(ConstraintData::try_from) .collect::, _>>()?; let extra: Vec = HoonList::try_from(extra)? - .map(|n| ConstraintData::try_from(n)) + .map(ConstraintData::try_from) .collect::, _>>()?; Ok(MPDenseConstraints { @@ -259,7 +259,7 @@ impl TryFrom for ConstraintData { .collect::, _>>()?; Ok(ConstraintData { constraint: cs, - degs: degs, + degs, }) } } @@ -307,14 +307,19 @@ pub fn eval_composition_poly_jet(context: &mut Context, subject: Noun) -> Result let dyn_list: Vec> = HoonList::try_from(dyn_list)? .into_iter() - .map(|x| BPolySlice::try_from(x)) + .map(BPolySlice::try_from) .collect::>, _>>()?; let weights_map = IndexBPolyMap::try_from(weights_map)?; let challenges = BPolySlice::try_from(challenges)?; let deep_challenge = deep_challange.as_felt()?; let table_full_widths: Vec = HoonList::try_from(table_full_widths)? .into_iter() - .map(|x| x.as_atom().unwrap().as_u64().unwrap()) + .map(|x| { + x.as_atom() + .expect("table_full_widths element should be an atom") + .as_u64() + .expect("table_full_widths element should be a u64") + }) .collect(); let is_extra = unsafe { is_extra.raw_equals(&D(0)) }; @@ -328,16 +333,17 @@ pub fn eval_composition_poly_jet(context: &mut Context, subject: Noun) -> Result Ok(res_atom.as_noun()) } +#[allow(clippy::too_many_arguments)] fn eval_composition_poly( trace_evaluations: &FPolySlice<'_>, - heights: &Vec, + heights: &[u64], constraint_map: &Constraints, counts_map: &CountMap, - dyn_list: &Vec>, + dyn_list: &[BPolySlice<'_>], weights_map: &IndexBPolyMap<'_>, challenges: &BPolySlice<'_>, deep_challenge: &Felt, - table_full_widths: &Vec, + table_full_widths: &[u64], is_extra: bool, ) -> Result { let dp = degree_processing(heights, is_extra, constraint_map); @@ -353,9 +359,18 @@ fn eval_composition_poly( let last_row = fsub_(deep_challenge, &finv_(&omicron)); let terminal_zerofier = finv_(&last_row); - let weights = weights_map.0.get(&i).unwrap(); - let constraints = dp.constraints.get(&i).unwrap(); - let counts = counts_map.0.get(&i).unwrap(); + let weights = weights_map + .0 + .get(&i) + .expect("weights_map should have entry for index"); + let constraints = dp + .constraints + .get(&i) + .expect("constraints should have entry for index"); + let counts = counts_map + .0 + .get(&i) + .expect("counts_map should have entry for index"); let dyns = &dyn_list[i]; let row_zerofier = finv_(&fsub_(&fpow_(deep_challenge, height), &Felt::one())); @@ -431,7 +446,7 @@ fn eval_composition_poly( } fn evaluate_constraints( - constraints: &Vec>, + constraints: &[PolyWithDegreeFudges<'_>], dyns: &BPolySlice<'_>, evals: &[Felt], weights: &[Belt], @@ -443,7 +458,7 @@ fn evaluate_constraints( let mut idx = 0; for constraint in constraints { - let evaled = mpeval_ultra_felt(&constraint.poly, evals, challenges, dyns.0)?; + let evaled = mpeval_ultra_felt(constraint.poly, evals, challenges, dyns.0)?; for (deg, eval) in constraint.degrees.iter().zip(evaled.iter()) { let alpha = Felt::lift(weights[2 * idx]); let beta = Felt::lift(weights[2 * idx + 1]); @@ -452,7 +467,7 @@ fn evaluate_constraints( let degree_factor = fpow_(deep_challenge, fri_degree_bound - deg); let weight_factor = fadd_(&beta, &fmul_(&alpha, °ree_factor)); - acc = fadd_(&acc, &fmul_(&eval, &weight_factor)); + acc = fadd_(&acc, &fmul_(eval, &weight_factor)); idx += 1; } } @@ -488,13 +503,13 @@ struct DegreeData<'a> { } pub fn degree_processing<'a>( - heights: &Vec, + heights: &[u64], is_extra: bool, constraint_map: &'a Constraints, ) -> ProcessedDegrees<'a> { let mut max_degree: u64 = 0; let mut res = ProofMap::>::new(); - for (i, &height) in heights.into_iter().enumerate() { + for (i, &height) in heights.iter().enumerate() { let constraints = constraint_map.0.get(&i).unwrap_or_else(|| { panic!( "Panicked at {}:{} (git sha: {:?})", @@ -542,7 +557,7 @@ pub fn degree_processing<'a>( } } -fn do_degree_processing(height: u64, cs: &Vec, typ: ConstraintType) -> DegreeData { +fn do_degree_processing(height: u64, cs: &[ConstraintData], typ: ConstraintType) -> DegreeData<'_> { let mut max_degree: u64 = 0; let mut res = Vec::::new(); cs.iter().for_each(|cd| { diff --git a/crates/zkvm-jetpack/src/jets/tip5_jets.rs b/crates/zkvm-jetpack/src/jets/tip5_jets.rs index 5e63d0882..cfa37bb7f 100644 --- a/crates/zkvm-jetpack/src/jets/tip5_jets.rs +++ b/crates/zkvm-jetpack/src/jets/tip5_jets.rs @@ -225,7 +225,7 @@ fn hash_hashable_leaf(stack: &mut NockStack, p: Noun) -> Result { fn hash_hashable_list(stack: &mut NockStack, p: Noun) -> Result { let turn: Vec = HoonList::try_from(p)? .into_iter() - .map(|x| hash_hashable(stack, x).unwrap()) + .map(|x| hash_hashable(stack, x).expect("hash_hashable should succeed for list element")) .collect(); let turn_list = vecnoun_to_hoon_list(stack, &turn); tip5::hash::hash_noun_varlen(stack, turn_list) diff --git a/crates/zkvm-jetpack/src/jets/verifier_jets.rs b/crates/zkvm-jetpack/src/jets/verifier_jets.rs index 9e14e919a..755526696 100644 --- a/crates/zkvm-jetpack/src/jets/verifier_jets.rs +++ b/crates/zkvm-jetpack/src/jets/verifier_jets.rs @@ -113,12 +113,22 @@ pub fn evaluate_deep_jet(context: &mut Context, subject: Noun) -> Result = HoonList::try_from(trace_elems)? .into_iter() - .map(|x| x.as_atom().unwrap().as_u64().unwrap()) + .map(|x| { + x.as_atom() + .expect("trace_elems element should be an atom") + .as_u64() + .expect("trace_elems element should be a u64") + }) .map(Belt) .collect(); let comp_elems: Vec = HoonList::try_from(comp_elems)? .into_iter() - .map(|x| x.as_atom().unwrap().as_u64().unwrap()) + .map(|x| { + x.as_atom() + .expect("comp_elems element should be an atom") + .as_u64() + .expect("comp_elems element should be a u64") + }) .map(Belt) .collect(); let num_comp_pieces = num_comp_pieces.as_atom()?.as_u64()?; @@ -128,11 +138,21 @@ pub fn evaluate_deep_jet(context: &mut Context, subject: Noun) -> Result = HoonList::try_from(heights)? .into_iter() - .map(|x| x.as_atom().unwrap().as_u64().unwrap()) + .map(|x| { + x.as_atom() + .expect("heights element should be an atom") + .as_u64() + .expect("heights element should be a u64") + }) .collect(); let full_widths: Vec = HoonList::try_from(full_widths)? .into_iter() - .map(|x| x.as_atom().unwrap().as_u64().unwrap()) + .map(|x| { + x.as_atom() + .expect("full_widths element should be an atom") + .as_u64() + .expect("full_widths element should be a u64") + }) .collect(); let omega = omega.as_felt()?; let index = index.as_atom()?.as_u64()?; @@ -223,8 +243,8 @@ fn process_belt( let mut acc = *acc_start; let mut num = start_num; - for i in 0..width { - let elem_val = Felt::lift(elems[i]); + for elem in elems.iter().take(width) { + let elem_val = Felt::lift(*elem); let eval_val = evals[num]; let weight_val = weights[num]; @@ -284,7 +304,7 @@ impl Fops for Felt { if let Ok(r) = noun.as_felt() { Ok(*r) } else { - return Err(BAIL_FAIL); + Err(BAIL_FAIL) } } diff --git a/crates/zkvm-jetpack/src/lib.rs b/crates/zkvm-jetpack/src/lib.rs index c523413e1..dcb601807 100644 --- a/crates/zkvm-jetpack/src/lib.rs +++ b/crates/zkvm-jetpack/src/lib.rs @@ -1,4 +1,6 @@ #![feature(cold_path)] +// Allow unwrap in test code - standard practice for test assertions +#![cfg_attr(test, allow(clippy::unwrap_used))] pub mod form; pub mod hot; diff --git a/example-nockapp.toml b/example-nockapp.toml new file mode 100644 index 000000000..3e99ce435 --- /dev/null +++ b/example-nockapp.toml @@ -0,0 +1,28 @@ +[package] +name = "arcadia" +version = "0.1.0" +description = "My famous game." +authors = ["sigilante"] +license = "MIT" +template = "basic" + +[dependencies] +"urbit/lull" = "@k409" +"urbit/zuse" = "^k409" +"urbit/bits" = "latest" +"nockchain/zose" = "latest" +"nockchain/wallet" = "latest" + +[dependencies.lagoon] +git = "https://github.com/urbit/numerics" +commit = "01905f364178958bb2d0c1a7ce009b6f3e68f737" +# Specify only the subdirectory within the repository; if no files, all will be included. +path = "lagoon/desk" + +[dependencies.sequent] +git = "https://github.com/jackfoxy/sequent" +commit = "7fc95fd4d6df7548cf354c9c91df2980e902770d" +# Specify the subdirectory within the repository (which will be omitted) +path = "desk" +# Keep the part of the path you need to preserve +files = ["lib/seq"] diff --git a/example-registry.toml b/example-registry.toml new file mode 100644 index 000000000..efa787077 --- /dev/null +++ b/example-registry.toml @@ -0,0 +1,650 @@ +# ============================================================================ +# Registry metadata +# ============================================================================ +[registry] +name = "typhoon" +version = "0.1.0" +description = "TYPical HOON package registry" +url = "https://github.com/sigilante/typhoon" + +[config] +default_ref = "latest" + +# ============================================================================ +# Workspaces +# ============================================================================ + +[workspace.urbit] +# Public Git URL +git_url = "https://github.com/urbit/urbit" +# Tag or commit hash +ref = "409k" +# Plaintext description +description = "Urbit OS" +# Path in repo; preserve structure after this point +root_path = "pkg/arvo" + +[workspace.nockchain] +git_url = "https://github.com/nockchain/nockchain" +ref = "a19ad4dc" +description = "Nockchain standard library" +root_path = "hoon" + +# ============================================================================ +# Packages +# ============================================================================ + +# Urbit standard libraries from /sys +[[package]] +name = "urbit/hoon" +workspace = "urbit" +path = "sys" +file = "hoon.hoon" +dependencies = [] + +[[package]] +name = "urbit/lull" +workspace = "urbit" +path = "sys" +file = "lull.hoon" +dependencies = ["urbit/hoon"] + +[[package]] +name = "urbit/zuse" +workspace = "urbit" +path = "sys" +file = "zuse.hoon" +dependencies = ["urbit/lull"] + +[[package]] +name = "urbit/arvo" +workspace = "urbit" +path = "sys" +file = "arvo.hoon" +dependencies = ["urbit/lull", "urbit/zuse"] + +# Urbit vanes from /sys/vanes +[[package]] +name = "urbit/ames" +workspace = "urbit" +path = "sys/vanes" +file = "ames.hoon" +dependencies = ["urbit/arvo"] + +[[package]] +name = "urbit/behn" +workspace = "urbit" +path = "sys/vanes" +file = "behn.hoon" +dependencies = ["urbit/arvo"] + +[[package]] +name = "urbit/clay" +workspace = "urbit" +path = "sys/vanes" +file = "clay.hoon" +dependencies = ["urbit/arvo"] + +[[package]] +name = "urbit/dill" +workspace = "urbit" +path = "sys/vanes" +file = "dill.hoon" +dependencies = ["urbit/arvo"] + +[[package]] +name = "urbit/eyre" +workspace = "urbit" +path = "sys/vanes" +file = "eyre.hoon" +dependencies = ["urbit/arvo"] + +[[package]] +name = "urbit/gall" +workspace = "urbit" +path = "sys/vanes" +file = "gall.hoon" +dependencies = ["urbit/arvo"] + +[[package]] +name = "urbit/iris" +workspace = "urbit" +path = "sys/vanes" +file = "iris.hoon" +dependencies = ["urbit/arvo"] + +[[package]] +name = "urbit/jael" +workspace = "urbit" +path = "sys/vanes" +file = "jael.hoon" +dependencies = ["urbit/arvo"] + +[[package]] +name = "urbit/khan" +workspace = "urbit" +path = "sys/vanes" +file = "khan.hoon" +dependencies = ["urbit/arvo"] + +[[package]] +name = "urbit/lick" +workspace = "urbit" +path = "sys/vanes" +file = "lick.hoon" +dependencies = ["urbit/arvo"] + +# Urbit libraries from /lib +[[package]] +name = "urbit/bits" +workspace = "urbit" +path = "lib" +file = "bits.hoon" +dependencies = [] + +[[package]] +name = "urbit/list" +workspace = "urbit" +path = "lib" +file = "list.hoon" +dependencies = [] + +[[package]] +name = "urbit/map" +workspace = "urbit" +path = "lib" +file = "map.hoon" +dependencies = [] + +[[package]] +name = "urbit/math" +workspace = "urbit" +path = "lib" +file = "math.hoon" +dependencies = [] + +[[package]] +name = "urbit/mapset" +workspace = "urbit" +path = "lib" +file = "mapset.hoon" +dependencies = ["urbit/map"] + +[[package]] +name = "urbit/set" +workspace = "urbit" +path = "lib" +file = "set.hoon" +dependencies = [] + +[[package]] +name = "urbit/tiny" +workspace = "urbit" +path = "lib" +file = "tiny.hoon" +dependencies = [] + +# Nockchain standard library +[[package]] +name = "nockchain/apps/dumbnet/inner" +workspace = "nockchain" +path = "hoon/apps/dumbnet" +file = "inner.hoon" +dependencies = ["nockchain/apps/dumbnet/lib/types", "nockchain/common/stark/prover", "nockchain/common/tx-engine", "nockchain/apps/dumbnet/lib/miner", "nockchain/apps/dumbnet/lib/derived", "nockchain/apps/dumbnet/lib/consensus", "nockchain/common/pow", "nockchain/common/nock-verifier", "nockchain/common/zeke", "nockchain/common/zoon", "nockchain/common/wrapper"] + +[[package]] +name = "nockchain/apps/dumbnet/lib/consensus" +workspace = "nockchain" +path = "hoon/apps/dumbnet/lib" +file = "consensus.hoon" +dependencies = ["nockchain/apps/dumbnet/lib/types", "nockchain/common/stark/prover", "nockchain/common/pow", "nockchain/common/tx-engine", "nockchain/common/zoon"] + +[[package]] +name = "nockchain/apps/dumbnet/lib/derived" +workspace = "nockchain" +path = "hoon/apps/dumbnet/lib" +file = "derived.hoon" +dependencies = ["nockchain/apps/dumbnet/lib/types", "nockchain/common/tx-engine", "nockchain/common/zoon"] + +[[package]] +name = "nockchain/apps/dumbnet/lib/miner" +workspace = "nockchain" +path = "hoon/apps/dumbnet/lib" +file = "miner.hoon" +dependencies = ["nockchain/apps/dumbnet/lib/types", "nockchain/common/stark/prover", "nockchain/common/tx-engine", "nockchain/common/zoon"] + +[[package]] +name = "nockchain/apps/dumbnet/lib/types" +workspace = "nockchain" +path = "hoon/apps/dumbnet/lib" +file = "types.hoon" +dependencies = ["nockchain/common/zoon", "nockchain/common/zeke", "nockchain/common/wrapper", "nockchain/common/tx-engine", "nockchain/common/stark/prover", "nockchain/apps/dumbnet/miner"] + +[[package]] +name = "nockchain/apps/dumbnet/miner" +workspace = "nockchain" +path = "hoon/apps/dumbnet" +file = "miner.hoon" +dependencies = ["nockchain/common/pow", "nockchain/common/stark/prover", "nockchain/common/zoon", "nockchain/common/zeke", "nockchain/common/wrapper"] + +[[package]] +name = "nockchain/apps/dumbnet/outer" +workspace = "nockchain" +path = "hoon/apps/dumbnet" +file = "outer.hoon" +dependencies = ["nockchain/apps/dumbnet/inner"] + +[[package]] +name = "nockchain/apps/hoonc/hoonc" +workspace = "nockchain" +path = "hoon/apps/hoonc" +file = "hoonc.hoon" +dependencies = ["nockchain/common/wrapper"] + +[[package]] +name = "nockchain/apps/peek/peek" +workspace = "nockchain" +path = "hoon/apps/peek" +file = "peek.hoon" +dependencies = ["nockchain/common/tx-engine", "nockchain/common/wrapper", "nockchain/common/zoon"] + +[[package]] +name = "nockchain/apps/wallet/lib/s10" +workspace = "nockchain" +path = "hoon/apps/wallet/lib" +file = "s10.hoon" +dependencies = ["nockchain/common/slip10", "nockchain/common/bip39", "nockchain/common/zose"] + +[[package]] +name = "nockchain/apps/wallet/lib/tx-builder" +workspace = "nockchain" +path = "hoon/apps/wallet/lib" +file = "tx-builder.hoon" +dependencies = ["nockchain/common/tx-engine", "nockchain/apps/wallet/lib/utils", "nockchain/apps/wallet/lib/types", "nockchain/common/zoon"] + +[[package]] +name = "nockchain/apps/wallet/lib/types" +workspace = "nockchain" +path = "hoon/apps/wallet/lib" +file = "types.hoon" +dependencies = ["nockchain/common/tx-engine", "nockchain/common/zoon", "nockchain/common/zose", "nockchain/apps/dumbnet/lib/types", "nockchain/apps/wallet/lib/s10"] + +[[package]] +name = "nockchain/apps/wallet/lib/utils" +workspace = "nockchain" +path = "hoon/apps/wallet/lib" +file = "utils.hoon" +dependencies = ["nockchain/apps/wallet/lib/s10", "nockchain/common/markdown/types", "nockchain/common/markdown/markdown", "nockchain/common/tx-engine", "nockchain/common/zoon", "nockchain/common/zose", "nockchain/common/zeke", "nockchain/apps/wallet/lib/types"] + +[[package]] +name = "nockchain/apps/wallet/wallet" +workspace = "nockchain" +path = "hoon/apps/wallet" +file = "wallet.hoon" +dependencies = ["nockchain/common/bip39", "nockchain/common/markdown/types", "nockchain/common/markdown/markdown", "nockchain/common/tx-engine", "nockchain/common/zeke", "nockchain/common/zoon", "nockchain/apps/dumbnet/lib/types", "nockchain/common/zose", "nockchain/common/wrapper", "nockchain/apps/wallet/lib/types", "nockchain/apps/wallet/lib/utils", "nockchain/apps/wallet/lib/tx-builder", "nockchain/apps/wallet/lib/s10"] + +[[package]] +name = "nockchain/common/bip39" +workspace = "nockchain" +path = "hoon/common" +file = "bip39.hoon" +dependencies = ["nockchain/common/bip39-english", "nockchain/common/zose"] + +[[package]] +name = "nockchain/common/bip39-english" +workspace = "nockchain" +path = "hoon/common" +file = "bip39-english.hoon" +dependencies = [] + +[[package]] +name = "nockchain/common/hoon" +workspace = "nockchain" +path = "hoon/common" +file = "hoon.hoon" +dependencies = [] + +[[package]] +name = "nockchain/common/markdown/markdown" +workspace = "nockchain" +path = "hoon/common/markdown" +file = "markdown.hoon" +dependencies = ["nockchain/common/markdown/types"] + +[[package]] +name = "nockchain/common/markdown/types" +workspace = "nockchain" +path = "hoon/common/markdown" +file = "types.hoon" +dependencies = [] + +[[package]] +name = "nockchain/common/nock-prover" +workspace = "nockchain" +path = "hoon/common" +file = "nock-prover.hoon" +dependencies = ["nockchain/common/zeke", "nockchain/common/stark/prover", "nockchain/dat/softed-constraints"] + +[[package]] +name = "nockchain/common/nock-verifier" +workspace = "nockchain" +path = "hoon/common" +file = "nock-verifier.hoon" +dependencies = ["nockchain/common/zeke", "nockchain/common/stark/verifier", "nockchain/dat/softed-constraints"] + +[[package]] +name = "nockchain/common/pow" +workspace = "nockchain" +path = "hoon/common" +file = "pow.hoon" +dependencies = ["nockchain/common/stark/prover", "nockchain/common/nock-prover", "nockchain/common/zeke"] + +[[package]] +name = "nockchain/common/schedule" +workspace = "nockchain" +path = "hoon/common" +file = "schedule.hoon" +dependencies = [] + +[[package]] +name = "nockchain/common/slip10" +workspace = "nockchain" +path = "hoon/common" +file = "slip10.hoon" +dependencies = ["nockchain/common/zose", "nockchain/common/zeke"] + +[[package]] +name = "nockchain/common/stark/prover" +workspace = "nockchain" +path = "hoon/common/stark" +file = "prover.hoon" +dependencies = ["nockchain/common/v0-v1/table/prover/compute", "nockchain/common/v2/table/prover/compute", "nockchain/common/v0-v1/table/prover/memory", "nockchain/common/v2/table/prover/memory", "nockchain/common/zeke", "nockchain/common/v0-v1/nock-common", "nockchain/common/v2/nock-common"] + +[[package]] +name = "nockchain/common/stark/verifier" +workspace = "nockchain" +path = "hoon/common/stark" +file = "verifier.hoon" +dependencies = ["nockchain/common/v0-v1/nock-common", "nockchain/common/v2/nock-common", "nockchain/common/zeke"] + +[[package]] +name = "nockchain/common/test" +workspace = "nockchain" +path = "hoon/common" +file = "test.hoon" +dependencies = [] + +[[package]] +name = "nockchain/common/tx-engine" +workspace = "nockchain" +path = "hoon/common" +file = "tx-engine.hoon" +dependencies = ["nockchain/common/tx-engine-0", "nockchain/common/tx-engine-1", "nockchain/common/zeke", "nockchain/common/zoon", "nockchain/common/zose"] + +[[package]] +name = "nockchain/common/tx-engine-0" +workspace = "nockchain" +path = "hoon/common" +file = "tx-engine-0.hoon" +dependencies = ["nockchain/common/schedule", "nockchain/common/pow", "nockchain/common/zeke", "nockchain/common/zoon"] + +[[package]] +name = "nockchain/common/tx-engine-1" +workspace = "nockchain" +path = "hoon/common" +file = "tx-engine-1.hoon" +dependencies = ["nockchain/common/tx-engine-0", "nockchain/common/zeke", "nockchain/common/zoon"] + +[[package]] +name = "nockchain/common/v0-v1/nock-common" +workspace = "nockchain" +path = "hoon/common/v0-v1" +file = "nock-common.hoon" +dependencies = ["nockchain/common/v0-v1/table/verifier/compute", "nockchain/common/v0-v1/table/verifier/memory", "nockchain/common/zeke"] + +[[package]] +name = "nockchain/common/v0-v1/table/compute" +workspace = "nockchain" +path = "hoon/common/v0-v1/table" +file = "compute.hoon" +dependencies = ["nockchain/common/zeke"] + +[[package]] +name = "nockchain/common/v0-v1/table/memory" +workspace = "nockchain" +path = "hoon/common/v0-v1/table" +file = "memory.hoon" +dependencies = ["nockchain/common/zeke"] + +[[package]] +name = "nockchain/common/v0-v1/table/prover/compute" +workspace = "nockchain" +path = "hoon/common/v0-v1/table/prover" +file = "compute.hoon" +dependencies = ["nockchain/common/v0-v1/table/compute", "nockchain/common/v0-v1/table/verifier/compute", "nockchain/common/zeke"] + +[[package]] +name = "nockchain/common/v0-v1/table/prover/memory" +workspace = "nockchain" +path = "hoon/common/v0-v1/table/prover" +file = "memory.hoon" +dependencies = ["nockchain/common/v0-v1/table/memory", "nockchain/common/v0-v1/table/verifier/memory", "nockchain/common/zeke"] + +[[package]] +name = "nockchain/common/v0-v1/table/verifier/compute" +workspace = "nockchain" +path = "hoon/common/v0-v1/table/verifier" +file = "compute.hoon" +dependencies = ["nockchain/common/v0-v1/table/compute", "nockchain/common/zeke"] + +[[package]] +name = "nockchain/common/v0-v1/table/verifier/memory" +workspace = "nockchain" +path = "hoon/common/v0-v1/table/verifier" +file = "memory.hoon" +dependencies = ["nockchain/common/v0-v1/table/memory", "nockchain/common/zeke"] + +[[package]] +name = "nockchain/common/v2/nock-common" +workspace = "nockchain" +path = "hoon/common/v2" +file = "nock-common.hoon" +dependencies = ["nockchain/common/v2/table/verifier/compute", "nockchain/common/v2/table/verifier/memory", "nockchain/common/zeke"] + +[[package]] +name = "nockchain/common/v2/table/compute" +workspace = "nockchain" +path = "hoon/common/v2/table" +file = "compute.hoon" +dependencies = ["nockchain/common/zeke"] + +[[package]] +name = "nockchain/common/v2/table/memory" +workspace = "nockchain" +path = "hoon/common/v2/table" +file = "memory.hoon" +dependencies = ["nockchain/common/zeke"] + +[[package]] +name = "nockchain/common/v2/table/prover/compute" +workspace = "nockchain" +path = "hoon/common/v2/table/prover" +file = "compute.hoon" +dependencies = ["nockchain/common/v2/table/compute", "nockchain/common/v2/table/verifier/compute", "nockchain/common/zeke"] + +[[package]] +name = "nockchain/common/v2/table/prover/memory" +workspace = "nockchain" +path = "hoon/common/v2/table/prover" +file = "memory.hoon" +dependencies = ["nockchain/common/v2/table/memory", "nockchain/common/v2/table/verifier/memory", "nockchain/common/zeke"] + +[[package]] +name = "nockchain/common/v2/table/verifier/compute" +workspace = "nockchain" +path = "hoon/common/v2/table/verifier" +file = "compute.hoon" +dependencies = ["nockchain/common/v2/table/compute", "nockchain/common/zeke"] + +[[package]] +name = "nockchain/common/v2/table/verifier/memory" +workspace = "nockchain" +path = "hoon/common/v2/table/verifier" +file = "memory.hoon" +dependencies = ["nockchain/common/v2/table/memory", "nockchain/common/zeke"] + +[[package]] +name = "nockchain/common/wrapper" +workspace = "nockchain" +path = "hoon/common" +file = "wrapper.hoon" +dependencies = [] + +[[package]] +name = "nockchain/common/zeke" +workspace = "nockchain" +path = "hoon/common" +file = "zeke.hoon" +dependencies = ["nockchain/common/ztd/eight"] + +[[package]] +name = "nockchain/common/zoon" +workspace = "nockchain" +path = "hoon/common" +file = "zoon.hoon" +dependencies = ["nockchain/common/zeke"] + +[[package]] +name = "nockchain/common/zose" +workspace = "nockchain" +path = "hoon/common" +file = "zose.hoon" +dependencies = ["nockchain/common/zeke"] + +[[package]] +name = "nockchain/common/ztd/eight" +workspace = "nockchain" +path = "hoon/common/ztd" +file = "eight.hoon" +dependencies = ["nockchain/common/ztd/seven"] + +[[package]] +name = "nockchain/common/ztd/five" +workspace = "nockchain" +path = "hoon/common/ztd" +file = "five.hoon" +dependencies = ["nockchain/common/ztd/four"] + +[[package]] +name = "nockchain/common/ztd/four" +workspace = "nockchain" +path = "hoon/common/ztd" +file = "four.hoon" +dependencies = ["nockchain/common/ztd/three"] + +[[package]] +name = "nockchain/common/ztd/one" +workspace = "nockchain" +path = "hoon/common/ztd" +file = "one.hoon" +dependencies = [] + +[[package]] +name = "nockchain/common/ztd/seven" +workspace = "nockchain" +path = "hoon/common/ztd" +file = "seven.hoon" +dependencies = ["nockchain/common/ztd/six"] + +[[package]] +name = "nockchain/common/ztd/six" +workspace = "nockchain" +path = "hoon/common/ztd" +file = "six.hoon" +dependencies = ["nockchain/common/ztd/five"] + +[[package]] +name = "nockchain/common/ztd/three" +workspace = "nockchain" +path = "hoon/common/ztd" +file = "three.hoon" +dependencies = ["nockchain/common/ztd/two"] + +[[package]] +name = "nockchain/common/ztd/two" +workspace = "nockchain" +path = "hoon/common/ztd" +file = "two.hoon" +dependencies = ["nockchain/common/ztd/one"] + +[[package]] +name = "nockchain/dat/constraints-v0-v1" +workspace = "nockchain" +path = "hoon/dat" +file = "constraints-v0-v1.hoon" +dependencies = ["nockchain/common/zeke", "nockchain/common/v0-v1/nock-common"] + +[[package]] +name = "nockchain/dat/constraints-v2" +workspace = "nockchain" +path = "hoon/dat" +file = "constraints-v2.hoon" +dependencies = ["nockchain/common/zeke", "nockchain/common/v2/nock-common"] + +[[package]] +name = "nockchain/dat/softed-constraints" +workspace = "nockchain" +path = "hoon/dat" +file = "softed-constraints.hoon" +dependencies = ["nockchain/common/zeke"] + +[[package]] +name = "nockchain/trivial" +workspace = "nockchain" +path = "hoon" +file = "trivial.hoon" +dependencies = [] + +# ============================================================================ +# Aliases +# ============================================================================ + +[[alias]] +name = "nockchain/eight" +target = "nockchain/common/ztd/eight" + +[[alias]] +name = "nockchain/five" +target = "nockchain/common/ztd/five" + +[[alias]] +name = "nockchain/four" +target = "nockchain/common/ztd/four" + +[[alias]] +name = "nockchain/one" +target = "nockchain/common/ztd/one" + +[[alias]] +name = "nockchain/seven" +target = "nockchain/common/ztd/seven" + +[[alias]] +name = "nockchain/six" +target = "nockchain/common/ztd/six" + +[[alias]] +name = "nockchain/two" +target = "nockchain/common/ztd/two" + +[[alias]] +name = "nockchain/zeke" +target = "nockchain/common/zeke" + +[[alias]] +name = "nockchain/zoon" +target = "nockchain/common/zoon" + +[[alias]] +name = "nockchain/zose" +target = "nockchain/common/zose" diff --git a/hoon/apps/bridge/base.hoon b/hoon/apps/bridge/base.hoon new file mode 100644 index 000000000..3dc34d0b6 --- /dev/null +++ b/hoon/apps/bridge/base.hoon @@ -0,0 +1,253 @@ +/= t /common/tx-engine +/= * /common/zeke +/= * /common/zoon +/= * /common/zose +/= * /common/wrapper +/= * /apps/bridge/types +/= dumb /apps/dumbnet/lib/types +|_ state=bridge-state +++ incoming-base-blocks + |= [raw=raw-base-blocks:cause rest=[=wire eny=@ our=@ux now=@da]] + ^- [(list effect) bridge-state] + ~& %incoming-base-blocks + ::~& [%incoming-base-blocks raw rest] + :: + :: hold onto old state in case the deposit process fails + =/ old-state state + =/ stop-info (get-stop-info old-state) + =/ blocks=base-blocks (cook-base-blocks raw) + =/ first=@ first-height.blocks + =/ chunk=@ base-blocks-chunk.constants.state + =/ start=@ base-start-height.constants.state + =/ blocks-hash (hash:base-blocks blocks) + ?. =((dec chunk) (sub last-height.blocks first-height.blocks)) + ::>) This is a stop condition because it means the driver malfunctioned + ::>) Batch must be exactly chunk size (last - first == chunk - 1) + [[%0 %stop 'driver malfunction: incoming base block chunk is not correct size' stop-info]~ old-state] + ?: (lth first start) + ~& "received base blocks starting at height {}, bridge starts at height {}." + [~ state] + ?^ stop=(validate-base-blocks-sequence blocks) + [[%0 %stop u.stop stop-info]~ old-state] + =+ process-blocks=(process-base-blocks blocks) + ?- -.process-blocks + %| + =/ =process-fail +.process-blocks + ?- -.process-fail + %stop + :: early stop and roll back to old state if we do not process the base blocks + :: this happens when we encounter a %hold or %stop condition. + [[%0 %stop msg.process-fail stop-info]~ old-state] + :: + %hold + [~ old-state(base-hold.hash-state `hold.process-fail)] + == + :: + %& + :: update state if blocks get processed without stop condition or hold + =. state p.process-blocks + :: check if we need to remove the hold + =? nock-hold.hash-state.state ?=(^ nock-hold.hash-state.state) + ?: =(blocks-hash hash.u.nock-hold.hash-state.state) ~ + nock-hold.hash-state.state + :: + =/ current-height=@ud ~(height get:page:t last-block.state) + :: + :: NOTE: This should always be true until we implement withdrawals + ?: =(~ withdrawals.blocks) + [~ state] + ?~ maybe-proposal=(base-propose-withdrawals blocks) + [~ state] + :: TODO: when we implement withdrawals, we will emit a real propose effect + [~ state] + == +:: +++ validate-base-blocks-sequence + |= blocks=base-blocks + ^- (unit @t) + ?. =(first-height.blocks base-hashchain-next-height.hash-state.state) + [~ 'driver malfunction: incoming base blocks start height not equal to next height'] + ?: ?& (gte base-start-height.constants.state first-height.blocks) + (lte base-start-height.constants.state last-height.blocks) + == + ~ + =/ last=base-blocks (~(got z-by base-hashchain.hash-state.state) last-base-blocks.hash-state.state) + =/ prev=[bid=bbid parent=bbid] + (last-block:base-blocks last) + =/ cur=[bid=bbid parent=bbid] + (first-block:base-blocks blocks) + =/ next-height=@ +(first-height.blocks) + |- + ?. =(parent.cur bid.prev) + [~ 'Invalid base block sequence: parent block ID mismatch'] + ?: =(next-height +(last-height.blocks)) + ~ + %= $ + prev cur + cur (~(got z-by blocks.blocks) next-height) + next-height +(next-height) + == +:: +:: cook-base-blocks: convert the base events to a usable form +++ cook-base-blocks + |= raw=raw-base-blocks:cause + ^- base-blocks + =| ret=base-blocks + |^ + ?~ raw + ret(prev last-base-blocks.hash-state.state) + =? first-height.ret =(first-height.ret 0) + height.i.raw + :: always update last-height to track the highest block in the batch + =. last-height.ret height.i.raw + =. blocks.ret (~(put z-by blocks.ret) height.i.raw [(from-atom:blist block-id.i.raw) (from-atom:blist parent-block-id.i.raw)]) + =/ [withdrawals=(z-map beid withdrawal) deposit-settlements=(z-map beid deposit-settlement)] + (cook-base-txs txs.i.raw) + =. withdrawals.ret (~(uni z-by withdrawals.ret) withdrawals) + =. deposit-settlements.ret (~(uni z-by deposit-settlements.ret) deposit-settlements) + $(raw t.raw) + :: + ++ cook-base-txs + |= txs=(list base-event) + ^- [withdrawals=(z-map beid withdrawal) deposit-settlements=(z-map beid deposit-settlement)] + =| ret=[withdrawals=(z-map beid withdrawal) deposit-settlements=(z-map beid deposit-settlement)] + |- + ?~ txs ret + =. ret + ?- +<.i.txs + %bridge-node-updated !! :: TODO: one day + %deposit-processed + :- withdrawals.ret + :: convert base-event-id to blist for z-map compatibility + %+ ~(put z-by deposit-settlements.ret) + (from-atom:blist base-event-id.i.txs) + :* (from-atom:blist base-event-id.i.txs) + nock-note-name.content.i.txs + as-of.content.i.txs + block-height.content.i.txs + recipient.content.i.txs + amount.content.i.txs + nonce.content.i.txs + == + :: + %burn-for-withdrawal + :_ deposit-settlements.ret + :: convert base-event-id to blist for z-map compatibility + %+ ~(put z-by withdrawals.ret) + (from-atom:blist base-event-id.i.txs) + :* (from-atom:blist base-event-id.i.txs) + lock-root.content.i.txs + amount-burned=amount.content.i.txs :: TODO: what about withdrawal fees on the nock side? + fee=*coins:t + == + == + $(txs t.txs) + -- +:: +:: +process-base-blocks: +:: - update hash-state to reflect new base blocks +:: - process unsettled deposits +:: +:: returns: [%| effect] if stop condition is hit, otherwise, return [%& state] +:: +++ process-base-blocks + |= blocks=base-blocks + ^- process-result + =/ base-blocks-hash (hash:base-blocks blocks) + =. base-hashchain.hash-state.state + %+ ~(put z-by base-hashchain.hash-state.state) + base-blocks-hash + blocks + =. last-base-blocks.hash-state.state base-blocks-hash + =. base-hashchain-next-height.hash-state.state + %+ add + base-hashchain-next-height.hash-state.state + base-blocks-chunk.constants.state + =? unsettled-withdrawals.hash-state.state !=(~ withdrawals.blocks) + %- ~(put z-by unsettled-withdrawals.hash-state.state) + [base-blocks-hash withdrawals.blocks] + (base-process-deposit-settlements blocks) +:: +:: +base-process-deposit-settlements: confirm the deposits in the latest base block batch +++ base-process-deposit-settlements + |= latest-blocks=base-blocks + ^- process-result + =+ settlements=~(tap z-by deposit-settlements.latest-blocks) + =/ hold base-hold.hash-state.state + |- + ?~ settlements + ?~ hold [%& state] + [%| [%hold u.hold]] + =/ [event-id=beid settlement=deposit-settlement] + i.settlements + =/ [name=nname:t as-of=nock-hash height=@] [counterpart as-of nock-height]:settlement + ?. (lth nonce.settlement next-nonce.state) + [%| [%stop 'nonce in deposit settlement is not less than next nonce']] + ?. (~(has z-by nock-hashchain.hash-state.state) as-of) + :: this means that we still have not processed the nockchain deposit tx + :: corresponding to the settlement. put a hold on it. if there is already a + :: hold, pick the hold with the greatest height. + %= $ + settlements + t.settlements + :: + hold + ?~ hold `[as-of height] + ?: (lte height height.u.hold) hold + `[as-of height] + == + :: + :: If there are any holds, do not process the settlement + ?. =(~ hold) + $(settlements t.settlements) + =/ counterpart=deposit + =+ block-with-deposit=(~(got z-by nock-hashchain.hash-state.state) as-of) + (~(got z-by deposits.block-with-deposit) name) + :: + :: find the corresponding unsettled deposit in the hash-state. + :: we do not require the bridge node to have seen the proposal prior to observing + :: the deposit settlement. + :: - if bridge node has seen proposal, the deposit will be in the unconfirmed-settled set. + :: - if bridge node has not seen proposal, the deposit will be in the unsettled deposit set. + :: - if the unsettled deposit is in neither, this is a STOP condition. + ?. (has-unsettled-deposit as-of name) + [%| [%stop 'failed to process deposit settlement: cannot find unsettled deposit in state']] + ?. (check-deposit-settlement counterpart settlement) + [%| [%stop 'failed to process deposit settlement: counterpart does not match settlement']] + :: + :: now that the deposit settled on base, delete it from the tracked state + :: we delete from both the unconfirmed and unsettled deposit sets because + :: the deposit it could have been in either. + =. unconfirmed-settled-deposits.hash-state.state + (~(del z-bi unconfirmed-settled-deposits.hash-state.state) [as-of name]) + =. unsettled-deposits.hash-state.state + (~(del z-bi unsettled-deposits.hash-state.state) [as-of name]) + $(settlements t.settlements) +:: +++ has-unsettled-deposit + |= [as-of=nock-hash name=nname:t] + ?| (~(has z-bi unconfirmed-settled-deposits.hash-state.state) as-of name) + (~(has z-bi unsettled-deposits.hash-state.state) as-of name) + == +:: +++ check-deposit-settlement + |= $: counterpart=deposit + settlement=deposit-settlement + == + =/ dest-matches=? + ?~ dest.counterpart %.n + =(dest.settlement u.dest.counterpart) + =/ amount-matches=? + =(amount-to-mint.counterpart settled-amount.settlement) + ?. dest-matches + ~> %slog.[0 'settlement destination does not match deposit destination'] %.n + ?. amount-matches + ~> %slog.[0 'settlement amount does not match deposit amount'] %.n + %.y +:: +++ base-propose-withdrawals + |= latest-blocks=base-blocks + ^- (unit effect) + ::>) TODO: when we implement withdrawals, return %nock-proposal request if there are qualified withdrawals + ~| %todo !! +-- diff --git a/hoon/apps/bridge/bridge.hoon b/hoon/apps/bridge/bridge.hoon new file mode 100644 index 000000000..c13abf6f0 --- /dev/null +++ b/hoon/apps/bridge/bridge.hoon @@ -0,0 +1,384 @@ +:: base bridge nockapp +:: +:: implements the nockchain side of a federated 3-of-5 multisig +:: bridge to base l2. detects v1 bridge transactions, coordinates +:: bundle proposals through round-robin, collects signatures from +:: bridge nodes, and submits bundles to base for token minting. +:: +/= t /common/tx-engine +/= base-lib /apps/bridge/base +/= nock-lib /apps/bridge/nock +/= * /common/zeke +/= * /common/zoon +/= * /common/zose +/= * /common/wrapper +/= * /apps/bridge/types +/= dumb /apps/dumbnet/lib/types +:: +=> +|% +++ moat (keep bridge-state) +:: +:: +generate-test-config: create testing configuration +:: +:: generates a default node-config for testing with 5 localhost +:: nodes. uses dummy keys and addresses. in production, nodes +:: load real configuration from config files. +:: +++ generate-test-config + ^- node-config + :: create deterministic test keys that ensure node 0 is always proposer at height 0 + :: by making its nockchain pubkey lexicographically smallest + =/ test-seckeys=(list schnorr-seckey:t) + :~ (from-atom:schnorr-seckey:t 0x1.0000.0000.0000.0000) + (from-atom:schnorr-seckey:t 0x2.0000.0000.0000.0000) + (from-atom:schnorr-seckey:t 0x3.0000.0000.0000.0000) + (from-atom:schnorr-seckey:t 0x4.0000.0000.0000.0000) + (from-atom:schnorr-seckey:t 0x5.0000.0000.0000.0000) + == + =/ test-pubkeys=(list schnorr-pubkey:t) + %+ turn test-seckeys + |= seckey=schnorr-seckey:t + %- ch-scal:affine:curve:cheetah + :* (t8-to-atom:belt-schnorr:cheetah seckey) + a-gen:curve:cheetah + == + :: compute PKHs (public key hashes) from pubkeys + =/ test-pkhs=(list hash:t) + %+ turn test-pubkeys + |= pubkey=schnorr-pubkey:t + (hash:schnorr-pubkey:t pubkey) + :: verify that node 0's pkh is lexicographically smallest + :: and reorder if necessary to ensure deterministic proposer selection + =/ pkh-b58-strings=(list @t) + %+ turn test-pkhs + |= pkh=hash:t + (to-b58:hash:t pkh) + =/ sorted-indices=(list @ud) + %+ sort (gulf 0 4) + |= [a=@ud b=@ud] + =/ str-a=@t (snag a pkh-b58-strings) + =/ str-b=@t (snag b pkh-b58-strings) + (lth str-a str-b) + :: reorder nodes so that the lexicographically smallest pkh is at index 0 + =/ reordered-seckeys=(list schnorr-seckey:t) + %+ turn sorted-indices + |= idx=@ud + (snag idx test-seckeys) + =/ reordered-pkhs=(list hash:t) + %+ turn sorted-indices + |= idx=@ud + (snag idx test-pkhs) + =/ test-nodes=(list node-info) + :~ [ip='localhost:8001' eth-pubkey=0x1111 nock-pkh=(snag 0 reordered-pkhs)] + [ip='localhost:8002' eth-pubkey=0x2222 nock-pkh=(snag 1 reordered-pkhs)] + [ip='localhost:8003' eth-pubkey=0x3333 nock-pkh=(snag 2 reordered-pkhs)] + [ip='localhost:8004' eth-pubkey=0x4444 nock-pkh=(snag 3 reordered-pkhs)] + [ip='localhost:8005' eth-pubkey=0x5555 nock-pkh=(snag 4 reordered-pkhs)] + == + :* 0 + test-nodes + 0xdead.beef + (snag 0 reordered-seckeys) + == +:: +++ bridge + |_ state=bridge-state + +* base ~(. base-lib state) + nock ~(. nock-lib state) + :: + ++ handle-cause + |= [=cause rest=[=wire eny=@ our=@ux now=@da]] + ^- [(list effect) bridge-state] + ?> ?=(%0 -.cause) + ~& %handle-cause + ?^ stop.state + =+ base-hash-b58=(to-b58:hash:t hash.base.u.stop.state) + =+ nock-hash-b58=(to-b58:hash:t hash.nock.u.stop.state) + =/ msg=@t + ;: (cury cat 3) + 'bridge was stopped. no causes will be processed. last known good base blocks hash: ' + base-hash-b58 + '. last known good nock block hash: ' + nock-hash-b58 + '.' + == + ~> %slog.[0 msg] + [~ state] + ?: ?| ?=(^ base-hold.hash-state.state) + ?=(^ nock-hold.hash-state.state) + == + [[%0 %stop 'hold detected. we do not handle holds at the moment, so we treat them as a stop condition' (get-stop-info state)]~ state] + :: virtualize the cause handler to catch crashes that may not have been caught. + =; result + ?- -.result + %| + =/ msg=@t (cat 3 'bridge kernel: crashed when handling cause: ' +<.cause) + %- (slog p.result) + [[%0 %stop msg (get-stop-info state)]~ state] + :: + %& + p.result + == + %- mule + |. + ?- +<.cause + %cfg-load (config-load config.cause) + %set-constants (set-constants constants.cause) + %stop [~ state(stop `last.cause)] + %start [~ state(stop ~)] + %base-blocks (incoming-base-blocks:base +>.cause rest) + %nockchain-block (incoming-nockchain-block:nock +>.cause rest) + %proposed-base-call (evaluate-base-call +>.cause rest) + %proposed-nock-tx (evaluate-proposed-nock-tx +>.cause rest) + == + ++ config-load + |= config=(unit node-config) + ?^ config + [~ state(config u.config)] + [~ state] + :: + ++ set-constants + |= new-constants=bridge-constants + ^- [(list effect) bridge-state] + :: validate version + ?. =(version.new-constants %0) + ~> %slog.[0 'set-constants: unsupported version'] + [~ state] + :: validate min-signers <= total-signers + ?: (gth min-signers.new-constants total-signers.new-constants) + ~> %slog.[0 'set-constants: min-signers cannot exceed total-signers'] + [~ state] + :: validate min-signers > 0 + ?: =(min-signers.new-constants 0) + ~> %slog.[0 'set-constants: min-signers must be at least 1'] + [~ state] + :: validate minimum-event-nocks > 0 + ?: =(minimum-event-nocks.new-constants 0) + ~> %slog.[0 'set-constants: minimum-event-nocks must be greater than 0'] + [~ state] + :: validate base-blocks-chunk > 0 + ?: =(base-blocks-chunk.new-constants 0) + ~> %slog.[0 'set-constants: base-blocks-chunk must be greater than 0'] + [~ state] + :: all validations passed, update state + ~> %slog.[0 'set-constants: constants updated successfully'] + :: update hashchain next-heights if they're still at old defaults + :: (i.e., bridge hasn't started processing blocks yet) + =/ old-nock-start nockchain-start-height.constants.state + =/ old-base-start base-start-height.constants.state + =/ new-state state(constants new-constants) + =? nock-hashchain-next-height.hash-state.new-state + =(nock-hashchain-next-height.hash-state.state old-nock-start) + nockchain-start-height.new-constants + =? base-hashchain-next-height.hash-state.new-state + =(base-hashchain-next-height.hash-state.state old-base-start) + base-start-height.new-constants + [~ new-state] + :: + :: +evaluate-base-call: + :: Invoke this function only after confirming that every proposed deposit corresponds + :: exactly to an unsettled deposit in the hash-state, matching both the recipient + :: EVM address and the amount. + :: + :: This function moves the proposed deposit from unsettled-deposits to + :: unconfirmed-settled-deposits, signalling that the deposit has been seen + :: by the node, is considered valid, and is now awaiting confirmation + :: base. + ++ evaluate-base-call + |= [proposal=proposed-base-call:cause rest=[=wire eny=@ our=@ux now=@da]] + ^- [(list effect) bridge-state] + ~& %evaluate-base-call + =+ old-state=state + =/ stop-info (get-stop-info old-state) + =/ template |=(msg=@t [%0 %stop msg stop-info]) + |- + ?~ proposal + [~ old-state] + =+ [name as-of nonce]=[name as-of nonce]:i.proposal + ?: (gte nonce next-nonce.state) + :: Proposed nonces must refer to an already-assigned nonce. This condition + :: should never trigger because %proposed-deposit checks if the nonce was already assigned. + :: If it triggers, it is a sign of rust driver malfunciton. + [[(template 'nonce in proposed base call is greater than or equal to next-nonce')]~ old-state] + :: + :: The two conditionals below can be removed if we confirm that the driver is + :: checking the pre-conditions before calling this arm. I will keep them here + :: for now out of paranoia. + ?. (~(has z-bi unsettled-deposits.hash-state.state) as-of name) + [[(template 'proposed deposit not in unsettled-deposits')]~ old-state] + ?: (~(has z-bi unconfirmed-settled-deposits.hash-state.state) as-of name) + [[(template 'encountered double proposal. proposed deposit already in unconfirmed-settled-deposits.')]~ old-state] + =+ deposit=(~(got z-bi unsettled-deposits.hash-state.state) as-of name) + :: + :: proposal should have already been checked against deposit through a rust peek + =. unsettled-deposits.hash-state.state + (~(del z-bi unsettled-deposits.hash-state.state) as-of name) + =. unconfirmed-settled-deposits.hash-state.state + (~(put z-bi unconfirmed-settled-deposits.hash-state.state) as-of name deposit) + $(proposal t.proposal) + :: + ++ evaluate-proposed-nock-tx + |= [proposal=proposed-nock-tx:cause rest=[=wire eny=@ our=@ux now=@da]] + ^- [(list effect) bridge-state] + ~& [%evaluate-proposed-nock-tx proposal rest] + ~| %todo !! + :: + -- +-- +:: +%- (moat |) +^- fort:moat +|_ state=bridge-state ++* b ~(. bridge state) +:: +:: +load: initialize or restore bridge state +:: +:: loads bridge node configuration from file or generates test +:: config if none exists. called on nockapp startup to initialize +:: the bridge state with node identity and network configuration. +:: +++ load + |= arg=bridge-state + ^- bridge-state + arg +:: +:: +peek: read-only queries into bridge state +:: +:: handles scry requests to inspect bridge state. +:: +++ peek + |= arg=path + ^- (unit (unit *)) + ~& bridge-peek+arg + =/ =(pole) arg + ?+ pole ~ + :: Use this peek to ensure that the bridge is booting in mainnet mode with the correct deployment constants + [%fakenet ~] ``!=(constants.state *bridge-constants) + :: + [%state ~] ``state + :: + [%hash-state ~] ``hash-state.state + :: + [%constants ~] ``constants.state + :: + [%base-hold ~] + =+ base-hold=base-hold.hash-state.state + ?~ base-hold + ``%.n + ``(~(has z-by base-hashchain.hash-state.state) hash.u.base-hold) + :: + [%nock-hold ~] + =+ nock-hold=nock-hold.hash-state.state + ?~ nock-hold + ``%.n + ``(~(has z-by nock-hashchain.hash-state.state) hash.u.nock-hold) + :: + [%unsettled-deposit-count ~] + ``~(wyt z-by unsettled-deposits.hash-state.state) + :: + [%unconfirmed-settled-deposit-count ~] + ``~(wyt z-by unconfirmed-settled-deposits.hash-state.state) + :: + [%unsettled-withdrawal-count ~] + ``~(wyt z-by unsettled-withdrawals.hash-state.state) + :: + [%unconfirmed-settled-withdrawal-count ~] + ``~(wyt z-by unconfirmed-settled-withdrawals.hash-state.state) + :: + [%nock-hashchain-next-height ~] + ``nock-hashchain-next-height.hash-state.state + :: + [%base-hashchain-next-height ~] + =/ stored base-hashchain-next-height.hash-state.state + =/ start base-start-height.constants.state + =/ result ?:((lth stored start) start stored) + ~& base-next-height+[stored=stored start=start result=result] + ``result + :: + [%stop-info ~] + ``(get-stop-info state) + :: + [%proposed-deposit tx-id=@t nock-hash=@t first-name=@t last-name=@t receiver=@t amount-to-mint=@ nonce=@ ~] + :: check if deposit exists under key (nock-hash [first-name last-name]). Then check if + :: the evm receiver address matches and the amount-to-mint = raw-amount - fee(raw-amount) + :: notes: + :: - consider storing minted-amount in deposit to simplify the check. + :: - do we need to check if the deposit tx-id matches with the tx-id in the proposal? + :: + =+ tx-id=(from-b58:hash:t tx-id.pole) + =+ block-hash=(from-b58:hash:t nock-hash.pole) + =+ name=(from-b58:nname:t [first-name last-name]:pole) + =/ receiver=evm-address (de-base58 (trip receiver.pole)) + :: + :: if this condition is hit, it should result in a stop condition because + :: it means that this node has already processed this deposit proposal. + :: do not call %evaluate-base-call, do not sign the proposal, emit a STOP. + ?: (~(has z-bi unconfirmed-settled-deposits.hash-state.state) block-hash name) + [~ ~ %.n] + :: returning [~ ~] means that a deposit corresponding to (block-hash, name) was not found. + :: if [~ ~] is returned, do not call %evaluate-base-call and do not sign the proposal. + :: This is not a stop condition because it is possible that the node is still syncing. + ?. (~(has z-bi unsettled-deposits.hash-state.state) block-hash name) + [~ ~] + :: + :: If we have the deposit in our hash-state, but the nonce is greater than the next + :: nonce, we may be getting asked to sign an invalid nonce. + :: + :: This is a STOP condition because we should have processed the deposit and produced + :: the proposal, which resulted in the next-nonce getting incremented, in the same atomic event. + ?: (gte nonce.pole next-nonce.state) + [~ ~ %.n] + =+ deposit=(~(got z-bi unsettled-deposits.hash-state.state) block-hash name) + =/ dest-matches=? + ?~ dest.deposit %.n + =(u.dest.deposit receiver) + =/ amount-matches=? + =(amount-to-mint.pole amount-to-mint.deposit) + =/ tx-id-matches=? + =(tx-id tx-id.deposit) + :: + :: returning %.y means that a matching deposit was present in the hash-state. + :: call %evaluate-base-call and sign the proposal. + ?: ?&(dest-matches amount-matches tx-id-matches) + [~ ~ %.y] + :: + :: if this condition is hit, it should result in a stop condition because + :: it means the deposit entry under (block-hash, name) exists, but the + :: the destination and/or amount does not match. This means that the proposer + :: submitted an invalid proposal. + :: + :: this goes without saying, if [~ ~ %.n] is returned: + :: - do not call %evaluate-base-call + :: - do not sign the proposal + :: - return a STOP condition + [~ ~ %.n] + == +:: +:: +poke: handle incoming bridge events +:: +:: processes all incoming events for the bridge: grpc responses, +:: bridge-specific causes, and node coordination +:: messages. routes to appropriate handlers based on wire and +:: cause type. +:: +++ poke + |= [=wire eny=@ our=@ux now=@da dat=*] + ^- [(list effect) bridge-state] + =; res + ~& > "effects: {<-.res>}" + res + =/ soft-cause ((soft cause) dat) + ?~ soft-cause + ~& "bridge: could not mold poke: {}" !! + =/ =cause u.soft-cause + =/ tag +<.cause + =/ =(pole) wire + ~& > "poke: saw cause {<;;(@t tag)>} on wire {}" + ?+ pole ~|("unsupported wire: {}" !!) + :: + [%poke src=?(%one-punch %signature) ver=@ *] + (handle-cause:b cause [wire eny our now]) + == +:: +-- diff --git a/hoon/apps/bridge/nock.hoon b/hoon/apps/bridge/nock.hoon new file mode 100644 index 000000000..a0b69f835 --- /dev/null +++ b/hoon/apps/bridge/nock.hoon @@ -0,0 +1,387 @@ +/= t /common/tx-engine +/= * /common/zeke +/= * /common/zoon +/= * /common/zose +/= * /common/wrapper +/= * /apps/bridge/types +/= dumb /apps/dumbnet/lib/types +|_ state=bridge-state +++ incoming-nockchain-block + |= [nockchain-block=nockchain-block:cause rest=[=wire eny=@ our=@ux now=@da]] + ^- [(list effect) bridge-state] + ~& %incoming-nockchain + ::~& [%incoming-nockchain-block rest] + ~| %txs-provided-check + :: save old-state in case we need to revert after an error + =/ old-state state + =/ stop-info (get-stop-info old-state) + ?. ?=(%1 -.block.nockchain-block) + ~> %slog.[0 'ignoring v0 block, bridge starts after v0 cutover'] + [~ state] + ?: !=(tx-ids.block.nockchain-block ~(key z-by txs.nockchain-block)) + [[%0 %stop 'tx-ids mismatch txs in nockchain block' stop-info]~ old-state] + =/ block-height=@ height.block.nockchain-block + =/ start=@ nockchain-start-height.constants.state + ?: (lth block-height start) + ~& "received nockchain block at height {}, bridge starts at height {}." + [~ old-state] + ?^ stop=(validate-nockchain-page-sequence block.nockchain-block) + [[%0 %stop u.stop stop-info]~ old-state] + =/ [latest-block=nock-block process-block=process-result] + (process-nockchain-block block.nockchain-block txs.nockchain-block) + ?- -.process-block + %| + =/ =process-fail +.process-block + ?- -.process-fail + %stop [[%0 %stop msg.process-fail stop-info]~ old-state] + %hold [~ old-state(nock-hold.hash-state `hold.process-fail)] + == + :: + %& + :: if process block was successful, update state and carry on + =. state p.process-block + =? nock-hold.hash-state.state ?=(^ nock-hold.hash-state.state) + =+ nock-hash=(hash:nock-block latest-block) + ?: =(nock-hash hash.u.nock-hold.hash-state.state) ~ + nock-hold.hash-state.state + =? base-hold.hash-state.state ?=(^ base-hold.hash-state.state) + =+ nock-hash=(hash:nock-block latest-block) + ?: =(nock-hash hash.u.base-hold.hash-state.state) ~ + base-hold.hash-state.state + =/ current-height=@ud ~(height get:page:t last-block.state) + :: + :: If there are no signature requests, we will not submit a proposal. + :: Note that even blocks with deposits could result in no signature requests + :: because the deposits may be issued to malformed evm addresses. + :: + :: Base recipient addresses are represented as (unit base-addr) where the null + :: case represents a malformed address. + :: + :: If any deposit is issued to a malformed address, we do not process it. + :: We instead keep the deposited funds in the bridge address. + :: + =^ eth-sig-requests state + (nockchain-propose-deposits latest-block) + ?~ eth-sig-requests + [~ state] + ~& eth-sig-requests+eth-sig-requests + [[%0 %propose-base-call eth-sig-requests]~ state] + == +:: +:: check if nockchain page belongs to hashchain +++ validate-nockchain-page-sequence + |= =page:v1:t + ^- (unit @t) + =/ height ~(height get:page:t page) + ?. =(height.page nock-hashchain-next-height.hash-state.state) + ~& %driver-malfunction-received-block-with-height-greater-than-next-height + [~ 'received block with height not equal to next height'] + ?: =(height.page nockchain-start-height.constants.state) + ~ + =/ last-nock-block + (~(got z-by nock-hashchain.hash-state.state) last-nock-block.hash-state.state) + :: + :: This condition should never ever trigger if the state machine is working correctly + ?. =(height.last-nock-block (dec nock-hashchain-next-height.hash-state.state)) + ~& %fatal-last-nock-block-is-not-decrement-of-next-nock-hashchain-height + [~ 'fatal: height of last block in hashchain is not (next-height - 1)'] + ?. =(block-id.last-nock-block parent.page) + [~ 'hashchain reorg: parent of incoming block is not the last block in the hashchain'] + ~ +:: +++ process-nockchain-block + |= [block=page:t txs=(z-map tx-id:t tx:t)] + ^- [nock-block process-result] + |^ + ?: ?=(^ -.block) + :: we should not be processing blocks that were mined prior to the bridge cutover. + ~| %v0-block-received !! + =+ [deposits withdrawal-settlements]=process-nock-txs + =/ nock-blk=nock-block + :* %nock + %0 + height.block + digest.block + deposits + withdrawal-settlements + :: if it's the first block in the hash chain, prev will point to [0x0 0x0 0x0 0x0 0x0] + :: this is okay. + prev=last-nock-block.hash-state.state + == + =/ nock-blk-hash (hash:nock-block nock-blk) + =. last-block.state block + =. nock-hashchain.hash-state.state + %+ ~(put z-by nock-hashchain.hash-state.state) + nock-blk-hash + nock-blk + =. last-nock-block.hash-state.state nock-blk-hash + =. nock-hashchain-next-height.hash-state.state + +(nock-hashchain-next-height.hash-state.state) + =. hash-state.state + %+ roll + ~(tap z-by deposits.nock-blk) + |= [[name=nname:t =deposit] hash-state=_hash-state.state] + =. unsettled-deposits.hash-state + %- ~(put z-bi unsettled-deposits.hash-state) + [nock-blk-hash name deposit] + hash-state + [nock-blk (nockchain-process-withdrawal-settlements nock-blk)] + :: + ++ process-nock-txs + ^- [deposits=(z-map nname deposit) withdrawal-settlements=(z-map nname withdrawal-settlement)] + =/ tx-list ~(tap z-by txs) + =| ret=[deposits=(z-map nname deposit) withdrawal-settlements=(z-map nname withdrawal-settlement)] + |- + ?~ tx-list ret + =* tx-id p.i.tx-list + =* tx q.i.tx-list + ?: (is-bridge-deposit-tx tx) + :: produce a deposit + :: + ~& bridge-deposit-detected+tx + =/ maybe-intent=(unit deposit-intent) + (extract-deposit-intent tx) + ~& maybe-intent+maybe-intent + ?~ maybe-intent + $(tx-list t.tx-list) + =. deposits.ret + (~(put z-by deposits.ret) name.u.maybe-intent [tx-id [name recipient amount-to-mint fee]:u.maybe-intent]) + $(tx-list t.tx-list) + ?: (is-bridge-withdrawal-tx tx) + :: crash here. there should be no withdrawals from the bridge address until we implement them. + :: the crash will get caught by the virtualization in +handle-cause and a %stop event will be + :: emitted. + ~> %slog.[0 'fatal: withdrawal tx detected, but withdrawals are disabled.'] + !! + :: TODO: revisit when its time to implement withdrawals + :: produce a withdrawal settlement + :: =/ withdraw-info=(unit [recipient=nock-addr name=nname:t amount=@ as-of=base-hash counterpart-base-event-id=base-event-id]) + :: (extract-withdrawal-info tx) + :: ?~ withdraw-info + :: :: just skip it + :: $(tx-list t.tx-list) + :: =/ w-settle=withdrawal-settlement + :: :* tx-id + :: name.u.withdraw-info + :: counterpart-base-event-id.u.withdraw-info + :: as-of.u.withdraw-info + :: recipient.u.withdraw-info + :: amount.u.withdraw-info + :: :: TODO: nock-tx-fee + :: *@ + :: == + :: =. withdrawal-settlements.ret + :: (~(put z-by withdrawal-settlements.ret) name.u.withdraw-info w-settle) + :: =. by-tx.ret + :: (~(put z-by by-tx.ret) name.u.withdraw-info tx-id) + :: $(tx-list t.tx-list) + $(tx-list t.tx-list) + :: + :: +is-bridge-deposit-tx: detect bridge transactions + :: + :: returns %.y if a transaction is a bridge deposit. checks that + :: the transaction is v1 and has %bridge field in note-data of + :: at least one output. the %bridge field contains [%base (list belt)] + :: where the list is the based representation of the evm recipient. + :: + ++ is-bridge-deposit-tx + |= =tx:t + ^- ? + ?. ?=(%1 -.tx) %.n + %+ lien ~(tap z-in outputs.tx) + |= out=output:v1:t + ?> ?=(@ -.note.out) + =/ =note-data:t note-data.note.out + (~(has z-by note-data) %bridge) + :: + :: +extract-deposit-intent: parse bridge transaction data + :: + :: extracts the recipient evm address and amount from a bridge + :: deposit transaction. searches outputs for %bridge field + :: containing [%0 %base evm-address-based], converts the based address + :: to raw evm format, and calculates total amount from spends. + :: returns ~ if the tx output doesn't go to the proper address or + :: the note-data doesn't have a %bridge entry. + :: + ++ extract-deposit-intent + |= =tx:t + ^- (unit deposit-intent) + ?> ?=(%1 -.tx) + =/ bridge-output=(unit output:v1:t) + =/ outputs-list=(list output:v1:t) + ~(tap z-in outputs.tx) + |- ^- (unit output:v1:t) + :: if there is no match, return ~ + ?~ outputs-list ~ + =/ out=output:v1:t i.outputs-list + ?. ?=(@ -.note.out) + $(outputs-list t.outputs-list) + ~& output-note+note.out + =/ =note-data:t note-data.note.out + ?: (lth assets.note.out minimum-event-nocks.constants.state) + ~> %slog.[0 'deposit-does-not-meet-minimum-requirement'] + $(outputs-list t.outputs-list) + ?: ?& (~(has z-by note-data) %bridge) + =(-.name.note.out (first:nname:v1:t bridge-lock-root.state)) + == + `out + $(outputs-list t.outputs-list) + ?~ bridge-output + ~> %slog.[0 'bridge data output note first name does not match bridge-lock-root first name'] + ~ + ~& bridge-output+bridge-output + ?> ?=(@ -.note.u.bridge-output) :: assert v1 output + =/ =note-data:t note-data.note.u.bridge-output + :: we already checked that the %bridge entry exists in the note data + =/ bridge-data (~(got z-by note-data) %bridge) + :: NOTE: the whole bridge will crash if someone puts a faulty bridge + :: note-data together without mole virtualizing the recipient processing. + :: validate bridge data format: [%0 %base evm-address-based] + =/ recipient=(unit evm-address) + %- mole + |. + =+ deposit-data=;;(bridge-deposit-data bridge-data) + :: convert from based representation to raw EVM address + (based-to-evm-address addr.deposit-data) + ?~ recipient + ~> %slog.[0 'Encountered malformed evm recipient address. Deposited nocks will remain in bridge nockchain wallet.'] + ~ + ~& recipient+recipient + =/ deposit-total assets.note.u.bridge-output + :: + =/ deposit-fee=@ (calculate:bridge-fee deposit-total nicks-fee-per-nock.constants.state) + =/ amount-to-mint=@ + (sub deposit-total deposit-fee) + :: amount that we are minting as a result of this deposit should be positive + ?: (gth amount-to-mint 0) + `[name.note.u.bridge-output recipient amount-to-mint deposit-fee] + ~ + -- +:: +:: +nockchain-process-withdrawal-settlements: +:: processes unsettled withdrawals in new nockchain block +:: note that withdrawal settlement will contain the amount of tokens burned minus the fee +:: TODO: once withdrawals are implemented, we need to emit holds for withdrawal settlements that we have not +:: processed the corresponding withdrawal for. +++ nockchain-process-withdrawal-settlements + |= latest=nock-block + ^- process-result + ?^ withdrawal-settlements.latest + [%| [%stop 'withdrawal settlement detected but withdrawals are not permitted']] + [%& state] +:: +:: +nockchain-propose-deposits: +:: This arm only gets called if its our turn to propose and there are deposits in the newst nock block. +++ nockchain-propose-deposits + |= =nock-block + ^- [(list eth-signature-request:effect) bridge-state] + =+ block-hash=(hash:^nock-block nock-block) + =^ requests=(list eth-signature-request:effect) state + %+ roll + ~(tap z-by deposits.nock-block) + |= $: [name=nname =deposit] + [requests=(list eth-signature-request:effect) state=_state] + == + :: if the recipient is malformed, we keep the funds in the bridge nock address + ?~ dest.deposit + [requests state] + =. unsettled-deposits.hash-state.state + (~(del z-bi unsettled-deposits.hash-state.state) block-hash name) + =. unconfirmed-settled-deposits.hash-state.state + (~(put z-bi unconfirmed-settled-deposits.hash-state.state) block-hash name deposit) + :_ state(next-nonce +(next-nonce.state)) + :_ requests + :: NOTE: as-of must be block-hash (hash of nock-block structure), NOT block-id (page digest). + :: Deposits are stored in unsettled-deposits keyed by block-hash, so peers must use + :: block-hash to look them up during validation. + :* tx-id.deposit + name + u.dest.deposit + amount-to-mint.deposit + height.nock-block + block-hash + next-nonce.state + == + :: + :: flop requests because they are getting prepended in the +roll and the + :: contract requires that deposits are processed in ascending nonce order + [(flop requests) state] +:: +++ is-bridge-withdrawal-tx + |= =tx:t + ^- ? + ?. ?=(%1 -.tx) %.n + =/ spent-from-bridge + %+ levy ~(tap z-by spends.raw-tx.tx) + |= [note-name=nname:t spend=spend-v1:t] + ^- ? + :: NOTE: must be spent from bridge + =(-.note-name (first:nname:v1:t bridge-lock-root.state)) + =/ output-has-counterpart + %+ lien ~(tap z-in outputs.tx) + |= out=output:v1:t + ?> ?=(@ -.note.out) + =/ =note-data:t note-data.note.out + :: check for some kind of bridge key that contains as-of (base hash) and counterpart-base-tx-id + ?> ?& (lth %ba-blk p) + (lth %ba-eid p) + == + ?& (~(has z-by note-data) %ba-blk) + (~(has z-by note-data) %ba-eid) + == + ?&(spent-from-bridge output-has-counterpart) +:: +++ extract-withdrawal-info + ::>) TODO: extract fee + |= =tx:t + ^- (unit [recipient=nock-addr name=nname:t amount=@ as-of=base-hash counterpart-base-event-id=base-event-id]) + ?> ?=(%1 -.tx) + =/ bridge-output=(unit output:v1:t) + =/ outputs-list=(list output:v1:t) + ~(tap z-in outputs.tx) + |- ^- (unit output:v1:t) + ?~ outputs-list ~ + =/ out=output:v1:t i.outputs-list + ?. ?=(@ -.note.out) + $(outputs-list t.outputs-list) + =/ =note-data:t note-data.note.out + ?. ?& (~(has z-by note-data) %ba-blk) + (~(has z-by note-data) %ba-eid) + == + $(outputs-list t.outputs-list) + `out + ?~ bridge-output + ~| %no-bridge-data-in-tx !! + ?> ?=(@ -.note.u.bridge-output) :: assert v1 output + =/ =note-data:t note-data.note.u.bridge-output + :: we already checked that these entries exist in the note data + =/ base-block-hash (~(got z-by note-data) %ba-blk) + =/ base-event-id (~(got z-by note-data) %ba-eid) + =/ recipient=nock-addr + =+ lock-data=(~(got z-by note-data) %lock) + ?~ soft-lock=((soft spend-condition:t) +.lock-data) + ~| %lock-data-malformed-in-tx-output !! + ?. =((lent u.soft-lock) 1) + ~| %more-than-one-lock-primitive-in-output-lock !! + =+ maybe-pkh=(head u.soft-lock) + ?. ?=(%pkh -.maybe-pkh) + ~| %lock-in-outputs-not-pkh !! + =+ receivers=~(tap z-in h.maybe-pkh) + ?> ?& =(1 (lent receivers)) + =(1 m.maybe-pkh) + == + (head receivers) + =/ amount-disbursed assets.note.u.bridge-output + =/ as-of=(unit base-hash) + ((soft base-hash) base-block-hash) + =/ counterpart-base-tx=(unit @) + ((soft @) base-event-id) + ?~ as-of + ~ + ?~ counterpart-base-tx + ~ + :: amount sent should be positive + ?: (gth amount-disbursed 0) + `[recipient name.note.u.bridge-output amount-disbursed u.as-of u.counterpart-base-tx] + ~ +-- diff --git a/hoon/apps/bridge/types.hoon b/hoon/apps/bridge/types.hoon new file mode 100644 index 000000000..de42b8e3f --- /dev/null +++ b/hoon/apps/bridge/types.hoon @@ -0,0 +1,849 @@ +:: bridge node types and utilities +:: +:: types and helper functions for the nockchain bridge system. +:: +/= t /common/tx-engine +/= * /common/zeke +/= * /common/zoon +/= * /common/wrapper +/= dumb /apps/dumbnet/lib/types +::>) TODO: review all hashables in types.hoon +:: +|% +:: +:: $node-config: bridge node configuration +:: +:: contains the identity of this node and the full configuration +:: of all 5 bridge nodes including their network addresses and +:: cryptographic keys for both ethereum and nockchain. +:: ++$ node-config + $: =node-id :: which of the 5 nodes (0-4) + nodes=(list node-info) :: all 5 node configs + my-eth-key=eth-seckey :: this node's eth private key + my-nock-key=schnorr-seckey:t :: this node's nock private key + == +:: +:: $node-info: information about a bridge node +:: +:: contains network address and public keys for both ethereum +:: and nockchain. used for gRPC communication and signature +:: verification. +:: ++$ node-info + $: ip=@t :: node ip/hostname + eth-pubkey=eth-pubkey :: ethereum public key + nock-pkh=hash:t :: nockchain public key hash + == +:: +:: $node-id: simple identifier for a node ++$ node-id @ +:: +:: $eth-seckey: ethereum secp256k1 secret key ++$ eth-seckey @ +:: +:: $eth-pubkey: ethereum secp256k1 public key ++$ eth-pubkey @ +:: +:: $eth-signature: ethereum secp256k1 signature ++$ eth-signature + $: r=@ux + s=@ux + v=@ud + == +:: +:: $evm-address: raw 20-byte ethereum address ++$ evm-address @ux +:: +:: $evm-address-based: ethereum address in based representation +:: +:: stored as list of base field elements for note-data compatibility +:: ++$ evm-address-based [@ux @ux @ux] +:: +++ base-addr evm-address +:: +:: base-event-id is base-tx-id + log index. +:: +:: every base tx has like an array of events +:: the log index is the index into that array +:: so the tx-id + that index will give you a unique id for every event +:: +++ base-event-id @ +++ base-tx-id @ +:: +:: $blist: based list - lossless representation of arbitrary atoms as based values +:: +:: atoms from Base chain (event IDs, tx IDs, block IDs) can exceed the +:: base field prime p, which causes crashes in z-map operations since +:: tip5 hash requires based values for both keys and values. +:: +:: blist stores atoms as a list of based field elements, which is: +:: - lossless (can convert back to original atom) +:: - safe for z-map keys and values (all elements < p) +:: - compatible with tip5 hashing +:: +:: modeled after atom-to-digest/digest-to-atom in ztd/three.hoon +:: +++ blist + =< form + |% + +$ form $+(blist (list @)) + ++ hashable + |= =form + ^- hashable:tip5 + leaf+form + :: + :: +from-atom: convert any atom to a based list (lossless) + ++ from-atom + |= n=@ + ^- form + ?: (based n) ~[n] + =/ [q=@ r=@] (dvr n p) + [r $(n q)] + :: + :: +to-atom: convert a based list back to an atom (inverse of +from-atom) + ++ to-atom + |= l=form + ^- @ + ?~ l 0 + %+ roll (flop l) + |= [belt=@ acc=@] + (add belt (mul p acc)) + :: + :: +valid: check all elements are based + ++ valid + |= l=form + ^- ? + (levy l based) + -- +:: +:: semantic aliases for blist - use these in type signatures for clarity +:: +++ beid blist :: base event id (Base chain tx ID added to log index as a based list) +++ btid blist :: based tx id (Base chain tx ID as based list) +++ bbid blist :: based block id (Base chain block ID as based list) +:: +:: alises so we can differentiate between nock and base blocks hashes +++ nock-hash $+(nock-hash hash:t) +++ base-hash $+(base-hash hash:t) +:: +:: TODO: should probably be called lock-root? +++ nock-addr hash:t +:: +:: +++ coins coins:t +:: +++ tx-id tx-id:t +:: +++ block-id block-id:t +:: +++ nname nname:t +:: +:: +++ base-block-id @ +:: +++ nock-pubkey schnorr-pubkey:t +:: +:: // Solidity Events +:: event DepositProcessed( +:: bytes32 indexed txId, +:: // TODO need nname +:: address indexed recipient, +:: uint256 amount, +:: uint256 blockHeight, +:: bytes32 asOf +:: ); +:: +:: event BridgeNodeUpdated( +:: uint256 indexed index, +:: address indexed oldNode, +:: address indexed newNode +:: ); +:: +:: event BurnForWithdrawal( +:: address indexed burner, +:: uint256 amount, +:: bytes32 indexed lockRoot, +:: ); +:: ++$ base-event + $: =base-event-id + $= content + $% [%deposit-processed nock-tx-id=tx-id nock-note-name=nname recipient=base-addr amount=@ block-height=@ as-of=hash:t nonce=@] + [%bridge-node-updated ~] + [%burn-for-withdrawal burner=base-addr amount=@ lock-root=hash:t] + == + == +:: +::: hashchain molds +::: ++$ min-signers $~(3 @) ++$ total-signers $~(5 @) ++$ minimum-event-nocks $~(100.000 @) :: 100,000 nock event = 300 nock fee ++$ nicks-fee-per-nock $~(195 @) :: 2^16 * 0.003 = 196.6, rounded down to nearest factor of 5 for easy division between the bridge nodes ++$ base-blocks-chunk $~(100 @) ++$ base-start-height $~(39.694.000 @) +::>) !TODO!: set this to the proper cutoff for the bridge to start accepting deposits ++$ nockchain-start-height $~(46.810 @) +:: +++ bridge-lock-root-default + (from-b58:hash:t 'AcsPkuhXQoGeEsF91yynpm1kcW17PQ2Z1MEozgx7YnDPkZwrtzLuuqd') ++$ bridge-lock-root $~(bridge-lock-root-default hash:t) +:: +++ bridge-constants + =< form + |% + +$ form + $+ bridge-constants + $: version=%0 + =min-signers + =total-signers + =minimum-event-nocks + =nicks-fee-per-nock + =base-blocks-chunk + =base-start-height + =nockchain-start-height + == + -- +:: ++$ stop-info [base=[hash=base-hash height=@] nock=[hash=nock-hash height=@]] +:: +:: $bridge-state: state of the bridge ++$ bridge-state + $: %0 + config=node-config :: node configuration + constants=bridge-constants :: static bridge parameters + =hash-state :: hashlogged cross-chain state + next-nonce=$~(1 @) :: nonce to track processed deposits + last-block=page:t :: for determining proposer + =bridge-lock-root :: script hash: receive address for bridge deposits + stop=(unit stop-info) :: flag to stop the bridge. populated with last known good block hashes if stop is true. + == +++ get-stop-info + |= state=bridge-state + ^- stop-info + =+ hs=hash-state.state + :- :- last-base-blocks.hs + ?: =(0 base-hashchain-next-height.hs) 0 + (dec base-hashchain-next-height.hs) + :- last-nock-block.hs + ?: =(0 nock-hashchain-next-height.hs) 0 + (dec nock-hashchain-next-height.hs) +:: ++$ process-result (each bridge-state process-fail) ++$ process-fail + $% [%stop msg=@t] + [%hold hold=[=hash:t height=@]] + == +:: +::: +++ hash-state + =< form + |% + +$ form + $+ hash-state + $: version=%0 + :: + :: hashchains + last-nock-block=nock-hash + last-base-blocks=base-hash + nock-hashchain=(z-map nock-hash nock-block) + base-hashchain=(z-map base-hash base-blocks) + :: + :: + :: nock-hold blocks the advancement of nock hashchain until the + :: the base block with the specified hash is processed + nock-hold=(unit [hash=base-hash height=@]) + :: + :: base-hold blocks the advancement of base hashchain until the + :: the nock block with the specified hash is processed + base-hold=(unit [hash=nock-hash height=@]) + :: + :: Next Nockchain block height required for the hashchain + nock-hashchain-next-height=nockchain-start-height + :: + :: Next highest-in-the-batch height required for the BASE hashchain + base-hashchain-next-height=base-start-height + :: + :: TODO: track the as-of cursor note for the bridge and aggregate all assets here + bridge-treasury-note=nname + :: + :: TODO: track the new bridge deposit notes since last withdrawal + bridge-deposit-notes=(z-set nname) + :: + :: track unsettled asset allocations + :: + :: For each hashchain we need two sets: + :: + :: unsettled-deposits tracks deposits + :: which have confirmed on Nockchain but for which our node has never + :: seen a settlement transaction. + :: + :: If a deposit is not in this set we will not sign a settlement transaction for it. + :: + :: unconfirmed-settled-deposits tracks deposits for which we have seen a deposit user action, + :: but which we have not yet seen confirmed on BASE. This allows us to + :: validate the BASE consensus. + :: + :: unsettled-withdrawals and unconfirmed-settled-withdrawals work similarly, + :: but for withdrawals initiated on BASE and settled on Nockchain + :: + :: unsettled-deposits are tracked post-fee, so the amount recorded + :: is the exact amount which should be minted in Base NOCK + :: + :: deposits are removed from this set when we propose, sign, or + :: observe a transaction settling them, even if not posted to or + :: confirmed on BASE + :: + :: Populated by nock hashchain: when a nock block is confirmed, + :: deposit notes are added to this set + unsettled-deposits=(z-mip nock-hash nname:t deposit) + :: + :: unconfirmed-settled-deposits may have a signed transaction settling, + :: but not yet confirmed on BASE + :: + :: deposits are removed from this set when the deposit settlement is + :: confirmed on BASE + :: + :: Populated by deposits removed from unsettled deposits + :: + :: Note that during BASE hashchain validation, we may encounter a + :: a deposit settlement which we never observed prior to confirmation, + :: so if a deposit is not in this set 'unsettled-deposits' should also + :: be checked and the deposit should be removed from that set and *not* + :: added to this one + unconfirmed-settled-deposits=(z-mip nock-hash nname:t deposit) + :: + :: unsettled-withdrawals are tracked pre-fee, so the amount recorded + :: is exact amount which is burned on Base NOCK, but not the exact amount which will be received on nockchain + :: + :: withdrawals are removed from this set when we propose, sign, or + :: observe a transaction settling them, even if not posted to or + :: confirmed on Nockchain + unsettled-withdrawals=(z-mip base-hash beid withdrawal) + :: + :: unconfirmed-settled-withdrawals may have a signed transaction settling, + :: but not yet confirmed on Nockchain + :: + :: withdrawals are removed from this set when the withdrawal settlement is + :: confirmed on Nockchain + unconfirmed-settled-withdrawals=(z-mip base-hash beid withdrawal) + == + -- +::: +++ nock-block + =< form + |% + +$ form + $+ nock-block + $: %nock + version=%0 + height=@ + =block-id + deposits=(z-map nname:t deposit) :: deposit request + withdrawal-settlements=(z-map nname:t withdrawal-settlement) + prev=nock-hash + == + ++ hashable + |= =form + ^- hashable:tip5 + :* [%leaf %nock] + [%leaf version.form] + [%leaf height.form] + [%hash block-id.form] + (hashable-deposits:deposit deposits.form) + (hashable-withdrawal-settlements:withdrawal-settlement withdrawal-settlements.form) + [%hash prev.form] + == + :: + ++ hash + |= =form + %- hash-hashable:tip5 + (hashable form) + -- +::: +++ base-blocks + =< form + |% + +$ form + $+ base-blocks + $: %base + version=%0 + first-height=@ + last-height=@ + ::>) TODO: check the sequence of hashes and that the parent of the + :: first block in a new batch matches the last block in the previous batch + blocks=(z-map @ [bid=bbid parent=bbid]) + withdrawals=(z-map beid withdrawal) + deposit-settlements=(z-map beid deposit-settlement) + prev=base-hash + == + ++ hashable-blocks + |= blk-map=(z-map @ [bid=bbid parent=bbid]) + ^- hashable:tip5 + %- hashable-block-list + ~(tap z-by blk-map) + ++ hashable-block-list + |= entries=(list [@ [bid=bbid parent=bbid]]) + ^- hashable:tip5 + ?~ entries leaf+~ + :- (hashable-block-pair i.entries) + $(entries t.entries) + ++ hashable-block-pair + |= (pair @ [bid=bbid parent=bbid]) + ^- hashable:tip5 + :: blist elements are already based, safe to use directly + :* leaf+p + (hashable:bbid bid.q) + (hashable:bbid parent.q) + == + ++ hashable + |= =form + ^- hashable:tip5 + :* + [%leaf %base] + [%leaf version.form] + [%leaf first-height.form] + [%leaf last-height.form] + (hashable-blocks:base-blocks blocks.form) + (hashable-withdrawals:withdrawal withdrawals.form) + (hashable-deposit-settlements:deposit-settlement deposit-settlements.form) + [%hash prev.form] + == + ++ hash + |= =form + %- hash-hashable:tip5 + (hashable form) + :: + ++ first-block + |= =form + (~(got z-by blocks.form) first-height.form) + :: + ++ last-block + |= =form + (~(got z-by blocks.form) last-height.form) + :: + ++ nth-block + |= [=form n=@] + ?> ?& (gte n first-height.form) + (lte n last-height.form) + == + (~(got z-by blocks.form) n) + -- +::: +++ deposit + =< form + |% + +$ form + $+ deposit + $: =tx-id + =nname + dest=(unit base-addr) + amount-to-mint=coins + fee=coins + == + ++ hashable + |= =form + ^- hashable:tip5 + :* hash+tx-id.form + (hashable:nname nname.form) + (hashable-dest dest.form) + leaf+amount-to-mint.form + leaf+fee.form + == + ++ hashable-dest + |= dest=(unit base-addr) + ^- hashable:tip5 + ?~ dest + leaf+~ + leaf+(evm-address-to-based u.dest) + ++ hashable-deposits + |= mp=(z-map nname form) + ^- hashable:tip5 + %- hashable-deposit-list + ~(tap z-by mp) + ++ hashable-deposit-list + |= entries=(list [nname form]) + ^- hashable:tip5 + ?~ entries leaf+~ + :- (hashable-deposit-pair i.entries) + $(entries t.entries) + ++ hashable-deposit-pair + |= (pair nname form) + ^- hashable:tip5 + :* (hashable:nname p) + (hashable q) + == + ++ hash + |= =form + %- hash-hashable:tip5 + (hashable form) + -- +++ withdrawal + =< form + |% + +$ form + $+ withdrawal + $: =beid + dest=nock-addr + amount-burned=coins + fee=coins + == + ++ hashable + |= =form + ^- hashable:tip5 + ::>) TODO: check hashable + :* (hashable:beid beid.form) + hash+dest.form + leaf+amount-burned.form + leaf+fee.form + == + ++ hashable-withdrawals + |= mp=(z-map beid form) + ^- hashable:tip5 + %- hashable-withdrawal-list + ~(tap z-by mp) + ++ hashable-withdrawal-list + |= entries=(list [beid form]) + ^- hashable:tip5 + ?~ entries leaf+~ + :- (hashable-withdrawal-pair i.entries) + $(entries t.entries) + ++ hashable-withdrawal-pair + |= (pair beid form) + ^- hashable:tip5 + :* (hashable:beid p) + (hashable q) + == + ++ hash + |= =form + %- hash-hashable:tip5 + (hashable form) + -- +:: +++ bridge-fee + =< form + |% + +$ form + $+ bridge-fee @ + ++ calculate + |= [nicks=@ nicks-fee-per-nock=@] + ^- @ + =/ [nocks=@ nicks=@] (dvr nicks nicks-per-nock:t) + :: round up to the nearest nock if there is a remainder + =? nocks (gth nicks 0) + +(nocks) + (mul nicks-fee-per-nock nocks) + -- +::: +:: data format for bridge deposit, stored under %bridge key in note-data map. ++$ bridge-deposit-data + $: %0 + [%base addr=evm-address-based] + == +:: ++$ deposit-intent [name=nname recipient=(unit evm-address) amount-to-mint=coins fee=coins] +:: +++ deposit-settlement + =< form + |% + +$ form + $+ deposit-settlement + [=beid data-part] + :: + +$ data-part + $: counterpart=nname:t + as-of=nock-hash + nock-height=@ + dest=base-addr + settled-amount=coins + nonce=@ + == + ++ hashable + |= =form + ^- hashable:tip5 + :* (hashable:beid beid.form) + (hashable:nname counterpart.form) + hash+as-of.form + leaf+nock-height.form + leaf+(evm-address-to-based dest.form) + leaf+settled-amount.form + leaf+nonce.form + == + ++ hashable-deposit-settlements + |= mp=(z-map beid form) + ^- hashable:tip5 + %- hashable-settlement-list + ~(tap z-by mp) + ++ hashable-settlement-list + |= entries=(list [beid form]) + ^- hashable:tip5 + ?~ entries leaf+~ + :- (hashable-settlement-pair i.entries) + $(entries t.entries) + ++ hashable-settlement-pair + |= (pair beid form) + ^- hashable:tip5 + :* (hashable:beid p) + (hashable q) + == + ++ hash + |= =form + %- hash-hashable:tip5 + (hashable form) + -- +::: +++ withdrawal-settlement + =< form + |% + +$ form + $+ withdrawal-settlement + $: =tx-id + =nname + counterpart=beid + as-of=base-hash + dest=nock-addr + settled-amount=coins + nock-tx-fee=coins + == + ++ hashable + |= =form + ^- hashable:tip5 + :* hash+tx-id.form + (hashable:nname nname.form) + (hashable:beid counterpart.form) + hash+as-of.form + hash+dest.form + leaf+settled-amount.form + leaf+nock-tx-fee.form + == + ++ hashable-withdrawal-settlements + |= mp=(z-map nname form) + ^- hashable:tip5 + %- hashable-settlement-list + ~(tap z-by mp) + ++ hashable-settlement-list + |= entries=(list [nname form]) + ^- hashable:tip5 + ?~ entries leaf+~ + :- (hashable-settlement-pair i.entries) + $(entries t.entries) + ++ hashable-settlement-pair + |= (pair nname form) + ^- hashable:tip5 + :* (hashable:nname p) + (hashable q) + == + ++ hash + |= =form + %- hash-hashable:tip5 + (hashable form) + -- +:: +active-proposer: determine which node should propose +:: +:: computes which bridge node should propose the bundle at a given +:: block height. uses deterministic ordering by sorting node ids +:: by their nockchain public keys, then rotating based on height +:: modulo 5. ensures all nodes agree on proposer at any height. +:: +++ active-proposer + |= [height=@ud config=node-config] + ^- @ud + =/ node-pairs=(list [@ud node-info]) + %+ turn (gulf 0 4) + |= idx=@ud + [idx (snag idx nodes.config)] + =/ node-map=(z-map @ud node-info) + (z-malt node-pairs) + =/ sorted-pubkeys=(list @ud) + %+ sort (gulf 0 4) + |= [a=@ud b=@ud] + =/ node-a=node-info (~(got z-by node-map) a) + =/ node-b=node-info (~(got z-by node-map) b) + =/ pkh-a=@t (to-b58:hash:t nock-pkh.node-a) + =/ pkh-b=@t (to-b58:hash:t nock-pkh.node-b) + (lth pkh-a pkh-b) + =+ rotation=(mod height 5) + (snag rotation sorted-pubkeys) +:: +:: +active-verifiers: determine primary verification nodes +:: +:: returns the two nodes immediately after the proposer in the +:: rotation order. these nodes have primary responsibility for +:: verification, though all nodes can sign. uses same deterministic +:: ordering as active-proposer to ensure byzantine fault tolerance. +:: +++ active-verifiers + |= [height=@ud config=node-config] + ^- (list @ud) + =/ node-pairs=(list [@ud node-info]) + %+ turn (gulf 0 4) + |= idx=@ud + [idx (snag idx nodes.config)] + =/ node-map=(z-map @ud node-info) + (malt node-pairs) + =/ sorted-pubkeys=(list @ud) + %+ sort (gulf 0 4) + |= [a=@ud b=@ud] + =/ node-a=node-info (~(got z-by node-map) a) + =/ node-b=node-info (~(got z-by node-map) b) + =/ pkh-a=@t (to-b58:hash:t nock-pkh.node-a) + =/ pkh-b=@t (to-b58:hash:t nock-pkh.node-b) + (lth pkh-a pkh-b) + =+ rotation=(mod height 5) + =/ proposer=@ud (snag rotation sorted-pubkeys) + =/ verifier-1=@ud (snag (add rotation 1) sorted-pubkeys) + =/ verifier-2=@ud (snag (add rotation 2) sorted-pubkeys) + :~ verifier-1 + verifier-2 + == +:: +:: +is-my-turn: check if this node should propose +:: +:: returns %.y if this node is the active proposer at the given +:: height, %.n otherwise. used by nodes to determine when to +:: create bundle proposals from pending deposits. +:: +++ is-my-turn + |= [height=@ud config=node-config] + ^- ? + =/ proposer=@ud (active-proposer height config) + ~& [%is-my-turn-check height=height node-id=node-id.config active-proposer=proposer rotation=(mod height 5)] + =(node-id.config proposer) +:: +:: +is-verifier: check if this node is a primary verifier +:: +:: returns %.y if this node is one of the two primary verifiers +:: at the given height. verifiers have first responsibility to +:: validate and sign proposals, though all nodes may participate. +:: +++ is-verifier + |= [height=@ud config=node-config] + ^- ? + %+ lien (active-verifiers height config) + |= id=@ud + =(id node-id.config) +:: +:: +get-node-by-id: retrieve node info by id +:: +:: looks up a node's information by its id (0-4). returns ~ +:: if id is out of bounds. used for routing grpc messages +:: and signature verification. +:: +++ get-node-by-id + |= [config=node-config id=@ud] + ^- (unit node-info) + ?: (lth id (lent nodes.config)) + `(snag id nodes.config) + ~ +:: +:: +evm-address-to-based: convert evm address to based field +:: +:: converts a 20-byte ethereum address to a list of base field +:: elements (belts) for storage in note-data. splits the 160-bit +:: address into 3 base field elements of characteristic p = 2^64 - 2^32 + 1 +:: by converting the address to a base-p number. this ensures all data in note-data +:: uses based arithmetic. +:: +++ evm-address-to-based + |= addr=evm-address + ^- evm-address-based + =/ [q=@ a=@] (dvr addr p) + =/ [q=@ b=@] (dvr q p) + =/ [q=@ c=@] (dvr q p) + ?. =(q 0) ~| %invalid-evm-address !! + [a b c] +:: +:: +based-to-evm-address: convert based field to evm address +:: +:: converts a list of base field elements back to a 20-byte +:: ethereum address. takes exactly three chunks, +:: then reconstructs the address from the 32-bit chunks. inverse +:: of evm-address-to-based. +:: +++ based-to-evm-address + |= addr=evm-address-based + ^- evm-address + =+ [a=@ux b=@ux c=@ux]=addr + ?. ?& (based a) + (based b) + (based c) + == + ~| %evm-address-has-not-based-entries !! + =/ p2 (mul p p) + :(add a (mul p b) (mul p2 c)) +:: +++ cause + =< form + |% + +$ form + $+ cause + $: %0 + $% [%cfg-load config=(unit node-config)] + [%set-constants constants=bridge-constants] + [%base-blocks raw-base-blocks] + [%nockchain-block nockchain-block] + [%proposed-base-call proposed-base-call] + [%proposed-nock-tx proposed-nock-tx] :: TODO: fill in when we do withdrawals + [%stop last=stop-info] + [%start ~] + == + == + :: + +$ raw-base-blocks (list [height=@ block-id=base-block-id parent-block-id=base-block-id txs=(list base-event)]) + :: + +$ nockchain-block [block=page:t txs=(z-map tx-id:t tx:t)] + :: + +$ proposed-base-call (list eth-signature-request:effect) + :: + +$ proposed-nock-tx raw-tx:t + :: + +$ base-call-sig [sig=eth-signature data=@] + -- +:: +++ effect + =< form + |% + +$ form + $+ effect + $: %0 + $% [%base-call base-call] + [%assemble-base-call data-part:deposit-settlement] + [%eth-signature-request eth-signature-request] + :: broadcast signature requests to peer nodes for signing + [%propose-base-call reqs=(list eth-signature-request)] + [%nockchain-tx nockchain-tx] + [%propose-nockchain-tx propose-nockchain-tx] + [%grpc grpc-effect] + [%stop reason=cord last=stop-info] + == + == + :: + +$ base-call [sigs=(list eth-signature) data=@] + :: + :: bytes32 (nockchain) txId, + :: bytes name, + :: address recipient, + :: uint256 amount, + :: uint256 blockHeight, + :: bytes32 asOf, + :: + :: proposal hash is computed as: + :: keccak256(abi.encode(txId, name, recipient, amount, blockHeight, asOf)) + :: + +$ eth-signature-request + [tx-id=tx-id:t name=nname:t recipient=base-addr amount=@ block-height=@ as-of=nock-hash nonce=@] + :: + +$ propose-base-call [peer=nock-pubkey req=eth-signature-request] + :: + +$ nockchain-tx [tx=raw-tx:t] + :: + +$ propose-nockchain-tx [peer=nock-pubkey data=@] + :: + +$ grpc-effect + $% [%peek pid=@ typ=@tas =path] + [%call ip=@t method=@tas data=*] + == + -- +-- diff --git a/hoon/apps/wallet/lib/tx-builder.hoon b/hoon/apps/wallet/lib/tx-builder.hoon index 7e1b5b238..6a5236cab 100644 --- a/hoon/apps/wallet/lib/tx-builder.hoon +++ b/hoon/apps/wallet/lib/tx-builder.hoon @@ -1,9 +1,14 @@ /= transact /common/tx-engine -/= utils /apps/wallet/lib/utils +/= wutils /apps/wallet/lib/utils /= wt /apps/wallet/lib/types /= zo /common/zoon +/= bridge /apps/bridge/types :: -:: Builds a fan-in transaction that can emit both simple PKH and multisig locks. +:: Builds m-to-n transaction that can emit both simple PKH and multisig locks. +|_ bc=blockchain-constants:transact ++* t ~(. transact bc) + utils ~(. wutils %.y bc) +++ build |= $: names=(list nname:transact) orders=(list order:wt) fee=coins:transact @@ -357,8 +362,9 @@ == ^- [seeds:v1:transact output-lock-map:wt] =; [seeds=(list seed:v1:transact) total-gifts=@ =output-lock-map:wt] - ~| "assets in must equal gift + fee + refund" - ?> =(assets.note (add total-gifts fee-portion)) + ?. =(assets.note (add total-gifts fee-portion)) + ~& [assets+assets.note total-gifts+total-gifts fee-portion+fee-portion] + ~| "assets in must equal gift + fee + refund" !! [(z-silt:zo seeds) output-lock-map] %+ roll specs |= $: spec=order:wt @@ -366,23 +372,33 @@ gifts=@ =output-lock-map:wt == - =/ output-lock=lock:transact (order-lock spec) + =/ metadata=lock-metadata-1:wt (extract-metadata spec) =? include-data ?=(%multisig -.spec) %.y - =/ nd=note-data:v1:transact - ?. include-data - ~ - %- ~(put z-by:zo *note-data:v1:transact) - [%lock ^-(lock-data:wt [%0 output-lock])] + =| nd=note-data:v1:transact + =/ [lock-root=hash:transact nd=note-data:v1:transact] + ?- -.+.metadata + %lock + =? nd include-data + %- ~(put z-by:zo nd) + [%lock ^-(lock-data:wt [%0 lock.metadata])] + [(hash:lock:transact lock.metadata) nd] + :: + %lock-root + [root.metadata nd] + :: + %bridge-deposit + :- root.metadata + %- ~(put z-by:zo nd) + [%bridge [%0 %base (evm-address-to-based:bridge addr.metadata)]] + == =/ seed=seed:v1:transact :* output-source=~ - lock-root=(hash:lock:transact output-lock) + lock-root=lock-root note-data=nd gift=(order-gift spec) parent-hash=(hash:nnote:transact note) == - =/ metadata=lock-metadata:wt - [output-lock include-data] :* [seed seeds] (add gifts (order-gift spec)) %- ~(put z-by:zo output-lock-map) @@ -394,8 +410,11 @@ ^- (reason:transact ~) ?: =(0 (lent orders)) [%.n 'cannot create transaction with no orders'] + =| num-bridge-orders=@ |- ?~ orders + ?: (gth num-bridge-orders 1) + [%.n %bridge-orders-exceeds-one] [%.y ~] =/ ord=order:wt i.orders ?- -.ord @@ -419,6 +438,16 @@ ?: =(0 gift.ord) [%.n 'order must include a gift greater than 0'] $(orders t.orders) + :: + %bridge-deposit + ?. (gte gift.ord *minimum-event-nocks:bridge) + [%.n %gift-amount-is-less-than-minimum-bridge-deposit] + $(orders t.orders, num-bridge-orders +(num-bridge-orders)) + :: + %lock-root + ?: =(0 gift.ord) + [%.n %gift-cannot-be-zero] + $(orders t.orders) == :: ++ order-gift @@ -427,27 +456,39 @@ ?- -.ord %pkh gift.ord %multisig gift.ord + %lock-root gift.ord + %bridge-deposit gift.ord == :: ++ with-gift |= [ord=order:wt gift=coins:transact] ^- order:wt ?- -.ord - %pkh [%pkh recipient=recipient.ord gift=gift] - %multisig [%multisig threshold=threshold.ord participants=participants.ord gift=gift] + %pkh ord(gift gift) + %multisig ord(gift gift) + %lock-root ord(gift gift) + %bridge-deposit ord(gift gift) == :: -++ order-lock +++ extract-metadata |= ord=order:wt - ^- lock:transact + ^- lock-metadata-1:wt + :- %1 ?- -.ord + %bridge-deposit + [%bridge-deposit root=bridge-lock-root-default:bridge addr=address.ord] + :: + %lock-root + [%lock-root root=root.ord] + :: %pkh - [%pkh [m=1 (z-silt:zo ~[recipient.ord])]]~ + [%lock [%pkh [m=1 (z-silt:zo ~[recipient.ord])]]~ include-data] + :: %multisig - =/ participants=(list hash:transact) participants.ord - =/ allowed=(z-set:zo hash:transact) (z-silt:zo participants) - [%pkh [m=threshold.ord allowed]]~ - == + =/ participants=(list hash:transact) participants.ord + =/ allowed=(z-set:zo hash:transact) (z-silt:zo participants) + [%lock [%pkh [m=threshold.ord allowed]]~ include-data=%.y] + == :: ++ order-from-lock |= [lok=lock:transact gift=@] @@ -492,3 +533,4 @@ `pulled :: -- +-- diff --git a/hoon/apps/wallet/lib/types.hoon b/hoon/apps/wallet/lib/types.hoon index 0ef8df5e5..5f298604e 100644 --- a/hoon/apps/wallet/lib/types.hoon +++ b/hoon/apps/wallet/lib/types.hoon @@ -1,9 +1,11 @@ /= transact /common/tx-engine /= zo /common/zoon /= * /common/zose +/= bridge /apps/bridge/types /= dumb /apps/dumbnet/lib/types /= s10 /apps/wallet/lib/s10 -|% +|_ bc=blockchain-constants:transact ++* t ~(. transact bc) :: $key: public or private key :: :: both private and public keys are in serialized cheetah point form @@ -74,13 +76,13 @@ %0 =/ key=schnorr-pubkey:transact (from-ser:schnorr-pubkey:transact p.key.form) - (coinbase:v0:first-name:transact key) + (coinbase:v0:first-name:t key) :: %1 =/ key-hash=hash:transact %- hash:schnorr-pubkey:transact (from-ser:schnorr-pubkey:transact p.key.form) - (coinbase:v1:first-name:transact key-hash) + (coinbase:v1:first-name:t key-hash) == :: ++ simple-first-name @@ -341,6 +343,14 @@ keys=keys-v4 == :: + +$ state-5 + $: %5 + balance=balance-v4 + active-master=active-v4 + keys=keys-v4 + bc=blockchain-constants:transact + == + :: :: $versioned-state: wallet state :: +$ versioned-state @@ -349,9 +359,10 @@ state-2 state-3 state-4 + state-5 == :: - +$ state $>(%4 versioned-state) + +$ state $>(%5 versioned-state) :: +$ seed-name $~('default-seed' @t) :: @@ -359,19 +370,24 @@ :: +$ input-name $~('default-input' @t) :: +:: ++ order =< form |% +$ form $% [%pkh recipient=hash:transact gift=coins:transact] [%multisig threshold=@ participants=(list hash:transact) gift=coins:transact] + [%lock-root root=hash:transact gift=coins:transact] + [%bridge-deposit address=evm-address:bridge gift=coins:transact] == ++ gift |= =form ^- coins:transact ?- -.form - %pkh gift.form - %multisig gift.form + %pkh gift.form + %multisig gift.form + %lock-root gift.form + %bridge-deposit gift.form == -- :: @@ -432,6 +448,7 @@ dat=transaction sign-keys=(unit (list [child-index=@ud hardened=?])) == + [%fakenet ~] == +$ file-cause $% [%write path=@t contents=@t success=?] @@ -475,10 +492,17 @@ [%1 p=(z-map:zo nname:transact sc=spend-condition:transact)] == :: - +$ lock-metadata - $: lock=lock:transact - include-data=? + +$ lock-metadata-0 [=lock:transact include-data=?] + +$ lock-metadata-1 + $: %1 + $% [%lock =lock:transact include-data=?] + [%lock-root root=hash:transact] + [%bridge-deposit root=hash:transact addr=evm-address:bridge] + == == + +$ lock-metadata + $^ lock-metadata-0 + lock-metadata-1 :: +$ output-lock-map (z-map:zo hash:transact lock-metadata) :: diff --git a/hoon/apps/wallet/lib/utils.hoon b/hoon/apps/wallet/lib/utils.hoon index 0e7f6793a..de9b5fb03 100644 --- a/hoon/apps/wallet/lib/utils.hoon +++ b/hoon/apps/wallet/lib/utils.hoon @@ -6,7 +6,8 @@ /= * /common/zose /= * /common/zeke /= wt /apps/wallet/lib/types -|_ bug=? +|_ [bug=? bc=blockchain-constants:transact] ++* t ~(. transact bc) :: :: print helpers ++ warn @@ -120,7 +121,7 @@ =/ simple-lock [(simple-pkh-lp:v1:first-name:transact u.pkh)]~ ?: =((first:nname:transact (hash:lock:transact simple-lock)) -.nn) (some simple-lock) - =/ coinbase-lock (coinbase-pkh-sc:v1:first-name:transact u.pkh) + =/ coinbase-lock (coinbase-pkh-sc:v1:first-name:t u.pkh) ?: =((first:nname:transact (hash:lock:transact coinbase-lock)) -.nn) (some coinbase-lock) ~> %slog.[2 'unsupported lock type'] @@ -496,6 +497,11 @@ |= @ ^- @t (rsh [3 2] (scot %ui +<)) + ++ format-ux + |= @ux + ^- @t + %- crip + (z-co:co +<) :: ++ poke |= =cause:wt @@ -632,8 +638,6 @@ ++ lock-primitive |= prim=lock-primitive:transact ^- cord - =; txt=@t - (cat 3 txt '\0a---') ?- -.prim %pkh =/ participants=(list hash:transact) ~(tap z-in:zo h.prim) @@ -672,9 +676,9 @@ ?~ min.abs.prim 'N/A' (format-ui:common u.min.abs.prim) =/ abs-max=@t - ?~ max.abs.prim 'N/A' %^ cat 3 '\0a - Max Absolute Height: ' + ?~ max.abs.prim 'N/A' (format-ui:common u.max.abs.prim) ;: (cury cat 3) '\0a - Time Lock' @@ -733,14 +737,39 @@ ++ lock-metadata |= data=lock-metadata:wt ^- @t - =/ cond=(unit spend-condition:transact) - ((soft spend-condition:transact) lock.data) - ?~ cond - '\0a - Lock data not displayable' - ;: (cury cat 3) - '\0a - Lock data included in note: ' - (bool-text include-data.data) - (spend-condition u.cond) + =? data ?=(^ -.data) + [%1 %lock data] + ?> ?=(@ -.data) + ?- -.+.data + %lock + =/ cond=(unit spend-condition:transact) + ((soft spend-condition:transact) lock.data) + ?~ cond + '\0a - Lock data not displayable' + ;: (cury cat 3) + '\0a - Lock data included in note: ' + (bool-text include-data.data) + (spend-condition u.cond) + == + :: + %lock-root + ;: (cury cat 3) + '\0a - Lock data included in note: ' + (bool-text %.n) + '\0a - Assets should go to lock script root: ' + (to-b58:hash:transact root.data) + == + :: + %bridge-deposit + ;: (cury cat 3) + '\0a - Lock data included in note: ' + (bool-text %.n) + '\0a - Bridge Deposit: ' + '\0a - Assets should go to lock script root (this should be bridge operator lock root): ' + (to-b58:hash:transact root.data) + '\0a - EVM recipient address (tokens should mint to this address): ' + (format-ux:common addr.data) + == == :: ++ note-from-balance @@ -888,7 +917,8 @@ "\0a{(trip (note-from-output out-note metadata))}" %- crip """ - ## Transaction Information + + ### Transaction Information - Name: {(trip name)} - Fee: {(trip (format-ui:common fees))} @@ -976,4 +1006,10 @@ ^- tape %- trip (rsh [3 2] (scot %ui +<)) + :: + ++ ux-to-tape + |= @ + ^- tape + %- trip + (rsh [3 2] (scot %ux +<)) -- diff --git a/hoon/apps/wallet/wallet.hoon b/hoon/apps/wallet/wallet.hoon index fc7024186..1c963b35a 100644 --- a/hoon/apps/wallet/wallet.hoon +++ b/hoon/apps/wallet/wallet.hoon @@ -6,9 +6,10 @@ /= z /common/zeke /= zo /common/zoon /= dumb /apps/dumbnet/lib/types +/= bridge /apps/bridge/types /= * /common/zose /= * /common/wrapper -/= wt /apps/wallet/lib/types +/= wallet-types /apps/wallet/lib/types /= wutils /apps/wallet/lib/utils /= tx-builder /apps/wallet/lib/tx-builder /= s10 /apps/wallet/lib/s10 @@ -16,26 +17,24 @@ =| bug=_& |% :: -:: re-exporting names from wallet types while passing the bug flag -++ utils ~(. wutils bug) -++ debug debug:utils -++ warn warn:utils -++ moat (keep state:wt) +++ moat (keep state:wallet-types) -- :: %- (moat &) ^- fort:moat -|_ =state:wt -+* v ~(. vault:utils state) - d ~(. draw:utils state) - p ~(. plan:utils transaction-tree.state) +|_ =state:wallet-types ++* utils ~(. wutils bug bc.state) + v ~(. vault:utils state) + debug debug:utils + warn warn:utils + wt ~(. wallet-types bc.state) :: ++ load |= old=versioned-state:wt ^- state:wt |^ |- - ?: ?=(%4 -.old) + ?: ?=(%5 -.old) old ~> %slog.[0 'load: State upgrade required'] ?- -.old @@ -43,6 +42,7 @@ %1 $(old state-1-2) %2 $(old state-2-3) %3 $(old state-3-4) + %4 $(old state-4-5) == :: ++ state-0-1 @@ -109,7 +109,7 @@ == :: ++ state-3-4 - ^- state:wt + ^- state-4:wt ?> ?=(%3 -.old) ~> %slog.[0 'upgrade version 3 to 4'] :* %4 @@ -118,6 +118,17 @@ active-master.old keys.old == + :: + ++ state-4-5 + ^- state:wt + ?> ?=(%4 -.old) + ~> %slog.[0 'upgrade version 4 to 5'] + :* %5 + balance.old + active-master.old + keys.old + *blockchain-constants:transact + == -- :: ++ peek @@ -126,6 +137,8 @@ %- (debug "peek: {}") =/ =(pole) arg ?+ pole ~ + [%fakenet ~] + ``!=(bc.state *blockchain-constants:transact) :: [%balance ~] ``balance.state @@ -226,6 +239,7 @@ %show-master-zprv (do-show-master-zprv cause) %list-master-addresses (do-list-master-addresses cause) %set-active-master-address (do-set-active-master-address cause) + %fakenet [[%exit 0]~ state(bc bc.state(coinbase-timelock-min 1))] :: %file ?- +<.cause @@ -1269,7 +1283,7 @@ |= key-info=[child-index=@ud hardened=?] (sign-key:get:v [~ key-info]) =/ [=spends:v1:transact =witness-data:wt display=transaction-display:wt] - %: tx-builder + %: ~(build tx-builder bc.state) names orders fee.cause @@ -1279,8 +1293,8 @@ include-data.cause selection-strategy.cause == - =/ multisig-recv-locks=(z-set:zo lock:transact) - (gather-multisig-locks orders) + =/ lock-roots-to-watch=(z-set:zo [hash:transact (unit lock:transact)]) + (gather-watch-roots orders) =/ transaction-name=@t %- to-b58:hash:transact id:(new:raw-tx:v1:transact spends) @@ -1293,10 +1307,10 @@ == =/ res=effects=(list effect:wt) (save-transaction transaction) - ?: ?=(~ multisig-recv-locks) + ?: ?=(~ lock-roots-to-watch) [effects.res state] :- effects.res - state(keys (watch-multisig-locks multisig-recv-locks)) + state(keys (watch-root-locks lock-roots-to-watch)) :: ++ parse-names |= raw-names=(list [first=@t last=@t]) @@ -1354,9 +1368,9 @@ == ~[write-effect [%markdown markdown-text]] :: - ++ gather-multisig-locks + ++ gather-watch-roots |= orders=(list order:wt) - ^- (z-set:zo lock:transact) + ^- (z-set:zo [hash:transact (unit lock:transact)]) %- z-silt:zo %+ murn orders |= ord=order:wt @@ -1365,16 +1379,25 @@ :: %multisig =/ allowed=(z-set:zo hash:transact) (z-silt:zo participants.ord) - `[%pkh [m=threshold.ord allowed]]~ + =/ lock [%pkh [m=threshold.ord allowed]]~ + %- some + :- (hash:lock:transact lock) + `lock + :: + %lock-root + `[root.ord ~] + :: + %bridge-deposit + `[bridge-lock-root-default:bridge ~] == :: - ++ watch-multisig-locks - |= locks=(z-set:zo lock:transact) + ++ watch-root-locks + |= roots=(z-set:zo [hash:transact (unit lock:transact)]) ^- keys:wt - %- ~(rep z-in:zo locks) - |= [lock=lock:transact acc=_keys.state] + %- ~(rep z-in:zo roots) + |= [[root=hash:transact lock=(unit lock:transact)] acc=_keys.state] %- watch-first-name:put:v - [(first:nname:transact (hash:lock:transact lock)) `lock] + [(first:nname:transact root) lock] :: -- :: diff --git a/hoon/common/hoon.hoon b/hoon/common/hoon.hoon new file mode 120000 index 000000000..57336a8ab --- /dev/null +++ b/hoon/common/hoon.hoon @@ -0,0 +1 @@ +../../crates/hoonc/hoon/hoon-138.hoon \ No newline at end of file diff --git a/hoon/common/tx-engine.hoon b/hoon/common/tx-engine.hoon index a15abfe68..488e28ffb 100644 --- a/hoon/common/tx-engine.hoon +++ b/hoon/common/tx-engine.hoon @@ -13,6 +13,7 @@ ++ quadruple-ted ^~((mul target-epoch-duration 4)) ++ genesis-target ^~((chunk:bignum genesis-target-atom)) ++ max-target ^~((chunk:bignum max-target-atom)) +++ nicks-per-nock ^~((bex 16)) :: ++ bignum bignum:v0 ++ block-commitment block-commitment:v0 @@ -108,6 +109,7 @@ ++ lock lock:v1 ++ lock-primitive lock-primitive:v1 ++ nname nname:v1 +++ note-data note-data:v1 ++ page-msg page-msg:v0 ++ page-number page-number:v0 ++ page-summary page-summary:v0 diff --git a/rust-toolchain.toml b/rust-toolchain.toml index b7bfaee21..c094b3aa6 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "nightly-2025-02-14" +channel = "nightly-2025-11-26" components = ["miri"] From ede35f2f3146f64f6e57c0d154d0114f76818dbf Mon Sep 17 00:00:00 2001 From: tacryt-socryp <1377557+tacryt-socryp@users.noreply.github.com> Date: Sun, 21 Dec 2025 08:37:41 -0600 Subject: [PATCH 35/99] bridge, nockchain-wallet: correctly enforce 100k nock minimum bridge deposit --- hoon/apps/bridge/nock.hoon | 2 +- hoon/apps/wallet/lib/tx-builder.hoon | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/hoon/apps/bridge/nock.hoon b/hoon/apps/bridge/nock.hoon index a0b69f835..2651e34c5 100644 --- a/hoon/apps/bridge/nock.hoon +++ b/hoon/apps/bridge/nock.hoon @@ -218,7 +218,7 @@ $(outputs-list t.outputs-list) ~& output-note+note.out =/ =note-data:t note-data.note.out - ?: (lth assets.note.out minimum-event-nocks.constants.state) + ?: (lth assets.note.out (mul minimum-event-nocks.constants.state nicks-per-nock:t)) ~> %slog.[0 'deposit-does-not-meet-minimum-requirement'] $(outputs-list t.outputs-list) ?: ?& (~(has z-by note-data) %bridge) diff --git a/hoon/apps/wallet/lib/tx-builder.hoon b/hoon/apps/wallet/lib/tx-builder.hoon index 6a5236cab..164af1982 100644 --- a/hoon/apps/wallet/lib/tx-builder.hoon +++ b/hoon/apps/wallet/lib/tx-builder.hoon @@ -440,7 +440,7 @@ $(orders t.orders) :: %bridge-deposit - ?. (gte gift.ord *minimum-event-nocks:bridge) + ?. (gte gift.ord (mul *minimum-event-nocks:bridge nicks-per-nock:transact)) [%.n %gift-amount-is-less-than-minimum-bridge-deposit] $(orders t.orders, num-bridge-orders +(num-bridge-orders)) :: From 6e6d6bcaf19f06c1909320bfad188c12cd97413a Mon Sep 17 00:00:00 2001 From: tacryt-socryp <1377557+tacryt-socryp@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:32:56 -0600 Subject: [PATCH 36/99] nockup release --- crates/hoonc/src/lib.rs | 35 ++- crates/nockup/Cargo.toml | 2 +- crates/nockup/README.md | 68 +++-- crates/nockup/scripts/build-registry.sh | 57 ++++ crates/nockup/src/cli.rs | 25 +- crates/nockup/src/commands/build/build.rs | 46 ++- crates/nockup/src/commands/build/init.rs | 3 +- crates/nockup/src/commands/build/mod.rs | 5 +- crates/nockup/src/commands/build/run.rs | 36 ++- crates/nockup/src/commands/cache/clear.rs | 146 +++++++++ crates/nockup/src/commands/cache/mod.rs | 17 ++ crates/nockup/src/commands/init.rs | 4 +- crates/nockup/src/commands/mod.rs | 1 + crates/nockup/src/commands/package/install.rs | 287 ++++++++++-------- crates/nockup/src/commands/package/list.rs | 8 +- crates/nockup/src/commands/run.rs | 8 - crates/nockup/src/main.rs | 7 +- crates/nockup/src/manifest.rs | 2 +- crates/nockup/src/resolver/engine.rs | 74 +++-- crates/nockup/src/resolver/types.rs | 2 +- crates/nockup/templates/basic/manifest.toml | 12 - crates/nockup/templates/grpc/Cargo.toml | 10 +- crates/nockup/templates/grpc/manifest.toml | 12 - .../templates/http-server/manifest.toml | 12 - .../templates/http-static/manifest.toml | 12 - crates/nockup/templates/repl/manifest.toml | 12 - crates/nockup/tests/cli_tests.rs | 258 ---------------- 27 files changed, 597 insertions(+), 564 deletions(-) create mode 100755 crates/nockup/scripts/build-registry.sh create mode 100644 crates/nockup/src/commands/cache/clear.rs create mode 100644 crates/nockup/src/commands/cache/mod.rs delete mode 100644 crates/nockup/templates/basic/manifest.toml delete mode 100644 crates/nockup/templates/grpc/manifest.toml delete mode 100644 crates/nockup/templates/http-server/manifest.toml delete mode 100644 crates/nockup/templates/http-static/manifest.toml delete mode 100644 crates/nockup/templates/repl/manifest.toml delete mode 100644 crates/nockup/tests/cli_tests.rs diff --git a/crates/hoonc/src/lib.rs b/crates/hoonc/src/lib.rs index defbcdbc0..e4d403278 100644 --- a/crates/hoonc/src/lib.rs +++ b/crates/hoonc/src/lib.rs @@ -379,20 +379,28 @@ pub async fn initialize_hoonc_( initialize_hoonc_with_jammer::(entry, deps_dir, arbitrary, out, boot_cli).await } +const BLACKLISTED_DIRS: &[&str] = &["packages", "node_modules", ".git", "target"]; + pub fn is_valid_file_or_dir(entry: &DirEntry) -> bool { - let is_dir = entry - .metadata() - .unwrap_or_else(|_| { - panic!( - "Panicked at {}:{} (git sha: {:?})", - file!(), - line!(), - option_env!("GIT_SHA") - ) - }) - .is_dir(); + let metadata = entry.metadata().unwrap_or_else(|_| { + panic!( + "Panicked at {}:{} (git sha: {:?})", + file!(), + line!(), + option_env!("GIT_SHA") + ) + }); + + let is_dir = metadata.is_dir(); + let file_name = entry.file_name().to_str().unwrap_or(""); + + // Skip blacklisted directories + if is_dir && BLACKLISTED_DIRS.contains(&file_name) { + return false; + } - let is_valid = entry + // Whitelist valid file extensions + let is_valid_file = entry .file_name() .to_str() .map(|s| { @@ -400,7 +408,6 @@ pub fn is_valid_file_or_dir(entry: &DirEntry) -> bool { || s.ends_with(".hoon") || s.ends_with(".txt") || s.ends_with(".jam") - // Include common web asset types || s.ends_with(".html") || s.ends_with(".css") || s.ends_with(".js") @@ -410,7 +417,7 @@ pub fn is_valid_file_or_dir(entry: &DirEntry) -> bool { }) .unwrap_or(false); - is_dir || is_valid + is_dir || is_valid_file } #[instrument] diff --git a/crates/nockup/Cargo.toml b/crates/nockup/Cargo.toml index 6a26a2f56..384289aeb 100644 --- a/crates/nockup/Cargo.toml +++ b/crates/nockup/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nockup" -version = "0.5.0" +version = "1.0.0" edition = "2021" authors = [""] description = "A developer support framework for NockApp development" diff --git a/crates/nockup/README.md b/crates/nockup/README.md index df2c636de..0afebca08 100644 --- a/crates/nockup/README.md +++ b/crates/nockup/README.md @@ -252,11 +252,11 @@ In the `Cargo.toml` file, include both targets explicitly: ```toml [[bin]] name = "main1" -path = "src/bin/main1.rs" +path = "src/main1.rs" [[bin]] name = "main2" -path = "src/bin/main2.rs" +path = "src/main2.rs" ``` Nockup is opinionated here, and will match `hoon/app/main1.hoon`, etc., as kernels; that is, @@ -269,6 +269,11 @@ will produce both `target/release/main1` and `target/release/main2`. Projects which produce more than one binary cannot be used directly with `nockup project run` since more than one process must be started. This should be kept in mind when using templates which produce more than one binary (like `grpc`). +```sh +cargo run --release --bin main1 +cargo run --release --bin main2 +``` + #### Nockchain Interactions A Nockchain must be running locally in order to obtain chain state data. @@ -347,12 +352,15 @@ A complete library may be imported by omitting the `files` key: [dependencies.lagoon] git = "https://github.com/urbit/numerics" commit = "01905f364178958bb2d0c1a7ce009b6f3e68f737" -# Specify only the subdirectory within the repository; if no files, all will be included. +# Specify the subdirectory within the repository. path = "lagoon/desk" +# Keep the parts of the path you need to preserve. +files = ["lib/lagoon", "sur/lagoon"] [dependencies.math] git = "https://github.com/urbit/numerics" commit = "7c11c48ab3f21135caa5a4e8744a9c3f828f2607" +# Specify the subdirectory within the repository; if no files, all will be included. path = "libmath/desk" ``` @@ -364,6 +372,24 @@ which supplies these files (among others) in the following pattern: These are simply copied over from the source directory in the repository, so care should be taken to ensure that files with the same name do not conflict (such as `types.hoon`). +## Registry + +Nockup supports publishing and consuming Hoon libraries via a registry. A registry is a Git repository which contains a `registry.toml` file listing available packages. The standard registry is currently hosted at [Typhoon, `sigilante/typhoon`](https://github.com/sigilante/typhoon). + +To generate a registry file for your Hoon library, you may use the `scan-deps-v2.py` script included in the Nockup repository. This script scans a directory for Hoon files and their dependencies, then produces a registry-compatible TOML segment. + +```sh +python3 scan-deps-v2.py \ + --workspace nockchain \ + --root-path "hoon" \ + --git-url "https://github.com/nockchain/nockchain" \ + --ref "a19ad4dc" \ + --description "Nockchain standard library" \ + /path/to/nockchain/hoon/common +``` + +Developers should not commit symlinked upstream dependencies into their own repositories. Instead, they should list them in the `[dependencies]` section of their `nockapp.toml` manifest files and let them be automatically fetched by Nockup. + ### Channels Nockup can use the `stable` build of `hoon` and `hoonc`. (As of this release, there is not yet a `nightly` build, but we demonstrate its support here.) @@ -396,14 +422,14 @@ Nockup supports the following `nockup` commands. ### Operations - `nockup`: Print version information for Nockup and installed binaries. -- `nockup update`: Initialize and check for updates to binaries and templates. +- `nockup update`: Update Nockup toolchain binaries (hoon, hoonc, nockup) and templates. - `nockup help`: Print this message or the help of the given subcommand(s). ### Project -- `nockup build init`: Initialize a new NockApp project from a `.toml` config file. -- `nockup build`: Build a NockApp project using Cargo. -- `nockup build run`: Run a NockApp project. +- `nockup project init`: Initialize a new NockApp project from a `.toml` config file. +- `nockup project build`: Build a NockApp project using Cargo. +- `nockup project run`: Run a NockApp project. ### Channels @@ -416,24 +442,11 @@ Nockup supports the following `nockup` commands. - `nockup package list`: List installed Hoon libraries in a project. - `nockup package add`: Add a Hoon library to a project manifest at a particular version. - `nockup package remove`: Remove an installed Hoon library from a project. -- `nockup package purge`: Clear the package cache. -- `nockup package purge --dry-run`: Preview what would be deleted. +- `nockup package purge [--dry-run]`: Clear the package cache. -## Registry +### Cache -Nockup supports publishing and consuming Hoon libraries via a registry. A registry is a Git repository which contains a `registry.toml` file listing available packages. The standard registry is currently hosted at [Typhoon, `sigilante/typhoon`](https://github.com/sigilante/typhoon). - -To generate a registry file for your Hoon library, you may use the `scan-deps-v2.py` script included in the Nockup repository. This script scans a directory for Hoon files and their dependencies, then produces a registry-compatible TOML segment. - -```sh -python3 scan-deps-v2.py \ - --workspace nockchain \ - --root-path "hoon" \ - --git-url "https://github.com/nockchain/nockchain" \ - --ref "a19ad4dc" \ - --description "Nockchain standard library" \ - /path/to/nockchain/hoon/common -``` +- `nockup cache clear [--git --packages --registry --all]`: Clear the Nockup cache (more extensive than `nockup package purge`). ## Security @@ -483,14 +496,7 @@ Code building is a general-purpose computing process, like `eval`. You should n * `nockup test` to run unit tests * expand repertoire of templates - * list and ship appropriate Hoon libraries -* `nockup publish`/`nockup clone` (awaiting PKI/namespace) -* The current code handles a single `specific_file`. To support multiple files, the changes needed are: - 1. Change GitSpec struct to store files: `Option>` instead of file: `Option` - 2. Change ResolvedPackage struct to store source_files: `Option>` instead of source_file: `Option` - 3. Update resolver to pass all files from the manifest to `GitSpec` - 4. Update installer to loop through all files and create symlinks for each - The key change is in the install logic - instead of handling specific_file: `Option<&str>`, it needs to handle specific_files: `Option<&[String]>` and loop through them. +* `nockup package publish` (awaiting PKI/namespace) ## Contributor's Guide diff --git a/crates/nockup/scripts/build-registry.sh b/crates/nockup/scripts/build-registry.sh new file mode 100755 index 000000000..195ef67d0 --- /dev/null +++ b/crates/nockup/scripts/build-registry.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Build complete registry.toml from multiple workspaces + +set -e + +OUTPUT="registry.toml" + +# Registry header +cat > "$OUTPUT" << 'EOF' +# ============================================================================ +# Registry metadata +# ============================================================================ +[registry] +name = "typhoon" +version = "0.1.0" +description = "TYPical HOON package registry" +url = "https://github.com/sigilante/typhoon" + +[config] +default_ref = "latest" + +EOF + +echo "Building registry..." + +# Scan Urbit workspace (if available) +if [ -d "$HOME/.nockup/cache/git/urbit/urbit/pkg/arvo" ]; then + echo "Scanning urbit workspace..." + python3 scan-deps-v2.py \ + --workspace urbit \ + --root-path "pkg/arvo" \ + --git-url "https://github.com/urbit/urbit" \ + --ref "409k" \ + --description "Urbit OS" \ + "$HOME/.nockup/cache/git/urbit/urbit/pkg/arvo" \ + >> "$OUTPUT" +fi + +# Scan Nockchain workspace +if [ -d "../nockchain/hoon" ]; then + echo "Scanning nockchain workspace..." + python3 scan-deps-v2.py \ + --workspace nockchain \ + --root-path "hoon" \ + --git-url "https://github.com/nockchain/nockchain" \ + --ref "a19ad4dc" \ + --description "Nockchain standard library" \ + "../nockchain/hoon" \ + >> "$OUTPUT" +fi + +echo "Registry written to $OUTPUT" +echo "" +echo "To use this registry with nockup:" +echo " 1. Copy registry.toml to sigilante/typhoon repo" +echo " 2. Tag it with a version" +echo " 3. Configure nockup to use it" diff --git a/crates/nockup/src/cli.rs b/crates/nockup/src/cli.rs index f4a54acc4..67ccadceb 100644 --- a/crates/nockup/src/cli.rs +++ b/crates/nockup/src/cli.rs @@ -20,6 +20,10 @@ pub enum Commands { #[command(subcommand)] Package(PackageCommand), + /// Cache management + #[command(subcommand)] + Cache(CacheCommand), + /// Toolchain / channel management #[command(subcommand)] Channel(ChannelCommand), @@ -64,7 +68,7 @@ pub enum ProjectCommand { Build { project: Option }, /// Run a NockApp project Run { - project: String, + project: Option, #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, @@ -116,6 +120,25 @@ pub enum PackageCommand { GenerateProxy { url: String, path: Option }, } +#[derive(clap::Subcommand, Debug)] +pub enum CacheCommand { + /// Clear cache directories + Clear { + /// Clear git repository cache + #[arg(long)] + git: bool, + /// Clear processed packages cache + #[arg(long)] + packages: bool, + /// Clear registry cache + #[arg(long)] + registry: bool, + /// Clear all caches + #[arg(long)] + all: bool, + }, +} + #[derive(clap::Subcommand, Debug)] pub enum ChannelCommand { Show, diff --git a/crates/nockup/src/commands/build/build.rs b/crates/nockup/src/commands/build/build.rs index 5d37cd243..64dd80aab 100644 --- a/crates/nockup/src/commands/build/build.rs +++ b/crates/nockup/src/commands/build/build.rs @@ -5,12 +5,32 @@ use anyhow::{Context, Result}; use colored::Colorize; use tokio::process::Command; +use crate::manifest::NockAppManifest; + pub async fn run(project: &str) -> Result<()> { - let project_dir = Path::new(&project); + // If project is ".", try to read nockapp.toml to get the actual project name + let project_name = if project == "." { + let cwd = std::env::current_dir()?; + let manifest_path = cwd.join("nockapp.toml"); + + if manifest_path.exists() { + let manifest = + NockAppManifest::load(&manifest_path).context("Failed to parse nockapp.toml")?; + manifest.package.name.trim().to_string() + } else { + project.to_string() + } + } else { + project.to_string() + }; + + let project_dir = Path::new(&project_name); // Check if project directory exists if !project_dir.exists() { - return Err(anyhow::anyhow!("Project directory '{}' not found", project)); + return Err(anyhow::anyhow!( + "Project directory '{}' not found", project_name + )); } // Auto-install dependencies if nockapp.toml exists @@ -34,21 +54,17 @@ pub async fn run(project: &str) -> Result<()> { } } - // Check if it's a valid NockApp project (has manifest.toml) - let manifest_path = project_dir.join("manifest.toml"); - if !manifest_path.exists() { - return Err(anyhow::anyhow!( - "Not a NockApp project: '{}' missing manifest.toml", project - )); - } - // Check if Cargo.toml exists let cargo_toml = project_dir.join("Cargo.toml"); if !cargo_toml.exists() { - return Err(anyhow::anyhow!("No Cargo.toml found in '{}'", project)); + return Err(anyhow::anyhow!("No Cargo.toml found in '{}'", project_name)); } - println!("{} Building project '{}'...", "🔨".green(), project.cyan()); + println!( + "{} Building project '{}'...", + "🔨".green(), + project_name.cyan() + ); // Extract expected binary names from Cargo.toml let cargo_toml_content = tokio::fs::read_to_string(&cargo_toml) @@ -241,7 +257,11 @@ async fn should_install_dependencies(project_dir: &Path) -> Result { } for pkg in &lockfile.package { - let pkg_dir = packages_dir.join(format!("{}@{}", pkg.name, pkg.version)); + let pkg_dir = packages_dir.join(format!( + "{}--{}", + pkg.name.replace('/', "-"), + pkg.version.replace(['.', ':'], "-") + )); if !pkg_dir.exists() { return Ok(true); // Package directory missing, need to install } diff --git a/crates/nockup/src/commands/build/init.rs b/crates/nockup/src/commands/build/init.rs index 284c2f30b..72342355a 100644 --- a/crates/nockup/src/commands/build/init.rs +++ b/crates/nockup/src/commands/build/init.rs @@ -16,8 +16,7 @@ pub async fn run() -> Result<()> { anyhow::bail!( "No nockapp.toml found in current directory.\n\ → Create one with your desired name, template, and dependencies,\n\ - → then run `nockup project init` again.\n\n\ - Example: https://github.com/nockchain/nockchain/wiki/Project-Templates" + → then run `nockup project init` again." ); } diff --git a/crates/nockup/src/commands/build/mod.rs b/crates/nockup/src/commands/build/mod.rs index 3d8416c89..b82c97e09 100644 --- a/crates/nockup/src/commands/build/mod.rs +++ b/crates/nockup/src/commands/build/mod.rs @@ -13,7 +13,10 @@ pub async fn run(cmd: ProjectCommand) -> Result<()> { let project = project.as_deref().unwrap_or("."); builder_impl::run(project).await } - ProjectCommand::Run { project, args } => run::run(project, args).await, + ProjectCommand::Run { project, args } => { + let project = project.as_deref().unwrap_or("."); + run::run(project.to_string(), args).await + } ProjectCommand::Init => init::run().await, } } diff --git a/crates/nockup/src/commands/build/run.rs b/crates/nockup/src/commands/build/run.rs index b260f2d22..fa1a929bc 100644 --- a/crates/nockup/src/commands/build/run.rs +++ b/crates/nockup/src/commands/build/run.rs @@ -5,29 +5,45 @@ use anyhow::{Context, Result}; use colored::Colorize; use tokio::process::Command; +use crate::manifest::NockAppManifest; + pub async fn run(project: String, args: Vec) -> Result<()> { - let project_dir = Path::new(&project); + // If project is ".", try to read nockapp.toml to get the actual project name + let project_name = if project == "." { + let cwd = std::env::current_dir()?; + let manifest_path = cwd.join("nockapp.toml"); + + if manifest_path.exists() { + let manifest = + NockAppManifest::load(&manifest_path).context("Failed to parse nockapp.toml")?; + manifest.package.name.trim().to_string() + } else { + project + } + } else { + project + }; + + let project_dir = Path::new(&project_name); // Check if project directory exists if !project_dir.exists() { - return Err(anyhow::anyhow!("Project directory '{}' not found", project)); - } - - // Check if it's a valid NockApp project (has manifest.toml) - let manifest_path = project_dir.join("manifest.toml"); - if !manifest_path.exists() { return Err(anyhow::anyhow!( - "Not a NockApp project: '{}' missing manifest.toml", project + "Project directory '{}' not found", project_name )); } // Check if Cargo.toml exists let cargo_toml = project_dir.join("Cargo.toml"); if !cargo_toml.exists() { - return Err(anyhow::anyhow!("No Cargo.toml found in '{}'", project)); + return Err(anyhow::anyhow!("No Cargo.toml found in '{}'", project_name)); } - println!("{} Running project '{}'...", "🔨".green(), project.cyan()); + println!( + "{} Running project '{}'...", + "🔨".green(), + project_name.cyan() + ); // Run cargo run in the project directory let mut command = Command::new("cargo"); diff --git a/crates/nockup/src/commands/cache/clear.rs b/crates/nockup/src/commands/cache/clear.rs new file mode 100644 index 000000000..0dc05a099 --- /dev/null +++ b/crates/nockup/src/commands/cache/clear.rs @@ -0,0 +1,146 @@ +// src/commands/cache/clear.rs +use std::fs; + +use anyhow::{Context, Result}; +use colored::Colorize; + +use crate::commands::common::get_cache_dir; + +/// Clear nockup cache directories +pub async fn run(git: bool, packages: bool, registry: bool, all: bool) -> Result<()> { + let cache_dir = get_cache_dir()?.join("cache"); + + // Determine what to clear + let clear_git = all || git; + let clear_packages = all || packages; + let clear_registry = all || registry; + + // If no flags specified, show help + if !clear_git && !clear_packages && !clear_registry { + println!("{}", "Please specify what to clear:".yellow()); + println!(" --git Clear git repository cache"); + println!(" --packages Clear processed packages cache"); + println!(" --registry Clear registry cache"); + println!(" --all Clear all caches"); + println!(); + println!("Example: nockup cache clear --all"); + return Ok(()); + } + + println!("{} Clearing nockup cache...", "🗑️".cyan()); + println!(); + + let mut cleared_any = false; + + // Clear git cache + if clear_git { + let git_dir = cache_dir.join("git"); + if git_dir.exists() { + let size = calculate_dir_size(&git_dir)?; + fs::remove_dir_all(&git_dir) + .with_context(|| format!("Failed to remove git cache at {}", git_dir.display()))?; + println!( + " {} Cleared git cache (freed {})", + "✓".green(), + format_size(size).cyan() + ); + cleared_any = true; + } else { + println!(" {} Git cache already empty", "→".cyan()); + } + } + + // Clear packages cache + if clear_packages { + let packages_dir = cache_dir.join("packages"); + if packages_dir.exists() { + let size = calculate_dir_size(&packages_dir)?; + fs::remove_dir_all(&packages_dir).with_context(|| { + format!( + "Failed to remove packages cache at {}", + packages_dir.display() + ) + })?; + println!( + " {} Cleared packages cache (freed {})", + "✓".green(), + format_size(size).cyan() + ); + cleared_any = true; + } else { + println!(" {} Packages cache already empty", "→".cyan()); + } + } + + // Clear registry cache + if clear_registry { + let registry_dir = cache_dir.join("registry"); + if registry_dir.exists() { + let size = calculate_dir_size(®istry_dir)?; + fs::remove_dir_all(®istry_dir).with_context(|| { + format!( + "Failed to remove registry cache at {}", + registry_dir.display() + ) + })?; + println!( + " {} Cleared registry cache (freed {})", + "✓".green(), + format_size(size).cyan() + ); + cleared_any = true; + } else { + println!(" {} Registry cache already empty", "→".cyan()); + } + } + + println!(); + if cleared_any { + println!("{} Cache cleared successfully", "✓".green()); + println!( + " Run {} to re-download dependencies", + "nockup package install".cyan() + ); + } else { + println!("{} No cache to clear", "→".cyan()); + } + + Ok(()) +} + +/// Calculate the total size of a directory recursively +fn calculate_dir_size(path: &std::path::Path) -> Result { + let mut total = 0u64; + + if path.is_dir() { + for entry in fs::read_dir(path)? { + let entry = entry?; + let metadata = entry.metadata()?; + + if metadata.is_dir() { + total += calculate_dir_size(&entry.path())?; + } else { + total += metadata.len(); + } + } + } + + Ok(total) +} + +/// Format bytes as human-readable size +fn format_size(bytes: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = KB * 1024; + const GB: u64 = MB * 1024; + + if bytes >= GB { + format!("{:.2} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.2} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.2} KB", bytes as f64 / KB as f64) + } else { + format!("{} B", bytes) + } +} diff --git a/crates/nockup/src/commands/cache/mod.rs b/crates/nockup/src/commands/cache/mod.rs new file mode 100644 index 000000000..62bb5ef2b --- /dev/null +++ b/crates/nockup/src/commands/cache/mod.rs @@ -0,0 +1,17 @@ +// src/commands/cache/mod.rs +pub mod clear; + +use anyhow::Result; + +use crate::cli::CacheCommand; + +pub async fn run(cmd: CacheCommand) -> Result<()> { + match cmd { + CacheCommand::Clear { + git, + packages, + registry, + all, + } => clear::run(git, packages, registry, all).await, + } +} diff --git a/crates/nockup/src/commands/init.rs b/crates/nockup/src/commands/init.rs index 5eb355408..68673110a 100644 --- a/crates/nockup/src/commands/init.rs +++ b/crates/nockup/src/commands/init.rs @@ -56,8 +56,8 @@ pub async fn run(project_name: String) -> Result<()> { format!("./{}/", project_name).cyan() ); println!("To get started:"); - println!(" nockup build {}", project_name.cyan()); - println!(" nockup run {}", project_name.cyan()); + println!(" nockup project build {}", project_name.cyan()); + println!(" nockup project run {}", project_name.cyan()); Ok(()) } diff --git a/crates/nockup/src/commands/mod.rs b/crates/nockup/src/commands/mod.rs index c8da861f2..e81ed5834 100644 --- a/crates/nockup/src/commands/mod.rs +++ b/crates/nockup/src/commands/mod.rs @@ -1,4 +1,5 @@ pub mod build; +pub mod cache; pub mod channel; pub mod common; pub mod init; diff --git a/crates/nockup/src/commands/package/install.rs b/crates/nockup/src/commands/package/install.rs index a5df5da1e..ce6470950 100644 --- a/crates/nockup/src/commands/package/install.rs +++ b/crates/nockup/src/commands/package/install.rs @@ -111,10 +111,11 @@ pub async fn run() -> Result<()> { continue; } - // Install to hoon/packages/@/ - // Sanitize package name (replace / with -) for use in directory names + // Install to hoon/packages/--/ + // Sanitize package name (replace / with -) and version (replace : with -) for use in directory names let safe_name = sanitize_package_name(&pkg.name); - let install_dir = packages_dir.join(format!("{}@{}", safe_name, display_version)); + let safe_version = sanitize_version(&display_version); + let install_dir = packages_dir.join(format!("{}--{}", safe_name, safe_version)); if install_dir.exists() { println!(" {} Already installed, skipping", "✓".green()); @@ -127,33 +128,21 @@ pub async fn run() -> Result<()> { println!( " {} Installed to {}", "✓".green(), - format!("hoon/packages/{}@{}", pkg.name, display_version).cyan() + format!("hoon/packages/{}--{}", safe_name, safe_version).cyan() ); } // Create symlinks for .hoon files // If install_path is specified (from registry), preserve directory structure - // Otherwise, link to hoon/lib/ - // println!(" {} Creating symlinks...", "🔗".cyan()); - // println!("pkg: {:?}", pkg); - // if pkg.source_file == Some("seq.hoon".to_string()) { - // println!("source_file is seq.hoon"); - // println!("install_dir: {:?}", install_dir); - // println!("lib_dir: {:?}", lib_dir); - // println!("pkg.name: {:?}", pkg.name); - // println!("pkg.source_file: {:?}", pkg.source_file.as_deref()); - // link_package_files(&install_dir, &lib_dir, &pkg.name, pkg.install_path.as_deref(), pkg.source_file.as_deref())?; - // } - // should have install_path AND files - // if let Some(ref install_path) = pkg.install_path { - if let (Some(ref install_path), Some(_)) = (&pkg.install_path, &pkg.source_file) { + // Otherwise, link to hoon/lib/ and hoon/sur/ + if let (Some(ref install_path), Some(ref files)) = (&pkg.install_path, &pkg.source_files) { println!("install_path: {:?}", install_path); link_registry_package( install_dir.as_path(), hoon_dir.as_path(), install_path, &pkg.name, - pkg.source_file.as_deref(), + files, )?; } else { println!("No install_path specified, linking to hoon/lib/ and hoon/sur/"); @@ -163,7 +152,7 @@ pub async fn run() -> Result<()> { sur_dir.as_path(), &pkg.name, pkg.source_path.as_deref(), - pkg.source_file.as_deref(), + pkg.source_files.as_ref(), )?; } @@ -203,6 +192,16 @@ fn sanitize_package_name(name: &str) -> String { name.replace('/', "-") } +/// Sanitize version string for Hoon @tas compatibility +/// Replaces dots and colons with hyphens to ensure valid @tas +/// Examples: +/// "0.1.0" -> "0-1-0" +/// "commit:abc123" -> "commit-abc123" +/// "v1.2.3" -> "v1-2-3" +fn sanitize_version(version: &str) -> String { + version.replace(['.', ':'], "-") +} + /// Recursively copy a directory fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { fs::create_dir_all(dst)?; @@ -225,16 +224,16 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> { /// Create symlinks for registry packages that preserve directory structure /// For example: -/// - nockchain/common/zose with install_path="common" and file="zose.hoon" +/// - nockchain/common/zose with install_path="common" and files=["zose.hoon"] /// creates arcadia/hoon/common/zose.hoon -> ../packages/nockchain-common-zose@latest/zose.hoon -/// - urbit/zuse with install_path="sys" and file="zuse.hoon" +/// - urbit/zuse with install_path="sys" and files=["zuse.hoon"] /// creates arcadia/hoon/sys/zuse.hoon -> ../packages/urbit-zuse@latest/zuse.hoon fn link_registry_package( package_dir: &Path, hoon_dir: &Path, install_path: &str, package_name: &str, - specific_file: Option<&str>, + source_files: &Vec, ) -> Result<()> { let package_dir_name = package_dir_basename(package_dir)?; @@ -247,68 +246,70 @@ fn link_registry_package( let target_dir = hoon_dir.join(relative_path); fs::create_dir_all(&target_dir) .with_context(|| format!("Failed to create directory {}", target_dir.display()))?; - println!(" specific_file is {:?}", specific_file); - - if let Some(filename) = specific_file { - // Link only the specific file - let source_file = package_dir.join(filename); - if !source_file.exists() { - anyhow::bail!("Specific file {} not found in package {}", filename, package_name); - } + println!(" source_files: {:?}", source_files); + + if !source_files.is_empty() { + // Link each specified file + for filename in source_files { + let source_file = package_dir.join(filename); + if !source_file.exists() { + anyhow::bail!("Specific file {} not found in package {}", filename, package_name); + } - let link_path = target_dir.join(filename); - println!(" link_path: {:?}", link_path); + let link_path = target_dir.join(filename); + println!(" link_path: {:?}", link_path); - // Remove existing symlink if it exists - if link_path.exists() || link_path.is_symlink() { - fs::remove_file(&link_path).with_context(|| { - format!("Failed to remove existing symlink {}", link_path.display()) - })?; - } + // Remove existing symlink if it exists + if link_path.exists() || link_path.is_symlink() { + fs::remove_file(&link_path).with_context(|| { + format!("Failed to remove existing symlink {}", link_path.display()) + })?; + } - // Create relative symlink - // Calculate path from target_dir back to packages/ - // For hoon/common/, we need: ../../packages/package@version/file - let depth = relative_path.split('/').filter(|s| !s.is_empty()).count(); - let mut relative_target = PathBuf::new(); - for _ in 0..depth { - relative_target.push(".."); - } - relative_target.push("packages"); - relative_target.push(Path::new(&package_dir_name)); - relative_target.push(filename); - println!(" relative_target: {:?}", relative_target); - - #[cfg(unix)] - { - std::os::unix::fs::symlink(&relative_target, &link_path).with_context(|| { - format!( - "Failed to create symlink {} -> {}", - link_path.display(), - relative_target.display() - ) - })?; - } + // Create relative symlink + // Calculate path from target_dir back to packages/ + // For hoon/common/, we need: ../../packages/package@version/file + let depth = relative_path.split('/').filter(|s| !s.is_empty()).count(); + let mut relative_target = PathBuf::new(); + for _ in 0..depth { + relative_target.push(".."); + } + relative_target.push("packages"); + relative_target.push(Path::new(&package_dir_name)); + relative_target.push(filename); + println!(" relative_target: {:?}", relative_target); - #[cfg(windows)] - { - std::os::windows::fs::symlink_file(&relative_target, &link_path).with_context( - || { + #[cfg(unix)] + { + std::os::unix::fs::symlink(&relative_target, &link_path).with_context(|| { format!( "Failed to create symlink {} -> {}", link_path.display(), relative_target.display() ) - }, - )?; - } + })?; + } - println!( - " {} Linked {} to hoon/{}/", - "🔗".cyan(), - filename.yellow(), - relative_path.cyan() - ); + #[cfg(windows)] + { + std::os::windows::fs::symlink_file(&relative_target, &link_path).with_context( + || { + format!( + "Failed to create symlink {} -> {}", + link_path.display(), + relative_target.display() + ) + }, + )?; + } + + println!( + " {} Linked {} to hoon/{}/", + "🔗".cyan(), + filename.yellow(), + relative_path.cyan() + ); + } } else { // No specific file - link all .hoon files from common library/structure paths // When there's no specific file, we assume the package follows Urbit desk structure @@ -418,78 +419,106 @@ fn link_registry_package( } /// Create symlinks in hoon/lib/ and hoon/sur/ for .hoon files in the package -/// If `specific_file` is Some, only link that file. Otherwise, link all .hoon files. +/// If `source_files` is Some with files, only link those files. Otherwise, link all .hoon files. fn link_package_files( package_dir: &Path, lib_dir: &Path, sur_dir: &Path, package_name: &str, _path_from_root: Option<&str>, - specific_file: Option<&str>, + source_files: Option<&Vec>, ) -> Result<()> { let package_dir_name = package_dir_basename(package_dir)?; - println!(" specific_file is {:?}", specific_file); - if let Some(filename) = specific_file { - // Link only the specific file - // The filename may include subdirectories (e.g., "lib/seq.hoon") + println!(" source_files is {:?}", source_files); + + // Get the parent hoon/ directory from lib_dir + let hoon_dir = lib_dir + .parent() + .ok_or_else(|| anyhow::anyhow!("lib_dir has no parent directory"))?; + + if let Some(files) = source_files { + // Link each specified file + // Files may include subdirectories (e.g., "lib/lagoon.hoon", "sur/lagoon.hoon") // The package is cached with contents of source_path, so we don't prepend it - let source_file = package_dir.join(filename); - println!(" source_file: {:?}", source_file); - if !source_file.exists() { - anyhow::bail!("Specific file {} not found in package {}", filename, package_name); - } + for filename in files { + let source_file = package_dir.join(filename); + println!(" source_file: {:?}", source_file); + if !source_file.exists() { + anyhow::bail!("Specific file {} not found in package {}", filename, package_name); + } - // Extract just the filename (last component) for the link path in hoon/lib/ - let file_name = PathBuf::from(filename) - .file_name() - .ok_or_else(|| anyhow::anyhow!("Invalid filename: {}", filename))? - .to_os_string(); - let link_path = lib_dir.join(&file_name); - println!(" link_path: {:?}", link_path); - - // Remove existing symlink if it exists - if link_path.exists() || link_path.is_symlink() { - fs::remove_file(&link_path).with_context(|| { - format!("Failed to remove existing symlink {}", link_path.display()) - })?; - } + // Determine destination directory based on path prefix + // Extract the first path component (e.g., "lib" from "lib/lagoon.hoon") + let (dest_dir, dest_subdir, file_name) = + if let Some((prefix, rest)) = filename.split_once('/') { + // File has a prefix like "lib/lagoon.hoon" or "sys/zuse.hoon" + let dest = hoon_dir.join(prefix); + (dest, prefix.to_string(), rest.to_string()) + } else { + // No prefix, default to lib for backward compatibility + (lib_dir.to_path_buf(), "lib".to_string(), filename.clone()) + }; + + // Extract just the filename (last component) for the link path + let file_name = PathBuf::from(&file_name) + .file_name() + .ok_or_else(|| anyhow::anyhow!("Invalid filename: {}", filename))? + .to_os_string(); + let link_path = dest_dir.join(&file_name); + println!(" link_path: {:?}", link_path); + + // Ensure destination directory exists + if !dest_dir.exists() { + fs::create_dir_all(&dest_dir).with_context(|| { + format!("Failed to create directory {}", dest_dir.display()) + })?; + } - // Create relative symlink - // filename may include subdirectories (e.g., "lib/seq.hoon") - let mut relative_target = PathBuf::from("../packages"); - relative_target.push(Path::new(&package_dir_name)); - relative_target.push(Path::new(filename)); - println!(" relative_target: {:?}", relative_target); - - #[cfg(unix)] - { - std::os::unix::fs::symlink(&relative_target, &link_path).with_context(|| { - format!( - "Failed to create symlink {} -> {}", - link_path.display(), - relative_target.display() - ) - })?; - } + // Remove existing symlink if it exists + if link_path.exists() || link_path.is_symlink() { + fs::remove_file(&link_path).with_context(|| { + format!("Failed to remove existing symlink {}", link_path.display()) + })?; + } + + // Create relative symlink + // filename may include subdirectories (e.g., "lib/lagoon.hoon") + let mut relative_target = PathBuf::from("../packages"); + relative_target.push(Path::new(&package_dir_name)); + relative_target.push(Path::new(filename)); + println!(" relative_target: {:?}", relative_target); - #[cfg(windows)] - { - std::os::windows::fs::symlink_file(&relative_target, &link_path).with_context( - || { + #[cfg(unix)] + { + std::os::unix::fs::symlink(&relative_target, &link_path).with_context(|| { format!( "Failed to create symlink {} -> {}", link_path.display(), relative_target.display() ) - }, - )?; - } + })?; + } - println!( - " {} Linked {} to hoon/lib/", - "🔗".cyan(), - filename.yellow() - ); + #[cfg(windows)] + { + std::os::windows::fs::symlink_file(&relative_target, &link_path).with_context( + || { + format!( + "Failed to create symlink {} -> {}", + link_path.display(), + relative_target.display() + ) + }, + )?; + } + + println!( + " {} Linked {} to hoon/{}/", + "🔗".cyan(), + filename.yellow(), + dest_subdir.cyan() + ); + } return Ok(()); } diff --git a/crates/nockup/src/commands/package/list.rs b/crates/nockup/src/commands/package/list.rs index 43ccbdead..631f32a74 100644 --- a/crates/nockup/src/commands/package/list.rs +++ b/crates/nockup/src/commands/package/list.rs @@ -85,8 +85,12 @@ pub async fn run() -> Result<()> { // Check installation status if let Some(installed_version) = installed.get(name) { // Verify the package directory exists - // Package directories use hyphens instead of slashes - let package_dir_name = format!("{}@{}", name.replace('/', "-"), installed_version); + // Package directories must be @tas compatible (lowercase, numbers, hyphens only) + let package_dir_name = format!( + "{}--{}", + name.replace('/', "-"), + installed_version.replace(['.', ':'], "-") + ); let package_dir = project_dir .join("hoon") .join("packages") diff --git a/crates/nockup/src/commands/run.rs b/crates/nockup/src/commands/run.rs index b260f2d22..14dc189de 100644 --- a/crates/nockup/src/commands/run.rs +++ b/crates/nockup/src/commands/run.rs @@ -13,14 +13,6 @@ pub async fn run(project: String, args: Vec) -> Result<()> { return Err(anyhow::anyhow!("Project directory '{}' not found", project)); } - // Check if it's a valid NockApp project (has manifest.toml) - let manifest_path = project_dir.join("manifest.toml"); - if !manifest_path.exists() { - return Err(anyhow::anyhow!( - "Not a NockApp project: '{}' missing manifest.toml", project - )); - } - // Check if Cargo.toml exists let cargo_toml = project_dir.join("Cargo.toml"); if !cargo_toml.exists() { diff --git a/crates/nockup/src/main.rs b/crates/nockup/src/main.rs index cbb7ea059..6a104202a 100644 --- a/crates/nockup/src/main.rs +++ b/crates/nockup/src/main.rs @@ -13,6 +13,7 @@ async fn main() { // Hierarchical commands Some(Commands::Project(cmd)) => commands::build::run(cmd).await, Some(Commands::Package(cmd)) => commands::package::run(cmd).await, + Some(Commands::Cache(cmd)) => commands::cache::run(cmd).await, Some(Commands::Channel(cmd)) => commands::channel::run(cmd).await, // Legacy flat commands (backward compatible) @@ -36,7 +37,11 @@ async fn main() { commands::package::run(PackageCommand::Install).await } Some(Commands::Run { project, args }) => { - commands::build::run(ProjectCommand::Run { project, args }).await + commands::build::run(ProjectCommand::Run { + project: Some(project), + args, + }) + .await } Some(Commands::TestPhase1) => commands::test_phase1::run().await, diff --git a/crates/nockup/src/manifest.rs b/crates/nockup/src/manifest.rs index cce5a0164..17d4c2fc4 100644 --- a/crates/nockup/src/manifest.rs +++ b/crates/nockup/src/manifest.rs @@ -81,7 +81,7 @@ pub enum DependencySpec { tag: Option, branch: Option, path: Option, - files: Option>, // Specific files to extract (e.g., ["seq", "test"]) + files: Option>, kelvin: Option, }, } diff --git a/crates/nockup/src/resolver/engine.rs b/crates/nockup/src/resolver/engine.rs index ce54e1367..990a28a1b 100644 --- a/crates/nockup/src/resolver/engine.rs +++ b/crates/nockup/src/resolver/engine.rs @@ -145,17 +145,8 @@ impl Resolver { ); } - // If a specific file is requested, verify it exists - if let Some(ref filename) = git_spec.file { - let file_path = source_dir.join(filename); - if !file_path.exists() { - anyhow::bail!( - "Specific file {} not found at {}", - filename, - source_dir.display() - ); - } - } + // Validate all requested source files exist + let source_files = self.validate_source_files(&source_dir, spec)?; // Check for transitive dependencies (look for hoon.toml in fetched repo) let transitive_deps = self @@ -197,7 +188,11 @@ impl Resolver { source_url: git_spec.url.clone(), source_path: git_spec.path.clone(), install_path: git_spec.install_path.clone(), - source_file: git_spec.file.clone(), + source_files: if source_files.is_empty() { + None + } else { + Some(source_files) + }, dependencies: transitive_deps, }) } @@ -212,9 +207,17 @@ impl Resolver { let version_str = version_spec.to_canonical_string(); if let Some(cached) = self.cache.find_cached(name, &version_str).await? { - // Reconstruct the GitSpec to get source_path and source_file + // Reconstruct the GitSpec to get source_path and source_files let git_spec = self.dep_spec_to_git_spec(spec, name).await?; + // Extract files list from spec + let source_files = match spec { + DependencySpec::Full { files, .. } => files + .as_ref() + .map(|f| f.iter().map(|s| format!("{}.hoon", s)).collect()), + _ => None, + }; + return Ok(Some(ResolvedPackage { name: name.to_string(), version_spec, @@ -222,7 +225,7 @@ impl Resolver { source_url: cached.source_url, source_path: git_spec.path, install_path: git_spec.install_path, - source_file: git_spec.file, + source_files, dependencies: HashMap::new(), // TODO: Store in cache metadata })); } @@ -291,23 +294,12 @@ impl Resolver { tag, branch, path, - files, .. } => { let url = git.as_ref().ok_or_else(|| { anyhow::anyhow!("Git URL is required (registry not yet implemented)") })?; - // If files is specified with exactly one entry, use it as the specific file - // If files has multiple entries, we'll need to handle that differently (TODO) - let file = files.as_ref().and_then(|f| { - if f.len() == 1 { - Some(format!("{}.hoon", f[0])) - } else { - None - } - }); - Ok(GitSpec { url: url.clone(), commit: commit.clone(), @@ -315,7 +307,7 @@ impl Resolver { branch: branch.clone(), path: path.clone(), install_path: None, // Don't auto-set for manifest packages; let install.rs handle it - file, + file: None, // Multiple files handled separately in source_files }) } } @@ -370,6 +362,36 @@ impl Resolver { } } + /// Validate that all requested source files exist and return the list + fn validate_source_files( + &self, + source_dir: &Path, + spec: &DependencySpec, + ) -> Result> { + let files = match spec { + DependencySpec::Full { files: Some(f), .. } => f.clone(), + _ => return Ok(Vec::new()), + }; + + let mut validated = Vec::new(); + for file_path in &files { + let full_path = format!("{}.hoon", file_path); + let abs_path = source_dir.join(&full_path); + + if !abs_path.exists() { + anyhow::bail!( + "Requested file '{}' not found in package at {}", + full_path, + source_dir.display() + ); + } + + validated.push(full_path); + } + + Ok(validated) + } + /// Convert DependencySpec to VersionSpec for caching fn spec_to_version_spec(&self, spec: &DependencySpec) -> Result { match spec { diff --git a/crates/nockup/src/resolver/types.rs b/crates/nockup/src/resolver/types.rs index 7b163be20..80e0ddc10 100644 --- a/crates/nockup/src/resolver/types.rs +++ b/crates/nockup/src/resolver/types.rs @@ -12,7 +12,7 @@ pub struct ResolvedPackage { pub source_url: String, pub source_path: Option, // Subdir within repo to fetch from (e.g., "pkg/arvo/sys") pub install_path: Option, // Subdir to install to (e.g., "sys") - pub source_file: Option, // Specific file to extract (if any) + pub source_files: Option>, // Specific files to extract (if any) pub dependencies: HashMap, // Transitive deps } diff --git a/crates/nockup/templates/basic/manifest.toml b/crates/nockup/templates/basic/manifest.toml deleted file mode 100644 index 12f4953bc..000000000 --- a/crates/nockup/templates/basic/manifest.toml +++ /dev/null @@ -1,12 +0,0 @@ -[project] -name = "{{name}}" -project_name = "{{project_name}}" -version = "{{version}}" -description = "{{description}}" -author_name = "{{author_name}}" -author_email = "{{author_email}}" -github_username = "{{github_username}}" -license = "{{license}}" -keywords = ["nockapp", "nockchain", "hoon"] -nockapp_commit_hash = "{{nockapp_commit_hash}}" -template = "basic" diff --git a/crates/nockup/templates/grpc/Cargo.toml b/crates/nockup/templates/grpc/Cargo.toml index 1d443cb92..1a62b7d85 100644 --- a/crates/nockup/templates/grpc/Cargo.toml +++ b/crates/nockup/templates/grpc/Cargo.toml @@ -6,8 +6,12 @@ authors = ["N. E. Davis "] description = "A NockApp project" [[bin]] -name = "grpc" -path = "src/main.rs" +name = "listen" +path = "src/listen.rs" + +[[bin]] +name = "talk" +path = "src/talk.rs" [dependencies] # NockVM dependency @@ -37,4 +41,4 @@ tokio-util = "0.7.11" tracing = "0.1.41" [build-dependencies] -# Build script dependencies if needed \ No newline at end of file +# Build script dependencies if needed diff --git a/crates/nockup/templates/grpc/manifest.toml b/crates/nockup/templates/grpc/manifest.toml deleted file mode 100644 index b954d476a..000000000 --- a/crates/nockup/templates/grpc/manifest.toml +++ /dev/null @@ -1,12 +0,0 @@ -[project] -name = "{{name}}" -project_name = "{{project_name}}" -version = "{{version}}" -description = "{{description}}" -author_name = "{{author_name}}" -author_email = "{{author_email}}" -github_username = "{{github_username}}" -license = "{{license}}" -keywords = ["nockapp", "nockchain", "hoon"] -nockapp_commit_hash = "{{nockapp_commit_hash}}" -template = "grpc" diff --git a/crates/nockup/templates/http-server/manifest.toml b/crates/nockup/templates/http-server/manifest.toml deleted file mode 100644 index 621108d47..000000000 --- a/crates/nockup/templates/http-server/manifest.toml +++ /dev/null @@ -1,12 +0,0 @@ -[project] -name = "{{name}}" -project_name = "{{project_name}}" -version = "{{version}}" -description = "{{description}}" -author_name = "{{author_name}}" -author_email = "{{author_email}}" -github_username = "{{github_username}}" -license = "{{license}}" -keywords = ["nockapp", "nockchain", "hoon"] -nockapp_commit_hash = "{{nockapp_commit_hash}}" -template = "http-server" diff --git a/crates/nockup/templates/http-static/manifest.toml b/crates/nockup/templates/http-static/manifest.toml deleted file mode 100644 index a2d11080a..000000000 --- a/crates/nockup/templates/http-static/manifest.toml +++ /dev/null @@ -1,12 +0,0 @@ -[project] -name = "{{name}}" -project_name = "{{project_name}}" -version = "{{version}}" -description = "{{description}}" -author_name = "{{author_name}}" -author_email = "{{author_email}}" -github_username = "{{github_username}}" -license = "{{license}}" -keywords = ["nockapp", "nockchain", "hoon"] -nockapp_commit_hash = "{{nockapp_commit_hash}}" -template = "http-static" diff --git a/crates/nockup/templates/repl/manifest.toml b/crates/nockup/templates/repl/manifest.toml deleted file mode 100644 index 3239d7a41..000000000 --- a/crates/nockup/templates/repl/manifest.toml +++ /dev/null @@ -1,12 +0,0 @@ -[project] -name = "{{name}}" -project_name = "{{project_name}}" -version = "{{version}}" -description = "{{description}}" -author_name = "{{author_name}}" -author_email = "{{author_email}}" -github_username = "{{github_username}}" -license = "{{license}}" -keywords = ["nockapp", "nockchain", "hoon"] -nockapp_commit_hash = "{{nockapp_commit_hash}}" -template = "repl" diff --git a/crates/nockup/tests/cli_tests.rs b/crates/nockup/tests/cli_tests.rs deleted file mode 100644 index f9215b059..000000000 --- a/crates/nockup/tests/cli_tests.rs +++ /dev/null @@ -1,258 +0,0 @@ -use std::process::Command; - -#[allow(deprecated)] -use assert_cmd::cargo::cargo_bin; -use assert_cmd::prelude::*; -use predicates::prelude::*; -use tempfile::TempDir; - -// Helper to get the cargo bin path -// Note: cargo_bin function is deprecated but the macro isn't exposed through prelude -fn nockup_bin() -> std::path::PathBuf { - #[allow(deprecated)] - cargo_bin("nockup") -} - -#[cfg(test)] -mod cli_input_validation_tests { - use super::*; - - // Test basic command structure - #[test] - fn test_no_args_shows_version() { - let mut cmd = Command::new(nockup_bin()); - cmd.assert() - .success() - .stdout(predicate::str::contains("version")); - } - - #[test] - fn test_help_command() { - let mut cmd = Command::new(nockup_bin()); - cmd.arg("help"); - cmd.assert() - .success() - .stdout(predicate::str::contains("Initialize nockup cache")); - } - - #[test] - fn test_invalid_command() { - let mut cmd = Command::new(nockup_bin()); - cmd.arg("invalid-command"); - cmd.assert() - .failure() - .stderr(predicate::str::contains("error")) - .stderr(predicate::str::contains("invalid-command")); - } - - // Test install command validation - #[test] - fn test_install_with_invalid_flags() { - let mut cmd = Command::new(nockup_bin()); - cmd.args(["install", "--invalid-flag"]); - cmd.assert() - .failure() - .stderr(predicate::str::contains("unexpected argument")); - } - - // Test start command validation - #[test] - fn test_init_without_project_name() { - let mut cmd = Command::new(nockup_bin()); - cmd.arg("init"); - cmd.assert() - .failure() - .stderr(predicate::str::contains("required")); - } - - #[test] - fn test_init_with_empty_project_name() { - let mut cmd = Command::new(nockup_bin()); - cmd.args(["init", ""]); - cmd.assert().failure().stderr(predicate::str::contains( - "Error: Project configuration file '.toml' not found", - )); - } - - #[test] - fn test_init_with_valid_project_names() { - let valid_names = vec!["myproject", "my-project", "my_project", "project123"]; - - for name in valid_names { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let mut cmd = Command::new(nockup_bin()); - cmd.current_dir(temp_dir.path()).args(["init", name]); - - // This might fail due to missing cache, but shouldn't fail on name validation - let output = cmd.output().expect("Failed to run command"); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!(!stderr.contains("invalid project name")); - } - } - - // Test build command validation - #[test] - fn test_build_without_project_name() { - let mut cmd = Command::new(nockup_bin()); - cmd.arg("build"); - cmd.assert() - .failure() - .stderr(predicate::str::contains("required")); - } - - #[test] - fn test_build_nonexistent_project() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let mut cmd = Command::new(nockup_bin()); - cmd.current_dir(temp_dir.path()) - .args(["build", "nonexistent-project"]); - cmd.assert().failure().stderr( - predicate::str::contains("Project directory") - .and(predicate::str::contains("not found")), - ); - } - - // Test run command validation - #[test] - fn test_run_without_project_name() { - let mut cmd = Command::new(nockup_bin()); - cmd.arg("run"); - cmd.assert() - .failure() - .stderr(predicate::str::contains("required")); - } - - // Test channel command validation - #[test] - fn test_channel_without_subcommand() { - let mut cmd = Command::new(nockup_bin()); - cmd.arg("channel"); - cmd.assert() - .failure() - .stderr(predicate::str::contains("nockup channel ")); - } - - #[test] - fn test_channel_show_with_extra_args() { - let mut cmd = Command::new(nockup_bin()); - cmd.args(["channel", "show", "extra-arg"]); - cmd.assert() - .failure() - .stderr(predicate::str::contains("unexpected argument")); - } - - #[test] - fn test_channel_set_without_channel_name() { - let mut cmd = Command::new(nockup_bin()); - cmd.args(["channel", "set"]); - cmd.assert() - .failure() - .stderr(predicate::str::contains("required")); - } - - #[test] - fn test_channel_set_invalid_channel() { - let mut cmd = Command::new(nockup_bin()); - cmd.args(["channel", "set", "invalid-channel"]); - cmd.assert() - .failure() - .stderr(predicate::str::contains("Invalid channel")); - } - - #[test] - fn test_channel_set_valid_channels() { - let channels = vec!["stable", "nightly"]; - - for channel in channels { - let mut cmd = Command::new(nockup_bin()); - cmd.args(["channel", "set", channel]); - - // This might fail due to missing cache, but shouldn't fail on channel validation - let output = cmd.output().expect("Failed to run command"); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!(!stderr.contains("invalid channel")); - } - } - - // Test configuration file validation (if manifest is required) - #[test] - fn test_build_without_manifest() { - let temp_dir = TempDir::new().expect("Failed to create temp dir"); - let project_dir = temp_dir.path().join("test-project"); - std::fs::create_dir_all(&project_dir).expect("Failed to create project dir"); - - let mut cmd = Command::new(nockup_bin()); - cmd.current_dir(&project_dir).args(["build", "."]); - cmd.assert().failure().stderr(predicate::str::contains( - "Error: Not a NockApp project: '.' missing manifest.toml", - )); - } -} - -// Unit tests for argument parsing (if you have a separate args module) -#[cfg(test)] -mod unit_input_validation_tests { - // use super::*; - - // These would test your argument parsing functions directly - // Example assuming you have a validate_project_name function: - - /* - #[test] - fn test_validate_project_name_valid() { - assert!(validate_project_name("valid-project").is_ok()); - assert!(validate_project_name("valid_project").is_ok()); - assert!(validate_project_name("project123").is_ok()); - } - - #[test] - fn test_validate_project_name_invalid() { - assert!(validate_project_name("").is_err()); - assert!(validate_project_name("project with spaces").is_err()); - assert!(validate_project_name("project/with/slashes").is_err()); - assert!(validate_project_name("project@with@symbols").is_err()); - } - - #[test] - fn test_validate_channel_name() { - assert!(validate_channel_name("stable").is_ok()); - assert!(validate_channel_name("nightly").is_ok()); - assert!(validate_channel_name("invalid").is_err()); - assert!(validate_channel_name("").is_err()); - } - */ -} - -// Property-based testing example (add proptest = "1.0" to dev-dependencies) -// #[cfg(feature = "proptest")] -// mod property_tests { -// use proptest::prelude::*; - -// proptest! { -// #[test] -// fn test_project_name_chars(s in "[a-zA-Z0-9_-]{1,50}") { -// // Valid project names should only contain alphanumeric, underscore, hyphen -// let mut cmd = Command::cargo_bin("nockup").unwrap(); -// cmd.args(&["start", &s]); -// let output = cmd.output().unwrap(); -// let stderr = String::from_utf8_lossy(&output.stderr); -// // Should not fail on name validation (might fail for other reasons) -// assert!(!stderr.contains("invalid project name")); -// } - -// #[test] -// fn test_invalid_project_name_chars(s in "[^a-zA-Z0-9_-]+") { -// // Invalid characters should be rejected -// let mut cmd = Command::cargo_bin("nockup").unwrap(); -// cmd.args(&["start", &s]); -// cmd.assert().failure(); -// } -// } -// } - -// Helper functions for test setup -#[cfg(test)] -mod test_helpers { - // use super::*; - // use std::fs; -} From 5039878a77b1ec4e42e4e56fb95f3aa63398ce49 Mon Sep 17 00:00:00 2001 From: tacryt-socryp <1377557+tacryt-socryp@users.noreply.github.com> Date: Mon, 22 Dec 2025 14:54:41 -0600 Subject: [PATCH 37/99] cargo.lock updated for nockup --- Cargo.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index fd44a7d46..7c457ad8b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5227,7 +5227,7 @@ dependencies = [ [[package]] name = "nockup" -version = "0.5.0" +version = "1.0.0" dependencies = [ "anyhow", "assert_cmd", From cd91acc3f2975ce2dc4f66ce73fb87a421e9b27c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 13 Feb 2026 16:48:08 -0500 Subject: [PATCH 38/99] sync: Bridge crate, kernel refactor, zoon jets, and hash improvements (#24) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - **Bridge crate**: Full Ethereum bridge implementation — deposit/withdrawal handling, gRPC ingress, proposal cache, health monitoring, Solidity contracts (MessageInbox, Nock token), TUI with status panels, and comprehensive test harness - **Kernel crate refactor**: Split monolithic `kernels` crate into individual per-kernel crates (bridge, dumb, miner, nockchain-peek, wallet) each with their own build.rs - **Zoon jets**: New jet implementations for zoon tree operations — `dor-tip`, `gor-tip`, `mor-tip`, `put-tip`, `get-tip`, `has-tip`, `tap-tip`, `gas-tip` with tip5 hash caching - **Hash/encoding improvements**: Replaced base-p-to-decimal with direct byte serialization for base58 encoding, added `from_be_bytes`/`to_be_bytes`/`to_atom`/`from_limbs` methods on `Hash`, new limb-based byte conversions - **Bridge Hoon**: Base hold logic to pause block processing, reworked deposit settlement flow, removed stale nonce check, improved hold/counterpart handling - **Nockup**: Added cache clearing commands, build registry script, simplified templates (removed manifest.toml files), updated resolver - **Nockvm**: Replaced `expect(&format!(...))` with `unwrap_or_else(|| panic!(...))` in ibig memory tests to avoid unnecessary allocations - **Nix flake**: Added flake.nix and flake.lock for reproducible builds - **Cargo.lock removed** from version control --------- Co-authored-by: bitemyapp Co-authored-by: Jake Miller --- .github/workflows/create-sync-pr.yml | 29 + .github/workflows/sync-to-nockchain.yml | 20 + .gitlab-ci.yml | 110 + Cargo.lock | 1970 +++++++++---- Cargo.toml | 34 +- crates/bridge/.gitignore | 41 + crates/bridge/Cargo.toml | 75 + crates/bridge/bridge-conf.example.toml | 93 + crates/bridge/build.rs | 312 ++ crates/bridge/contracts/.env.template | 93 + crates/bridge/contracts/DEPLOYMENT.md | 596 ++++ crates/bridge/contracts/Makefile | 143 + crates/bridge/contracts/MessageInbox.sol | 332 +++ crates/bridge/contracts/Nock.sol | 102 + crates/bridge/contracts/UPGRADE_GUIDE.md | 232 ++ crates/bridge/contracts/docs/UPGRADE_GUIDE.md | 231 ++ .../base-sepolia-testnet-accounts.md | 62 + .../environments/base-sepolia.example | 29 + .../contracts/environments/devnet.example | 29 + crates/bridge/contracts/forge/Deploy.s.sol | 83 + .../contracts/forge/IntegrationTest.s.sol | 122 + .../bridge/contracts/forge/MintAndBurn.s.sol | 145 + .../contracts/forge/SetBridgeNodes.s.sol | 80 + crates/bridge/contracts/forge/Upgrade.s.sol | 44 + crates/bridge/contracts/forge/Validate.s.sol | 75 + crates/bridge/contracts/foundry.lock | 26 + crates/bridge/contracts/foundry.toml | 30 + crates/bridge/contracts/remappings.txt | 5 + crates/bridge/contracts/tenderly.env.example | 25 + crates/bridge/contracts/tenderly.yaml | 49 + .../contracts/test/BridgeTestBase.t.sol | 154 + .../test/MessageInboxBridgeNodeTest.t.sol | 150 + .../test/MessageInboxDepositTest.t.sol | 605 ++++ .../contracts/test/MessageInboxFuzzTest.t.sol | 415 +++ .../test/MessageInboxUpgradeTest.t.sol | 57 + .../contracts/test/NockWithdrawalTest.t.sol | 79 + crates/bridge/proto/bridge_ingress.proto | 124 + crates/bridge/proto/bridge_status.proto | 59 + crates/bridge/proto/bridge_tui.proto | 293 ++ crates/bridge/src/bin/nockchain-bridge-tui.rs | 94 + crates/bridge/src/bridge_status.rs | 955 ++++++ crates/bridge/src/config.rs | 462 +++ crates/bridge/src/deposit_log.rs | 1532 ++++++++++ crates/bridge/src/errors.rs | 113 + crates/bridge/src/ethereum.rs | 1631 ++++++++++ crates/bridge/src/grpc.rs | 1 + crates/bridge/src/health.rs | 439 +++ crates/bridge/src/ingress.rs | 1552 ++++++++++ crates/bridge/src/lib.rs | 27 + crates/bridge/src/main.rs | 988 +++++++ crates/bridge/src/metrics.rs | 559 ++++ crates/bridge/src/nockchain.rs | 899 ++++++ crates/bridge/src/proposal_cache.rs | 1283 ++++++++ crates/bridge/src/proposer.rs | 74 + crates/bridge/src/runtime.rs | 2439 +++++++++++++++ crates/bridge/src/schema.rs | 13 + crates/bridge/src/signing.rs | 261 ++ crates/bridge/src/status.rs | 188 ++ crates/bridge/src/stop.rs | 205 ++ crates/bridge/src/tui/clipboard.rs | 72 + crates/bridge/src/tui/mod.rs | 1076 +++++++ crates/bridge/src/tui/panels/alerts.rs | 105 + crates/bridge/src/tui/panels/deposit_log.rs | 132 + crates/bridge/src/tui/panels/health.rs | 104 + crates/bridge/src/tui/panels/mod.rs | 18 + crates/bridge/src/tui/panels/network_state.rs | 492 ++++ crates/bridge/src/tui/panels/proposals.rs | 893 ++++++ crates/bridge/src/tui/panels/transactions.rs | 317 ++ crates/bridge/src/tui/state.rs | 338 +++ crates/bridge/src/tui/types.rs | 864 ++++++ crates/bridge/src/tui/widgets/help_overlay.rs | 112 + crates/bridge/src/tui/widgets/mod.rs | 9 + crates/bridge/src/tui/widgets/status_bar.rs | 158 + crates/bridge/src/tui_api.rs | 924 ++++++ crates/bridge/src/tui_client.rs | 913 ++++++ crates/bridge/src/types.rs | 2617 +++++++++++++++++ crates/bridge/tests/config_tests.rs | 962 ++++++ crates/bridge/tests/failover_tests.rs | 485 +++ crates/bridge/tests/logging_tests.rs | 136 + crates/bridge/tests/test_harness.rs | 1091 +++++++ crates/kernels/Cargo.toml | 18 - crates/kernels/bridge/Cargo.toml | 7 + crates/kernels/bridge/build.rs | 15 + crates/kernels/bridge/src/lib.rs | 1 + crates/kernels/dumb/Cargo.toml | 7 + crates/kernels/dumb/build.rs | 15 + crates/kernels/dumb/src/lib.rs | 1 + crates/kernels/miner/Cargo.toml | 7 + crates/kernels/miner/build.rs | 15 + crates/kernels/miner/src/lib.rs | 1 + crates/kernels/nockchain-peek/Cargo.toml | 7 + crates/kernels/nockchain-peek/build.rs | 15 + crates/kernels/nockchain-peek/src/lib.rs | 1 + crates/kernels/src/bridge.rs | 8 - crates/kernels/src/dumb.rs | 8 - crates/kernels/src/lib.rs | 14 - crates/kernels/src/miner.rs | 8 - crates/kernels/src/nockchain_peek.rs | 8 - crates/kernels/src/wallet.rs | 6 - crates/kernels/wallet/Cargo.toml | 7 + crates/kernels/wallet/build.rs | 15 + crates/kernels/wallet/src/lib.rs | 1 + crates/nockapp-grpc-proto/build.rs | 14 +- .../services/public_nockchain/v2/server.rs | 36 +- crates/nockapp/src/nockapp/actors/kernel.rs | 1 + crates/nockapp/src/nockapp/actors/save.rs | 19 +- crates/nockchain-api/Cargo.toml | 2 +- crates/nockchain-api/src/main.rs | 2 +- crates/nockchain-explorer-tui/Cargo.toml | 1 + crates/nockchain-explorer-tui/src/main.rs | 4 + crates/nockchain-math/src/zoon/common.rs | 62 +- crates/nockchain-peek/Cargo.toml | 3 +- crates/nockchain-peek/src/main.rs | 2 +- .../src/tx_engine/common/mod.rs | 205 +- .../src/tx_engine/common/page.rs | 29 +- crates/nockchain-wallet/Cargo.toml | 2 +- crates/nockchain-wallet/src/main.rs | 2 +- crates/nockchain/Cargo.toml | 3 +- .../jams/fakenet-genesis-pow-2-bex-4.jam | Bin 0 -> 69512 bytes crates/nockchain/src/main.rs | 2 +- crates/nockchain/src/mining.rs | 2 +- crates/nockup/src/validation.rs | 35 +- crates/nockup/templates/basic/build.rs | 3 +- crates/nockup/templates/basic/src/main.rs | 19 +- crates/nockup/templates/grpc/build.rs | 3 +- crates/nockup/templates/grpc/src/lib.rs | 6 +- crates/nockup/templates/grpc/src/listen.rs | 6 +- crates/nockup/templates/grpc/src/talk.rs | 39 +- .../nockup/templates/http-server/src/main.rs | 3 +- .../nockup/templates/http-static/src/main.rs | 3 +- crates/nockup/templates/repl/build.rs | 3 +- crates/nockup/templates/repl/src/main.rs | 9 +- crates/nockvm/rust/ibig/src/memory.rs | 86 +- .../rust/ibig/tests/bytehound_allocations.rs | 62 + crates/nockvm/rust/nockvm/src/noun.rs | 3 +- crates/noun-serde-derive/src/lib.rs | 99 +- crates/zkvm-jetpack/src/hot.rs | 80 + crates/zkvm-jetpack/src/jets/bp_jets.rs | 6 +- crates/zkvm-jetpack/src/jets/fp_jets.rs | 4 +- crates/zkvm-jetpack/src/jets/fpntt_jets.rs | 2 +- crates/zkvm-jetpack/src/jets/mod.rs | 1 + .../zkvm-jetpack/src/jets/proof_gen_jets.rs | 2 +- crates/zkvm-jetpack/src/jets/verifier_jets.rs | 4 +- crates/zkvm-jetpack/src/jets/zoon_jets.rs | 399 +++ flake.lock | 121 + flake.nix | 294 ++ hoon/apps/bridge/base.hoon | 25 +- hoon/apps/bridge/bridge.hoon | 305 +- hoon/apps/bridge/nock.hoon | 39 +- hoon/apps/bridge/types.hoon | 110 +- hoon/common/ztd/one.hoon | 2 + tools/sync_to_staging/copy.bara.sky | 28 + tools/sync_to_staging/sync_to_staging.sh | 38 + 153 files changed, 34091 insertions(+), 1010 deletions(-) create mode 100644 .github/workflows/create-sync-pr.yml create mode 100644 .github/workflows/sync-to-nockchain.yml create mode 100644 .gitlab-ci.yml create mode 100644 crates/bridge/.gitignore create mode 100644 crates/bridge/Cargo.toml create mode 100644 crates/bridge/bridge-conf.example.toml create mode 100644 crates/bridge/build.rs create mode 100644 crates/bridge/contracts/.env.template create mode 100644 crates/bridge/contracts/DEPLOYMENT.md create mode 100644 crates/bridge/contracts/Makefile create mode 100644 crates/bridge/contracts/MessageInbox.sol create mode 100644 crates/bridge/contracts/Nock.sol create mode 100644 crates/bridge/contracts/UPGRADE_GUIDE.md create mode 100644 crates/bridge/contracts/docs/UPGRADE_GUIDE.md create mode 100644 crates/bridge/contracts/environments/base-sepolia-testnet-accounts.md create mode 100644 crates/bridge/contracts/environments/base-sepolia.example create mode 100644 crates/bridge/contracts/environments/devnet.example create mode 100644 crates/bridge/contracts/forge/Deploy.s.sol create mode 100644 crates/bridge/contracts/forge/IntegrationTest.s.sol create mode 100644 crates/bridge/contracts/forge/MintAndBurn.s.sol create mode 100644 crates/bridge/contracts/forge/SetBridgeNodes.s.sol create mode 100644 crates/bridge/contracts/forge/Upgrade.s.sol create mode 100644 crates/bridge/contracts/forge/Validate.s.sol create mode 100644 crates/bridge/contracts/foundry.lock create mode 100644 crates/bridge/contracts/foundry.toml create mode 100644 crates/bridge/contracts/remappings.txt create mode 100644 crates/bridge/contracts/tenderly.env.example create mode 100644 crates/bridge/contracts/tenderly.yaml create mode 100644 crates/bridge/contracts/test/BridgeTestBase.t.sol create mode 100644 crates/bridge/contracts/test/MessageInboxBridgeNodeTest.t.sol create mode 100644 crates/bridge/contracts/test/MessageInboxDepositTest.t.sol create mode 100644 crates/bridge/contracts/test/MessageInboxFuzzTest.t.sol create mode 100644 crates/bridge/contracts/test/MessageInboxUpgradeTest.t.sol create mode 100644 crates/bridge/contracts/test/NockWithdrawalTest.t.sol create mode 100644 crates/bridge/proto/bridge_ingress.proto create mode 100644 crates/bridge/proto/bridge_status.proto create mode 100644 crates/bridge/proto/bridge_tui.proto create mode 100644 crates/bridge/src/bin/nockchain-bridge-tui.rs create mode 100644 crates/bridge/src/bridge_status.rs create mode 100644 crates/bridge/src/config.rs create mode 100644 crates/bridge/src/deposit_log.rs create mode 100644 crates/bridge/src/errors.rs create mode 100644 crates/bridge/src/ethereum.rs create mode 100644 crates/bridge/src/grpc.rs create mode 100644 crates/bridge/src/health.rs create mode 100644 crates/bridge/src/ingress.rs create mode 100644 crates/bridge/src/lib.rs create mode 100644 crates/bridge/src/main.rs create mode 100644 crates/bridge/src/metrics.rs create mode 100644 crates/bridge/src/nockchain.rs create mode 100644 crates/bridge/src/proposal_cache.rs create mode 100644 crates/bridge/src/proposer.rs create mode 100644 crates/bridge/src/runtime.rs create mode 100644 crates/bridge/src/schema.rs create mode 100644 crates/bridge/src/signing.rs create mode 100644 crates/bridge/src/status.rs create mode 100644 crates/bridge/src/stop.rs create mode 100644 crates/bridge/src/tui/clipboard.rs create mode 100644 crates/bridge/src/tui/mod.rs create mode 100644 crates/bridge/src/tui/panels/alerts.rs create mode 100644 crates/bridge/src/tui/panels/deposit_log.rs create mode 100644 crates/bridge/src/tui/panels/health.rs create mode 100644 crates/bridge/src/tui/panels/mod.rs create mode 100644 crates/bridge/src/tui/panels/network_state.rs create mode 100644 crates/bridge/src/tui/panels/proposals.rs create mode 100644 crates/bridge/src/tui/panels/transactions.rs create mode 100644 crates/bridge/src/tui/state.rs create mode 100644 crates/bridge/src/tui/types.rs create mode 100644 crates/bridge/src/tui/widgets/help_overlay.rs create mode 100644 crates/bridge/src/tui/widgets/mod.rs create mode 100644 crates/bridge/src/tui/widgets/status_bar.rs create mode 100644 crates/bridge/src/tui_api.rs create mode 100644 crates/bridge/src/tui_client.rs create mode 100644 crates/bridge/src/types.rs create mode 100644 crates/bridge/tests/config_tests.rs create mode 100644 crates/bridge/tests/failover_tests.rs create mode 100644 crates/bridge/tests/logging_tests.rs create mode 100644 crates/bridge/tests/test_harness.rs delete mode 100644 crates/kernels/Cargo.toml create mode 100644 crates/kernels/bridge/Cargo.toml create mode 100644 crates/kernels/bridge/build.rs create mode 100644 crates/kernels/bridge/src/lib.rs create mode 100644 crates/kernels/dumb/Cargo.toml create mode 100644 crates/kernels/dumb/build.rs create mode 100644 crates/kernels/dumb/src/lib.rs create mode 100644 crates/kernels/miner/Cargo.toml create mode 100644 crates/kernels/miner/build.rs create mode 100644 crates/kernels/miner/src/lib.rs create mode 100644 crates/kernels/nockchain-peek/Cargo.toml create mode 100644 crates/kernels/nockchain-peek/build.rs create mode 100644 crates/kernels/nockchain-peek/src/lib.rs delete mode 100644 crates/kernels/src/bridge.rs delete mode 100644 crates/kernels/src/dumb.rs delete mode 100644 crates/kernels/src/lib.rs delete mode 100644 crates/kernels/src/miner.rs delete mode 100644 crates/kernels/src/nockchain_peek.rs delete mode 100644 crates/kernels/src/wallet.rs create mode 100644 crates/kernels/wallet/Cargo.toml create mode 100644 crates/kernels/wallet/build.rs create mode 100644 crates/kernels/wallet/src/lib.rs create mode 100644 crates/nockchain/jams/fakenet-genesis-pow-2-bex-4.jam create mode 100644 crates/nockvm/rust/ibig/tests/bytehound_allocations.rs create mode 100644 crates/zkvm-jetpack/src/jets/zoon_jets.rs create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 tools/sync_to_staging/copy.bara.sky create mode 100644 tools/sync_to_staging/sync_to_staging.sh diff --git a/.github/workflows/create-sync-pr.yml b/.github/workflows/create-sync-pr.yml new file mode 100644 index 000000000..000de315c --- /dev/null +++ b/.github/workflows/create-sync-pr.yml @@ -0,0 +1,29 @@ +name: Create sync PR + +on: + push: + branches: + - staging + +jobs: + create-pr: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Create or update PR + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + EXISTING_PR=$(gh pr list --head staging --base master --json number --jq '.[0].number') + if [ -n "$EXISTING_PR" ]; then + echo "PR #$EXISTING_PR already exists" + else + gh pr create \ + --head staging \ + --base master \ + --title "sync: update from upstream" \ + --body "Automated sync from upstream via Copybara." + fi diff --git a/.github/workflows/sync-to-nockchain.yml b/.github/workflows/sync-to-nockchain.yml new file mode 100644 index 000000000..8e3cd698a --- /dev/null +++ b/.github/workflows/sync-to-nockchain.yml @@ -0,0 +1,20 @@ +name: Sync to nockchain/nockchain + +on: + push: + branches: + - master + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + persist-credentials: false + + - name: Push to nockchain/nockchain + run: | + git remote add nockchain https://bitemyapp:${{ secrets.NOCKCHAIN_SYNC_TOKEN }}@github.com/nockchain/nockchain.git + git push nockchain master:master diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 000000000..6d509fac6 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,110 @@ +stages: + - kernels + - test + - sync + +workflow: + rules: + - if: '$CI_COMMIT_BRANCH =~ /^ci\/open-sync-/' + - if: '$CI_COMMIT_BRANCH == "open"' + - if: '$CI_MERGE_REQUEST_ID' + - when: never + +default: + tags: + - hetzner + +variables: + RUST_LOG: "warn,slogger=off" + GIT_CLEAN_FLAGS: "-ffdx -e .bazel/" + +build-kernels: + stage: kernels + script: + - | + nix develop -c bash -c ' + set -euo pipefail + make update-hoonc + make assets/dumb.jam + make assets/miner.jam + make assets/peek.jam + make assets/wal.jam + make assets/bridge.jam + ' + artifacts: + paths: + - assets/*.jam + expire_in: 1 day + +cargo-test: + stage: test + needs: + - job: build-kernels + artifacts: true + script: + - | + nix develop -c bash -c ' + set -euo pipefail + export PATH="$HOME/.local/bin:$PATH" + # Download protoc if not available, then export PROTOC to wherever it is + if ! command -v protoc &> /dev/null; then + PROTOC_VERSION=25.1 + PROTOC_SHA256=ed8fca87a11c888fed329d6a59c34c7d436165f662a2c875246ddb1ac2b6dd50 + curl -sLO "https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/protoc-${PROTOC_VERSION}-linux-x86_64.zip" + if command -v sha256sum &> /dev/null; then + echo "${PROTOC_SHA256} protoc-${PROTOC_VERSION}-linux-x86_64.zip" | sha256sum -c - + elif command -v shasum &> /dev/null; then + echo "${PROTOC_SHA256} protoc-${PROTOC_VERSION}-linux-x86_64.zip" | shasum -a 256 -c - + else + echo "sha256sum or shasum is required to verify protoc download" >&2 + exit 1 + fi + unzip -q "protoc-${PROTOC_VERSION}-linux-x86_64.zip" -d "$HOME/.local" + rm "protoc-${PROTOC_VERSION}-linux-x86_64.zip" + fi + export PROTOC="$(command -v protoc)" + # Install Solidity dependencies for the bridge build script + make -C crates/bridge/contracts deps + cargo test --release + ' + +sync-to-staging: + stage: sync + needs: + - job: cargo-test + rules: + - if: '$CI_COMMIT_BRANCH == "open" && $CI_PIPELINE_SOURCE == "push"' + - if: '$CI_COMMIT_BRANCH == "open" && $FORCE_SYNC_TO_STAGING == "1"' + - if: '$CI_COMMIT_BRANCH == "open" && $CI_PIPELINE_SOURCE == "web"' + when: manual + - when: never + script: + - | + nix develop -c bash -c ' + set -euo pipefail + + # Guardrails for accidental private-code export. + if git ls-files | rg -q "^closed/"; then + echo "closed/ paths detected; refusing to sync to staging" >&2 + exit 1 + fi + + if git ls-files | rg -q "^open/"; then + echo "open/ subtree detected in open branch checkout; refusing to sync" >&2 + exit 1 + fi + + if [ -z "${GITHUB_NOCKCHAIN_STAGING_TOKEN:-}" ]; then + echo "GITHUB_NOCKCHAIN_STAGING_TOKEN is required for sync-to-staging" >&2 + exit 1 + fi + + cargo generate-lockfile + + echo "https://bitemyapp:${GITHUB_NOCKCHAIN_STAGING_TOKEN}@github.com" > ~/.git-credentials + git config --global credential.helper store + git config --global user.name "Zorp CI" + git config --global user.email "ci@zorp.corp" + + bash ./tools/sync_to_staging/sync_to_staging.sh + ' diff --git a/Cargo.lock b/Cargo.lock index 7c457ad8b..4afd26a6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -62,9 +62,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "alloy" -version = "1.1.3" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f07655fedc35188f3c50ff8fc6ee45703ae14ef1bc7ae7d80e23a747012184e3" +checksum = "07dc44b606f29348ce7c127e7f872a6d2df3cfeff85b7d6bba62faca75112fdd" dependencies = [ "alloy-consensus", "alloy-contract", @@ -73,6 +73,7 @@ dependencies = [ "alloy-genesis", "alloy-network", "alloy-provider", + "alloy-pubsub", "alloy-rpc-client", "alloy-rpc-types", "alloy-serde", @@ -80,14 +81,15 @@ dependencies = [ "alloy-signer-local", "alloy-transport", "alloy-transport-http", + "alloy-transport-ws", "alloy-trie", ] [[package]] name = "alloy-chains" -version = "0.2.23" +version = "0.2.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35d744058a9daa51a8cf22a3009607498fcf82d3cf4c5444dd8056cdf651f471" +checksum = "90f374d3c6d729268bbe2d0e0ff992bb97898b2df756691a62ee1d5f0506bc39" dependencies = [ "alloy-primitives", "num_enum", @@ -96,9 +98,9 @@ dependencies = [ [[package]] name = "alloy-consensus" -version = "1.1.3" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e318e25fb719e747a7e8db1654170fc185024f3ed5b10f86c08d448a912f6e2" +checksum = "4e4ff99651d46cef43767b5e8262ea228cd05287409ccb0c947cc25e70a952f9" dependencies = [ "alloy-eips", "alloy-primitives", @@ -118,14 +120,14 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "alloy-consensus-any" -version = "1.1.3" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "364380a845193a317bcb7a5398fc86cdb66c47ebe010771dde05f6869bf9e64a" +checksum = "1a0701b0eda8051a2398591113e7862f807ccdd3315d0b441f06c2a0865a379b" dependencies = [ "alloy-consensus", "alloy-eips", @@ -137,9 +139,9 @@ dependencies = [ [[package]] name = "alloy-contract" -version = "1.1.3" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d39c80ffc806f27a76ed42f3351a455f3dc4f81d6ff92c8aad2cf36b7d3a34" +checksum = "f3c83c7a3c4e1151e8cac383d0a67ddf358f37e5ea51c95a1283d897c9de0a5a" dependencies = [ "alloy-consensus", "alloy-dyn-abi", @@ -148,20 +150,21 @@ dependencies = [ "alloy-network-primitives", "alloy-primitives", "alloy-provider", + "alloy-pubsub", "alloy-rpc-types-eth", "alloy-sol-types", "alloy-transport", "futures", "futures-util", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "alloy-core" -version = "1.5.1" +version = "1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a651e1d9e50e6d0a78bd23cd08facb70459a94501c4036c7799a093e569a310" +checksum = "3bad0f48b9fe97029db0de15bb29a75f5f84848530673ba271b78216947d3877" dependencies = [ "alloy-dyn-abi", "alloy-json-abi", @@ -172,9 +175,9 @@ dependencies = [ [[package]] name = "alloy-dyn-abi" -version = "1.5.1" +version = "1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d48a9101f4a67c22fae57489f1ddf3057b8ab4a368d8eac3be088b6e9d9c9d9" +checksum = "e6ab1b2f1b48a7e6b3597cb2afae04f93879fb69d71e39736b5663d7366b23f2" dependencies = [ "alloy-json-abi", "alloy-primitives", @@ -196,7 +199,7 @@ dependencies = [ "alloy-rlp", "crc", "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -216,23 +219,37 @@ name = "alloy-eip7702" version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2919c5a56a1007492da313e7a3b6d45ef5edc5d33416fdec63c0d7a2702a0d20" +dependencies = [ + "alloy-primitives", + "alloy-rlp", + "borsh", + "k256", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "alloy-eip7928" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3231de68d5d6e75332b7489cfcc7f4dfabeba94d990a10e4b923af0e6623540" dependencies = [ "alloy-primitives", "alloy-rlp", "borsh", "serde", - "thiserror 2.0.17", ] [[package]] name = "alloy-eips" -version = "1.1.3" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4c4d7c5839d9f3a467900c625416b24328450c65702eb3d8caff8813e4d1d33" +checksum = "def1626eea28d48c6cc0a6f16f34d4af0001906e4f889df6c660b39c86fd044d" dependencies = [ "alloy-eip2124", "alloy-eip2930", "alloy-eip7702", + "alloy-eip7928", "alloy-primitives", "alloy-rlp", "alloy-serde", @@ -241,17 +258,19 @@ dependencies = [ "c-kzg", "derive_more", "either", + "ethereum_ssz", + "ethereum_ssz_derive", "serde", "serde_with", "sha2", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "alloy-genesis" -version = "1.1.3" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ba4b1be0988c11f0095a2380aa596e35533276b8fa6c9e06961bbfe0aebcac5" +checksum = "55d9d1aba3f914f0e8db9e4616ae37f3d811426d95bdccf44e47d0605ab202f6" dependencies = [ "alloy-eips", "alloy-primitives", @@ -264,9 +283,9 @@ dependencies = [ [[package]] name = "alloy-json-abi" -version = "1.5.1" +version = "1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9914c147bb9b25f440eca68a31dc29f5c22298bfa7754aa802965695384122b0" +checksum = "1e414aa37b335ad2acb78a95814c59d137d53139b412f87aed1e10e2d862cd49" dependencies = [ "alloy-primitives", "alloy-sol-type-parser", @@ -276,24 +295,24 @@ dependencies = [ [[package]] name = "alloy-json-rpc" -version = "1.1.3" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f72cf87cda808e593381fb9f005ffa4d2475552b7a6c5ac33d087bf77d82abd0" +checksum = "e57586581f2008933241d16c3e3f633168b3a5d2738c5c42ea5246ec5e0ef17a" dependencies = [ "alloy-primitives", "alloy-sol-types", "http", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", ] [[package]] name = "alloy-network" -version = "1.1.3" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12aeb37b6f2e61b93b1c3d34d01ee720207c76fe447e2a2c217e433ac75b17f5" +checksum = "3b36c2a0ed74e48851f78415ca5b465211bd678891ba11e88fee09eac534bab1" dependencies = [ "alloy-consensus", "alloy-consensus-any", @@ -312,14 +331,14 @@ dependencies = [ "futures-utils-wasm", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "alloy-network-primitives" -version = "1.1.3" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abd29ace62872083e30929cd9b282d82723196d196db589f3ceda67edcc05552" +checksum = "636c8051da58802e757b76c3b65af610b95799f72423dc955737dec73de234fd" dependencies = [ "alloy-consensus", "alloy-eips", @@ -330,9 +349,9 @@ dependencies = [ [[package]] name = "alloy-primitives" -version = "1.5.1" +version = "1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7db950a29746be9e2f2c6288c8bd7a6202a81f999ce109a2933d2379970ec0fa" +checksum = "66b1483f8c2562bf35f0270b697d5b5fe8170464e935bd855a4c5eaf6f89b354" dependencies = [ "alloy-rlp", "bytes", @@ -341,7 +360,7 @@ dependencies = [ "derive_more", "foldhash 0.2.0", "hashbrown 0.16.1", - "indexmap 2.12.1", + "indexmap 2.13.0", "itoa", "k256", "keccak-asm", @@ -353,14 +372,13 @@ dependencies = [ "rustc-hash 2.1.1", "serde", "sha3", - "tiny-keccak", ] [[package]] name = "alloy-provider" -version = "1.1.3" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b710636d7126e08003b8217e24c09f0cca0b46d62f650a841736891b1ed1fc1" +checksum = "b3dd56e2eafe8b1803e325867ac2c8a4c73c9fb5f341ffd8347f9344458c5922" dependencies = [ "alloy-chains", "alloy-consensus", @@ -369,12 +387,14 @@ dependencies = [ "alloy-network", "alloy-network-primitives", "alloy-primitives", + "alloy-pubsub", "alloy-rpc-client", "alloy-rpc-types-eth", "alloy-signer", "alloy-sol-types", "alloy-transport", "alloy-transport-http", + "alloy-transport-ws", "async-stream", "async-trait", "auto_impl", @@ -382,24 +402,46 @@ dependencies = [ "either", "futures", "futures-utils-wasm", - "lru 0.13.0", + "lru 0.16.3", "parking_lot", "pin-project", "reqwest", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "url", "wasmtimer", ] +[[package]] +name = "alloy-pubsub" +version = "1.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eebf54983d4fccea08053c218ee5c288adf2e660095a243d0532a8070b43955" +dependencies = [ + "alloy-json-rpc", + "alloy-primitives", + "alloy-transport", + "auto_impl", + "bimap", + "futures", + "parking_lot", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tower", + "tracing", + "wasmtimer", +] + [[package]] name = "alloy-rlp" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f70d83b765fdc080dbcd4f4db70d8d23fe4761f2f02ebfa9146b833900634b4" +checksum = "e93e50f64a77ad9c5470bf2ad0ca02f228da70c792a8f06634801e202579f35e" dependencies = [ "alloy-rlp-derive", "arrayvec", @@ -408,25 +450,27 @@ dependencies = [ [[package]] name = "alloy-rlp-derive" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64b728d511962dda67c1bc7ea7c03736ec275ed2cf4c35d9585298ac9ccf3b73" +checksum = "ce8849c74c9ca0f5a03da1c865e3eb6f768df816e67dd3721a398a8a7e398011" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] name = "alloy-rpc-client" -version = "1.1.3" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0882e72d2c1c0c79dcf4ab60a67472d3f009a949f774d4c17d0bdb669cfde05" +checksum = "91577235d341a1bdbee30a463655d08504408a4d51e9f72edbfc5a622829f402" dependencies = [ "alloy-json-rpc", "alloy-primitives", + "alloy-pubsub", "alloy-transport", "alloy-transport-http", + "alloy-transport-ws", "futures", "pin-project", "reqwest", @@ -442,9 +486,9 @@ dependencies = [ [[package]] name = "alloy-rpc-types" -version = "1.1.3" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39cf1398cb33aacb139a960fa3d8cf8b1202079f320e77e952a0b95967bf7a9f" +checksum = "79cff039bf01a17d76c0aace3a3a773d5f895eb4c68baaae729ec9da9e86c99c" dependencies = [ "alloy-primitives", "alloy-rpc-types-eth", @@ -454,20 +498,39 @@ dependencies = [ [[package]] name = "alloy-rpc-types-any" -version = "1.1.3" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a63fb40ed24e4c92505f488f9dd256e2afaed17faa1b7a221086ebba74f4122" +checksum = "73234a141ecce14e2989748c04fcac23deee67a445e2c4c167cfb42d4dacd1b6" dependencies = [ "alloy-consensus-any", "alloy-rpc-types-eth", "alloy-serde", ] +[[package]] +name = "alloy-rpc-types-engine" +version = "1.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10620d600cc46538f613c561ac9a923843c6c74c61f054828dcdb8dd18c72ec4" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-serde", + "derive_more", + "ethereum_ssz", + "ethereum_ssz_derive", + "rand 0.8.5", + "serde", + "strum 0.27.2", +] + [[package]] name = "alloy-rpc-types-eth" -version = "1.1.3" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9eae0c7c40da20684548cbc8577b6b7447f7bf4ddbac363df95e3da220e41e72" +checksum = "010e101dbebe0c678248907a2545b574a87d078d82c2f6f5d0e8e7c9a6149a10" dependencies = [ "alloy-consensus", "alloy-consensus-any", @@ -481,14 +544,14 @@ dependencies = [ "serde", "serde_json", "serde_with", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "alloy-serde" -version = "1.1.3" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0df1987ed0ff2d0159d76b52e7ddfc4e4fbddacc54d2fbee765e0d14d7c01b5" +checksum = "9e6d631f8b975229361d8af7b2c749af31c73b3cf1352f90e144ddb06227105e" dependencies = [ "alloy-primitives", "serde", @@ -497,9 +560,9 @@ dependencies = [ [[package]] name = "alloy-signer" -version = "1.1.3" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ff69deedee7232d7ce5330259025b868c5e6a52fa8dffda2c861fb3a5889b24" +checksum = "97f40010b5e8f79b70bf163b38cd15f529b18ca88c4427c0e43441ee54e4ed82" dependencies = [ "alloy-primitives", "async-trait", @@ -507,14 +570,14 @@ dependencies = [ "either", "elliptic-curve", "k256", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "alloy-signer-local" -version = "1.1.3" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72cfe0be3ec5a8c1a46b2e5a7047ed41121d360d97f4405bb7c1c784880c86cb" +checksum = "9c4ec1cc27473819399a3f0da83bc1cef0ceaac8c1c93997696e46dc74377a58" dependencies = [ "alloy-consensus", "alloy-network", @@ -523,47 +586,47 @@ dependencies = [ "async-trait", "k256", "rand 0.8.5", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "alloy-sol-macro" -version = "1.5.1" +version = "1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3b96d5f5890605ba9907ce1e2158e2701587631dc005bfa582cf92dd6f21147" +checksum = "2c4b64c8146291f750c3f391dff2dd40cf896f7e2b253417a31e342aa7265baa" dependencies = [ "alloy-sol-macro-expander", "alloy-sol-macro-input", "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] name = "alloy-sol-macro-expander" -version = "1.5.1" +version = "1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8247b7cca5cde556e93f8b3882b01dbd272f527836049083d240c57bf7b4c15" +checksum = "d9df903674682f9bae8d43fdea535ab48df2d6a8cb5104ca29c58ada22ef67b3" dependencies = [ "alloy-json-abi", "alloy-sol-macro-input", "const-hex", "heck", - "indexmap 2.12.1", + "indexmap 2.13.0", "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.111", + "sha3", + "syn 2.0.115", "syn-solidity", - "tiny-keccak", ] [[package]] name = "alloy-sol-macro-input" -version = "1.5.1" +version = "1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cd54f38512ac7bae10bbc38480eefb1b9b398ca2ce25db9cc0c048c6411c4f1" +checksum = "737b8a959f527a86e07c44656db237024a32ae9b97d449f788262a547e8aa136" dependencies = [ "alloy-json-abi", "const-hex", @@ -573,15 +636,15 @@ dependencies = [ "proc-macro2", "quote", "serde_json", - "syn 2.0.111", + "syn 2.0.115", "syn-solidity", ] [[package]] name = "alloy-sol-type-parser" -version = "1.5.1" +version = "1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "444b09815b44899564566d4d56613d14fa9a274b1043a021f00468568752f449" +checksum = "b28e6e86c6d2db52654b65a5a76b4f57eae5a32a7f0aa2222d1dbdb74e2cb8e0" dependencies = [ "serde", "winnow", @@ -589,9 +652,9 @@ dependencies = [ [[package]] name = "alloy-sol-types" -version = "1.5.1" +version = "1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1038284171df8bfd48befc0c7b78f667a7e2be162f45f07bd1c378078ebe58" +checksum = "fdf7effe4ab0a4f52c865959f790036e61a7983f68b13b75d7fbcedf20b753ce" dependencies = [ "alloy-json-abi", "alloy-primitives", @@ -601,9 +664,9 @@ dependencies = [ [[package]] name = "alloy-transport" -version = "1.1.3" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be98b07210d24acf5b793c99b759e9a696e4a2e67593aec0487ae3b3e1a2478c" +checksum = "a03bb3f02b9a7ab23dacd1822fa7f69aa5c8eefcdcf57fad085e0b8d76fb4334" dependencies = [ "alloy-json-rpc", "auto_impl", @@ -614,7 +677,7 @@ dependencies = [ "parking_lot", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tower", "tracing", @@ -624,12 +687,13 @@ dependencies = [ [[package]] name = "alloy-transport-http" -version = "1.1.3" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4198a1ee82e562cab85e7f3d5921aab725d9bd154b6ad5017f82df1695877c97" +checksum = "5ce599598ef8ebe067f3627509358d9faaa1ef94f77f834a7783cd44209ef55c" dependencies = [ "alloy-json-rpc", "alloy-transport", + "itertools 0.14.0", "reqwest", "serde_json", "tower", @@ -637,11 +701,28 @@ dependencies = [ "url", ] +[[package]] +name = "alloy-transport-ws" +version = "1.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ed38ea573c6658e0c2745af9d1f1773b1ed83aa59fbd9c286358ad469c3233a" +dependencies = [ + "alloy-pubsub", + "alloy-transport", + "futures", + "http", + "serde_json", + "tokio", + "tokio-tungstenite", + "tracing", + "ws_stream_wasm", +] + [[package]] name = "alloy-trie" -version = "0.9.1" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3412d52bb97c6c6cc27ccc28d4e6e8cf605469101193b50b0bd5813b1f990b5" +checksum = "4d7fd448ab0a017de542de1dcca7a58e7019fe0e7a34ed3f9543ebddf6aceffa" dependencies = [ "alloy-primitives", "alloy-rlp", @@ -650,19 +731,20 @@ dependencies = [ "nybbles", "serde", "smallvec", + "thiserror 2.0.18", "tracing", ] [[package]] name = "alloy-tx-macros" -version = "1.1.3" +version = "1.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "333544408503f42d7d3792bfc0f7218b643d968a03d2c0ed383ae558fb4a76d0" +checksum = "397406cf04b11ca2a48e6f81804c70af3f40a36abf648e11dc7416043eb0834d" dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -732,9 +814,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.100" +version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" [[package]] name = "arboard" @@ -758,9 +840,12 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.7.1" +version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" +checksum = "9ded5f9a03ac8f24d1b8a25101ee812cd32cdc8c50a4c50237de2c4915850e73" +dependencies = [ + "rustversion", +] [[package]] name = "argon2" @@ -859,7 +944,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62945a2f7e6de02a31fe400aa489f0e0f5b2502e69f95f853adb82a96c7a6b60" dependencies = [ "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -897,7 +982,7 @@ dependencies = [ "num-traits", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -996,7 +1081,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", ] @@ -1008,7 +1093,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", "synstructure", ] @@ -1020,14 +1105,14 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] name = "assert_cmd" -version = "2.1.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcbb6924530aa9e0432442af08bbcafdad182db80d2e560da42a6d442535bf85" +checksum = "9c5bcfa8749ac45dd12cb11055aeeb6b27a3895560d60d71e3c23bf979e60514" dependencies = [ "anstyle", "bstr", @@ -1051,7 +1136,7 @@ dependencies = [ "futures-lite", "parking", "polling", - "rustix 1.1.2", + "rustix 1.1.3", "slab", "windows-sys 0.61.2", ] @@ -1064,7 +1149,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -1086,7 +1171,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -1097,7 +1182,18 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", +] + +[[package]] +name = "async_io_stream" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d7b9decdf35d8908a7e3ef02f64c5e9b1695e230154c0e8de3969142d9b94c" +dependencies = [ + "futures", + "pharos", + "rustc_version 0.4.1", ] [[package]] @@ -1139,7 +1235,7 @@ checksum = "ffdcb70bdbc4d478427380519163274ac86e52916e10f0a8889adf0f96d3fee7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -1159,9 +1255,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.15.2" +version = "1.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a88aab2464f1f25453baa7a07c84c5b7684e274054ba06817f382357f77a288" +checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" dependencies = [ "aws-lc-sys", "zeroize", @@ -1169,9 +1265,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.35.0" +version = "0.37.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b45afffdee1e7c9126814751f88dddc747f41d91da16c9551a0f1e8a11e788a1" +checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" dependencies = [ "cc", "cmake", @@ -1181,9 +1277,9 @@ dependencies = [ [[package]] name = "axum" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b098575ebe77cb6d14fc7f32749631a6e44edbef6b796f89b020e99ba20d425" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" dependencies = [ "axum-core", "bytes", @@ -1214,9 +1310,9 @@ dependencies = [ [[package]] name = "axum-core" -version = "0.5.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59446ce19cd142f8833f856eb31f3eb097812d1479ab224f54d72428ca21ea22" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" dependencies = [ "bytes", "futures-core", @@ -1253,6 +1349,17 @@ dependencies = [ "tower-service", ] +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + [[package]] name = "base-x" version = "0.2.11" @@ -1283,9 +1390,15 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "base64ct" -version = "1.8.1" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e050f626429857a27ddccb31e0aca21356bfa709c04041aefddac081a8f068a" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bimap" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230c5f1ca6a325a32553f8640d31ac9b49f2411e901e427570154868b46da4f7" [[package]] name = "bincode" @@ -1326,7 +1439,7 @@ dependencies = [ "regex", "rustc-hash 1.1.0", "shlex", - "syn 2.0.111", + "syn 2.0.115", "which 4.4.2", ] @@ -1399,15 +1512,16 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.2" +version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3888aaa89e4b2a40fca9848e400f6a658a5a3978de7be858e209cafa8be9a4a0" +checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" dependencies = [ "arrayref", "arrayvec", "cc", "cfg-if", "constant_time_eq", + "cpufeatures", "serde", ] @@ -1452,7 +1566,64 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", +] + +[[package]] +name = "bridge" +version = "0.1.0" +dependencies = [ + "alloy", + "anyhow", + "async-trait", + "backon", + "bincode", + "blake3", + "bs58", + "chrono", + "clap", + "crossterm 0.29.0", + "deadpool-diesel", + "diesel", + "futures", + "getrandom 0.3.4", + "gnort", + "hex", + "ibig", + "kernels-open-bridge", + "libsqlite3-sys", + "nockapp", + "nockapp-grpc", + "nockchain-libp2p-io", + "nockchain-math", + "nockchain-types", + "nockvm", + "nockvm_macros", + "noun-serde", + "nu-ansi-term", + "num-bigint", + "op-alloy", + "prost 0.14.3", + "ratatui 0.29.0", + "rustls", + "serde", + "serde_bytes", + "serde_json", + "snmalloc-rs", + "tempfile", + "thiserror 2.0.18", + "tikv-jemallocator", + "tiny-keccak", + "tokio", + "toml", + "tonic 0.14.3", + "tonic-prost", + "tonic-prost-build", + "tonic-reflection", + "tracing", + "tracing-appender", + "tracing-subscriber", + "zkvm-jetpack", ] [[package]] @@ -1507,14 +1678,14 @@ checksum = "89385e82b5d1821d2219e0b095efa2cc1f246cbf99080f3be46a1a85c0d392d9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] name = "bytemuck" -version = "1.24.0" +version = "1.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" [[package]] name = "byteorder" @@ -1530,9 +1701,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.0" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" dependencies = [ "serde", ] @@ -1593,9 +1764,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.50" +version = "1.2.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f50d563227a1c37cc0a263f64eca3334388c01c5e4c4861a9def205c614383c" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", "jobserver", @@ -1626,9 +1797,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" dependencies = [ "iana-time-zone", "js-sys", @@ -1688,9 +1859,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.53" +version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +checksum = "63be97961acde393029492ce0be7a1af7e323e6bae9511ebfac33751be5e6806" dependencies = [ "clap_builder", "clap_derive", @@ -1698,9 +1869,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.53" +version = "4.5.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +checksum = "7f13174bda5dfd69d7e947827e5af4b0f2f94a4a3ee92912fba07a66150f21e2" dependencies = [ "anstream", "anstyle", @@ -1710,21 +1881,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.49" +version = "4.5.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] name = "clap_lex" -version = "0.7.6" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" [[package]] name = "clipboard-win" @@ -1847,7 +2018,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "once_cell", "tiny-keccak", ] @@ -1880,9 +2051,9 @@ dependencies = [ [[package]] name = "constant_time_eq" -version = "0.3.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] name = "convert_case" @@ -2015,9 +2186,9 @@ dependencies = [ [[package]] name = "crokey" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51360853ebbeb3df20c76c82aecf43d387a62860f1a59ba65ab51f00eea85aad" +checksum = "04a63daf06a168535c74ab97cdba3ed4fa5d4f32cb36e437dcceb83d66854b7c" dependencies = [ "crokey-proc_macros", "crossterm 0.29.0", @@ -2028,15 +2199,15 @@ dependencies = [ [[package]] name = "crokey-proc_macros" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bf1a727caeb5ee5e0a0826a97f205a9cf84ee964b0b48239fef5214a00ae439" +checksum = "847f11a14855fc490bd5d059821895c53e77eeb3c2b73ee3dded7ce77c93b231" dependencies = [ "crossterm 0.29.0", "proc-macro2", "quote", "strict", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -2123,7 +2294,7 @@ dependencies = [ "document-features", "mio 1.1.1", "parking_lot", - "rustix 1.1.2", + "rustix 1.1.3", "signal-hook", "signal-hook-mio", "winapi", @@ -2200,7 +2371,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -2223,6 +2394,16 @@ dependencies = [ "darling_macro 0.21.3", ] +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + [[package]] name = "darling_core" version = "0.20.11" @@ -2234,7 +2415,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -2249,7 +2430,20 @@ dependencies = [ "quote", "serde", "strsim", - "syn 2.0.111", + "syn 2.0.115", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.115", ] [[package]] @@ -2260,7 +2454,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -2271,7 +2465,18 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.111", + "syn 2.0.115", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core 0.23.0", + "quote", + "syn 2.0.115", ] [[package]] @@ -2290,15 +2495,15 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" [[package]] name = "data-encoding-macro" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47ce6c96ea0102f01122a185683611bd5ac8d99e62bc59dd12e6bda344ee673d" +checksum = "8142a83c17aa9461d637e649271eae18bf2edd00e91f2e105df36c3c16355bdb" dependencies = [ "data-encoding", "data-encoding-macro-internal", @@ -2306,12 +2511,12 @@ dependencies = [ [[package]] name = "data-encoding-macro-internal" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d162beedaa69905488a8da94f5ac3edb4dd4788b732fadb7bd120b2625c1976" +checksum = "7ab67060fc6b8ef687992d439ca0fa36e7ed17e9a0b16b25b601e8757df720de" dependencies = [ "data-encoding", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -2323,6 +2528,47 @@ dependencies = [ "generic-array", ] +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-diesel" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590573e9e29c5190a5ff782136f871e6e652e35d598a349888e028693601adf1" +dependencies = [ + "deadpool", + "deadpool-sync", + "diesel", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +dependencies = [ + "tokio", +] + +[[package]] +name = "deadpool-sync" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524bc3df0d57e98ecd022e21ba31166c2625e7d3e5bcc4510efaeeab4abcab04" +dependencies = [ + "deadpool-runtime", +] + [[package]] name = "der" version = "0.7.10" @@ -2349,9 +2595,9 @@ dependencies = [ [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" dependencies = [ "powerfmt", "serde_core", @@ -2386,7 +2632,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -2396,32 +2642,67 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] name = "derive_more" -version = "2.1.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10b768e943bed7bf2cab53df09f4bc34bfd217cdb57d971e769874c9a6710618" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" dependencies = [ "derive_more-impl", ] [[package]] name = "derive_more-impl" -version = "2.1.0" +version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d286bfdaf75e988b4a78e013ecd79c581e06399ab53fbacd2d916c2f904f30b" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version 0.4.1", - "syn 2.0.111", + "syn 2.0.115", "unicode-xid", ] +[[package]] +name = "diesel" +version = "2.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b6c2fc184a6fb6ebcf5f9a5e3bbfa84d8fd268cdfcce4ed508979a6259494d" +dependencies = [ + "diesel_derives", + "downcast-rs", + "libsqlite3-sys", + "sqlite-wasm-rs", + "time", +] + +[[package]] +name = "diesel_derives" +version = "2.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47618bf0fac06bb670c036e48404c26a865e6a71af4114dfd97dfe89936e404e" +dependencies = [ + "diesel_table_macro_syntax", + "dsl_auto_type", + "proc-macro2", + "quote", + "syn 2.0.115", +] + +[[package]] +name = "diesel_table_macro_syntax" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" +dependencies = [ + "syn 2.0.115", +] + [[package]] name = "difflib" version = "0.4.0" @@ -2488,7 +2769,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -2511,19 +2792,39 @@ dependencies = [ [[package]] name = "dogstatsd" -version = "0.12.1" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "faeb22dbbf6aef8e687ad83f9cd9fd893ac5c0f36d2e46fb0cbcf7ae19640ec0" +checksum = "9875925e718131ca660db072b66ce689cf60f216117704a3f126e6cf120830e4" dependencies = [ "chrono", "retry", ] +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + +[[package]] +name = "dsl_auto_type" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" +dependencies = [ + "darling 0.21.3", + "either", + "heck", + "proc-macro2", + "quote", + "syn 2.0.115", +] + [[package]] name = "dtoa" -version = "1.0.10" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6add3b8cff394282be81f3fc1a0605db594ed69890078ca6e2cab1c408bcf04" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" [[package]] name = "dunce" @@ -2549,7 +2850,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -2612,7 +2913,7 @@ dependencies = [ "enum-ordinalize", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -2662,7 +2963,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -2682,7 +2983,17 @@ checksum = "8ca9601fb2d62598ee17836250842873a413586e5d7ed88b356e38ddbb0ec631" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", +] + +[[package]] +name = "env_filter" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f" +dependencies = [ + "log", + "regex", ] [[package]] @@ -2693,12 +3004,12 @@ checksum = "c7f84e12ccf0a7ddc17a6c41c93326024c42920d7ee630d04950e6926645c0fe" [[package]] name = "env_logger" -version = "0.8.4" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a19187fea3ac7e84da7dacf48de0c45d63c6a76f9490dae389aead16c243fce3" +checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d" dependencies = [ + "env_filter", "log", - "regex", ] [[package]] @@ -2716,7 +3027,7 @@ dependencies = [ "arrayvec", "hashx", "num-traits", - "thiserror 2.0.17", + "thiserror 2.0.18", "visibility", ] @@ -2755,6 +3066,46 @@ version = "3.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" +[[package]] +name = "ethereum_serde_utils" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dc1355dbb41fbbd34ec28d4fb2a57d9a70c67ac3c19f6a5ca4d4a176b9e997a" +dependencies = [ + "alloy-primitives", + "hex", + "serde", + "serde_derive", + "serde_json", +] + +[[package]] +name = "ethereum_ssz" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0dcddb2554d19cde19b099fadddde576929d7a4d0c1cd3512d1fd95cf174375c" +dependencies = [ + "alloy-primitives", + "ethereum_serde_utils", + "itertools 0.13.0", + "serde", + "serde_derive", + "smallvec", + "typenum", +] + +[[package]] +name = "ethereum_ssz_derive" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a657b6b3b7e153637dc6bdc6566ad9279d9ee11a15b12cfb24a2e04360637e9f" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.115", +] + [[package]] name = "fastrand" version = "2.3.0" @@ -2800,7 +3151,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -2830,21 +3181,20 @@ checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" [[package]] name = "filetime" -version = "0.2.26" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc0505cd1b6fa6580283f6bdf70a73fcf4aba1184038c90902b92b3dd0df63ed" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" dependencies = [ "cfg-if", "libc", "libredox", - "windows-sys 0.60.2", ] [[package]] name = "find-msvc-tools" -version = "0.1.5" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a3076410a55c90011c298b04d0cfa770b00fa04e1e3c97d3f6c9de105a03844" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" [[package]] name = "fixed-capacity-vec" @@ -2872,9 +3222,9 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.5" +version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe33edd8e85a12a67454e37f8c75e730830d83e313556ab9ebf9ee7fbeb3bfb" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", @@ -2918,9 +3268,9 @@ dependencies = [ [[package]] name = "fs-err" -version = "3.2.1" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "824f08d01d0f496b3eca4f001a13cf17690a6ee930043d20817f547455fd98f8" +checksum = "73fde052dbfc920003cfd2c8e2c6e6d4cc7c1091538c3a24226cec0665ab08c0" dependencies = [ "autocfg", "tokio", @@ -3024,7 +3374,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -3112,15 +3462,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "rustix 1.1.2", + "rustix 1.1.3", "windows-link", ] [[package]] name = "getrandom" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", "js-sys", @@ -3143,12 +3493,38 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "rand_core 0.10.0", + "wasip2", + "wasip3", +] + [[package]] name = "glob" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "gnort" version = "0.2.0" @@ -3162,7 +3538,7 @@ dependencies = [ "maplit", "nonzero_ext", "once_cell", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -3201,9 +3577,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c0b69cfcb4e1b9f1bf2f53f95f766e4661169728ec61cd3fe5a0166f2d1386" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" dependencies = [ "atomic-waker", "bytes", @@ -3211,7 +3587,7 @@ dependencies = [ "futures-core", "futures-sink", "http", - "indexmap 2.12.1", + "indexmap 2.13.0", "slab", "tokio", "tokio-util", @@ -3237,9 +3613,9 @@ dependencies = [ [[package]] name = "handlebars" -version = "6.3.2" +version = "6.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "759e2d5aea3287cb1190c8ec394f42866cb5bf74fcbf213f354e3c856ea26098" +checksum = "9b3f9296c208515b87bd915a2f5d1163d4b3f863ba83337d7713cf478055948e" dependencies = [ "derive_builder", "log", @@ -3248,7 +3624,7 @@ dependencies = [ "pest_derive", "serde", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -3280,6 +3656,8 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.2.0", "serde", "serde_core", @@ -3305,8 +3683,8 @@ dependencies = [ "dynasmrt", "fixed-capacity-vec", "hex", - "rand_core 0.9.3", - "thiserror 2.0.17", + "rand_core 0.9.5", + "thiserror 2.0.18", ] [[package]] @@ -3361,7 +3739,7 @@ dependencies = [ "once_cell", "rand 0.9.2", "socket2 0.5.10", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tokio", "tracing", @@ -3384,7 +3762,7 @@ dependencies = [ "rand 0.9.2", "resolv-conf", "smallvec", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", ] @@ -3444,7 +3822,7 @@ dependencies = [ "nockvm", "nockvm_macros", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", "tracing-subscriber", @@ -3541,7 +3919,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", - "webpki-roots", + "webpki-roots 1.0.6", ] [[package]] @@ -3559,14 +3937,13 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.19" +version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ "base64", "bytes", "futures-channel", - "futures-core", "futures-util", "http", "http-body", @@ -3575,7 +3952,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.1", + "socket2 0.6.2", "tokio", "tower-service", "tracing", @@ -3583,9 +3960,9 @@ dependencies = [ [[package]] name = "iana-time-zone" -version = "0.1.64" +version = "0.1.65" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" dependencies = [ "android_system_properties", "core-foundation-sys", @@ -3698,6 +4075,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -3810,7 +4193,7 @@ checksum = "a0eb5a3343abf848c0984fe4604b2b105da9539376e24fc0a3b0007411ae4fd9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -3826,9 +4209,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.12.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ad4bb2b565bca0645f4d68c5c9af97fba094e9791da685bf83cb5f3ce74acf2" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown 0.16.1", @@ -3876,15 +4259,15 @@ dependencies = [ [[package]] name = "instability" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6778b0196eefee7df739db78758e5cf9b37412268bfa5650bfeed028aed20d9c" +checksum = "357b7205c6cd18dd2c86ed312d1e70add149aea98e7ef72b9fdf0270e555c11d" dependencies = [ - "darling 0.20.11", + "darling 0.23.0", "indoc", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -3935,9 +4318,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iri-string" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" dependencies = [ "memchr", "serde", @@ -3998,9 +4381,9 @@ dependencies = [ [[package]] name = "itoa" -version = "1.0.15" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" [[package]] name = "jobserver" @@ -4014,9 +4397,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.83" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" dependencies = [ "once_cell", "wasm-bindgen", @@ -4049,25 +4432,41 @@ dependencies = [ [[package]] name = "keccak" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc2af9a1119c51f12a14607e783cb977bde58bc069ff0c3da1095e635d70654" +checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653" dependencies = [ "cpufeatures", ] [[package]] name = "keccak-asm" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "505d1856a39b200489082f90d897c3f07c455563880bc5952e38eabf731c83b6" +checksum = "b646a74e746cd25045aa0fd42f4f7f78aa6d119380182c7e63a5593c4ab8df6f" dependencies = [ "digest 0.10.7", "sha3-asm", ] [[package]] -name = "kernels" +name = "kernels-open-bridge" +version = "0.1.0" + +[[package]] +name = "kernels-open-dumb" +version = "0.1.0" + +[[package]] +name = "kernels-open-miner" +version = "0.1.0" + +[[package]] +name = "kernels-open-nockchain-peek" +version = "0.1.0" + +[[package]] +name = "kernels-open-wallet" version = "0.1.0" [[package]] @@ -4092,9 +4491,9 @@ dependencies = [ [[package]] name = "lazy-regex" -version = "3.4.2" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "191898e17ddee19e60bccb3945aa02339e81edd4a8c50e21fd4d48cdecda7b29" +checksum = "6bae91019476d3ec7147de9aa291cadb6d870abf2f3015d2da73a90325ac1496" dependencies = [ "lazy-regex-proc_macros", "once_cell", @@ -4103,14 +4502,14 @@ dependencies = [ [[package]] name = "lazy-regex-proc_macros" -version = "3.4.2" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c35dc8b0da83d1a9507e12122c80dea71a9c7c613014347392483a83ea593e04" +checksum = "4de9c1e1439d8b7b3061b2d209809f447ca33241733d9a3c01eabf2dc8d94358" dependencies = [ "proc-macro2", "quote", "regex", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -4125,11 +4524,17 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" -version = "0.2.178" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] name = "libloading" @@ -4143,9 +4548,9 @@ dependencies = [ [[package]] name = "libm" -version = "0.2.15" +version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9fbbcab51052fe104eb5e5d351cf728d30a5be1fe14d9be8a3b097481fb97de" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libp2p" @@ -4156,7 +4561,7 @@ dependencies = [ "either", "futures", "futures-timer", - "getrandom 0.2.16", + "getrandom 0.2.17", "libp2p-allow-block-list", "libp2p-connection-limits", "libp2p-core", @@ -4178,7 +4583,7 @@ dependencies = [ "multiaddr", "pin-project", "rw-stream-sink", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -4219,7 +4624,7 @@ dependencies = [ "quick-protobuf", "rand 0.8.5", "rw-stream-sink", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "unsigned-varint", "web-time", @@ -4256,7 +4661,7 @@ dependencies = [ "quick-protobuf", "quick-protobuf-codec", "smallvec", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", ] @@ -4273,7 +4678,7 @@ dependencies = [ "quick-protobuf", "rand 0.8.5", "sha2", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "zeroize", ] @@ -4298,7 +4703,7 @@ dependencies = [ "rand 0.8.5", "sha2", "smallvec", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "uint 0.10.0", "web-time", @@ -4393,7 +4798,7 @@ dependencies = [ "ring", "rustls", "socket2 0.5.10", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tracing", ] @@ -4444,7 +4849,7 @@ source = "git+https://github.com/libp2p/rust-libp2p.git?rev=da0017ee887a868e231e dependencies = [ "heck", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -4475,7 +4880,7 @@ dependencies = [ "ring", "rustls", "rustls-webpki", - "thiserror 2.0.17", + "thiserror 2.0.18", "x509-parser 0.17.0", "yasna", ] @@ -4496,13 +4901,24 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.11" +version = "0.1.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df15f6eac291ed1cf25865b1ee60399f57e7c227e7f51bdbd4c5270396a9ed50" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" dependencies = [ "bitflags 2.10.0", "libc", - "redox_syscall 0.6.0", + "redox_syscall 0.7.1", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133c182a6a2c87864fe97778797e46c7e999672690dc9fa3ee8e241aa4a9c13f" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", ] [[package]] @@ -4568,11 +4984,11 @@ dependencies = [ [[package]] name = "lru" -version = "0.13.0" +version = "0.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "227748d55f2f0ab4735d87fd623798cb6b664512fe979705f829c9f81c934465" +checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -4589,7 +5005,7 @@ checksum = "1b27834086c65ec3f9387b096d66e99f221cf081c2b738042aa252bcd41204e3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -4600,13 +5016,13 @@ checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" [[package]] name = "match-lookup" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1265724d8cb29dbbc2b0f06fffb8bf1a8c0cf73a78eede9ba73a4a66c52a981e" +checksum = "757aee279b8bdbb9f9e676796fd459e4207a1f986e87886700abf589f5abf771" dependencies = [ "proc-macro2", "quote", - "syn 1.0.109", + "syn 2.0.115", ] [[package]] @@ -4626,9 +5042,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" [[package]] name = "memmap2" @@ -4716,9 +5132,9 @@ dependencies = [ [[package]] name = "moka" -version = "0.12.11" +version = "0.12.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8261cd88c312e0004c1d51baad2980c66528dfdb2bee62003e643a4d8f86b077" +checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" dependencies = [ "crossbeam-channel", "crossbeam-epoch", @@ -4726,7 +5142,6 @@ dependencies = [ "equivalent", "parking_lot", "portable-atomic", - "rustc_version 0.4.1", "smallvec", "tagptr", "uuid", @@ -4819,7 +5234,7 @@ checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -4886,17 +5301,17 @@ dependencies = [ "log", "netlink-packet-core", "netlink-sys", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] name = "netlink-sys" -version = "0.8.7" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16c903aa70590cb93691bf97a767c8d1d6122d2cc9070433deb3bbf36ce8bd23" +checksum = "cd6c30ed10fa69cc491d491b85cc971f6bdeb8e7367b7cde2ee6cc878d583fae" dependencies = [ "bytes", - "futures", + "futures-util", "libc", "log", "tokio", @@ -4950,7 +5365,7 @@ dependencies = [ "opentelemetry-otlp", "opentelemetry_sdk", "rand 0.9.2", - "rcgen 0.14.6", + "rcgen 0.14.7", "rustls", "rustls-pki-types", "serde", @@ -4959,18 +5374,18 @@ dependencies = [ "signal-hook-tokio", "tempfile", "termimad", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tokio-rustls", "tokio-util", - "tonic 0.14.2", + "tonic 0.14.3", "tower-http", "tracing", "tracing-opentelemetry", "tracing-subscriber", "tracing-test", "tracing-tracy", - "webpki-roots", + "webpki-roots 1.0.6", "x509-parser 0.17.0", "yaque", ] @@ -4996,15 +5411,15 @@ dependencies = [ "nockvm_macros", "noun-serde", "once_cell", - "prost 0.14.1", + "prost 0.14.3", "prost-build", "prost-types", "serde", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tokio-stream", - "tonic 0.14.2", + "tonic 0.14.3", "tonic-build", "tonic-health", "tonic-prost", @@ -5022,11 +5437,11 @@ dependencies = [ "glob", "nockchain-math", "nockchain-types", - "prost 0.14.1", + "prost 0.14.3", "prost-build", "prost-types", - "thiserror 2.0.17", - "tonic 0.14.2", + "thiserror 2.0.18", + "tonic 0.14.3", "tonic-build", "tonic-prost", "tonic-prost-build", @@ -5041,7 +5456,8 @@ dependencies = [ "equix", "futures", "ibig", - "kernels", + "kernels-open-dumb", + "kernels-open-miner", "libp2p", "nockapp", "nockapp-grpc", @@ -5067,7 +5483,7 @@ name = "nockchain-api" version = "0.1.0" dependencies = [ "clap", - "kernels", + "kernels-open-dumb", "nockapp", "nockapp-grpc-proto", "nockchain", @@ -5077,7 +5493,7 @@ dependencies = [ "tempfile", "tikv-jemallocator", "tokio", - "tonic 0.14.2", + "tonic 0.14.3", "tracy-client", "zkvm-jetpack", ] @@ -5094,9 +5510,10 @@ dependencies = [ "nockapp-grpc-proto", "nockchain-math", "nockchain-types", - "ratatui", + "ratatui 0.28.1", + "rustls", "tokio", - "tonic 0.14.2", + "tonic 0.14.3", "tracing", "tracing-subscriber", "tracing-tracy", @@ -5151,7 +5568,7 @@ dependencies = [ "quickcheck", "rkyv", "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", ] @@ -5160,7 +5577,7 @@ name = "nockchain-peek" version = "0.1.0" dependencies = [ "clap", - "kernels", + "kernels-open-nockchain-peek", "nockapp", "nockapp-grpc", "nockvm", @@ -5189,7 +5606,7 @@ dependencies = [ "num-bigint", "quickcheck", "serde", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "tracing-subscriber", ] @@ -5204,7 +5621,7 @@ dependencies = [ "either", "getrandom 0.3.4", "http", - "kernels", + "kernels-open-wallet", "nockapp", "nockapp-grpc", "nockchain-math", @@ -5217,9 +5634,9 @@ dependencies = [ "serde_json", "tempfile", "termimad", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", - "tonic 0.14.2", + "tonic 0.14.3", "tracing", "tracing-subscriber", "zkvm-jetpack", @@ -5250,7 +5667,7 @@ dependencies = [ "sha1", "tar", "tempfile", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "toml", "which 8.0.0", @@ -5281,7 +5698,7 @@ dependencies = [ "serde", "slotmap", "static_assertions", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", "tracing-core", ] @@ -5305,7 +5722,7 @@ name = "nockvm_macros" version = "0.1.0" dependencies = [ "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -5356,7 +5773,7 @@ dependencies = [ "ibig", "nockvm", "noun-serde-derive", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", ] @@ -5369,15 +5786,15 @@ dependencies = [ "noun-serde", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", "tracing", ] [[package]] name = "ntapi" -version = "0.4.1" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a3895c6391c39d7fe7ebc444a87eb2991b2a0bc718fdabd071eec617fc68e4" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" dependencies = [ "winapi", ] @@ -5403,9 +5820,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.1.0" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" [[package]] name = "num-derive" @@ -5415,7 +5832,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -5480,14 +5897,14 @@ checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] name = "nybbles" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c4b5ecbd0beec843101bffe848217f770e8b8da81d8355b7d6e226f2199b3dc" +checksum = "0d49ff0c0d00d4a502b39df9af3a525e1efeb14b9dabb5bb83335284c1309210" dependencies = [ "alloy-rlp", "cfg-if", @@ -5597,17 +6014,120 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "op-alloy" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9b8fee21003dd4f076563de9b9d26f8c97840157ef78593cd7f262c5ca99848" +dependencies = [ + "op-alloy-consensus", + "op-alloy-network", + "op-alloy-provider", + "op-alloy-rpc-types", + "op-alloy-rpc-types-engine", +] + +[[package]] +name = "op-alloy-consensus" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "736381a95471d23e267263cfcee9e1d96d30b9754a94a2819148f83379de8a86" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-network", + "alloy-primitives", + "alloy-rlp", + "alloy-rpc-types-eth", + "alloy-serde", + "derive_more", + "serde", + "thiserror 2.0.18", +] + +[[package]] +name = "op-alloy-network" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4034183dca6bff6632e7c24c92e75ff5f0eabb58144edb4d8241814851334d47" +dependencies = [ + "alloy-consensus", + "alloy-network", + "alloy-primitives", + "alloy-provider", + "alloy-rpc-types-eth", + "alloy-signer", + "op-alloy-consensus", + "op-alloy-rpc-types", +] + +[[package]] +name = "op-alloy-provider" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6753d90efbaa8ea8bcb89c1737408ca85fa60d7adb875049d3f382c063666f86" +dependencies = [ + "alloy-network", + "alloy-primitives", + "alloy-provider", + "alloy-rpc-types-engine", + "alloy-transport", + "async-trait", + "op-alloy-rpc-types-engine", +] + +[[package]] +name = "op-alloy-rpc-types" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd87c6b9e5b6eee8d6b76f41b04368dca0e9f38d83338e5b00e730c282098a4" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-network-primitives", + "alloy-primitives", + "alloy-rpc-types-eth", + "alloy-serde", + "derive_more", + "op-alloy-consensus", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "op-alloy-rpc-types-engine" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727699310a18cdeed32da3928c709e2704043b6584ed416397d5da65694efc" +dependencies = [ + "alloy-consensus", + "alloy-eips", + "alloy-primitives", + "alloy-rlp", + "alloy-rpc-types-engine", + "alloy-serde", + "derive_more", + "ethereum_ssz", + "ethereum_ssz_derive", + "op-alloy-consensus", + "serde", + "sha2", + "snap", + "thiserror 2.0.18", +] + [[package]] name = "openssl-probe" -version = "0.1.6" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "openssl-src" -version = "300.5.4+3.5.4" +version = "300.5.5+3.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a507b3792995dae9b0df8a1c1e3771e8418b7c2d9f0baeba32e6fe8b06c7cb72" +checksum = "3f1787d533e03597a7934fd0a765f0d28e94ecc5fb7789f8053b1e699a56f709" dependencies = [ "cc", ] @@ -5635,7 +6155,7 @@ dependencies = [ "futures-sink", "js-sys", "pin-project-lite", - "thiserror 2.0.17", + "thiserror 2.0.18", "tracing", ] @@ -5665,7 +6185,7 @@ dependencies = [ "opentelemetry_sdk", "prost 0.13.5", "reqwest", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tonic 0.13.1", "tracing", @@ -5696,7 +6216,7 @@ dependencies = [ "percent-encoding", "rand 0.9.2", "serde_json", - "thiserror 2.0.17", + "thiserror 2.0.18", "tokio", "tokio-stream", ] @@ -5742,7 +6262,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -5815,9 +6335,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.4" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbcfd20a6d4eeba40179f05735784ad32bdaef05ce8e8af05f180d45bb3e7e22" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" dependencies = [ "memchr", "ucd-trie", @@ -5825,9 +6345,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.4" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51f72981ade67b1ca6adc26ec221be9f463f2b5839c7508998daa17c23d94d7f" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" dependencies = [ "pest", "pest_generator", @@ -5835,22 +6355,22 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.4" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dee9efd8cdb50d719a80088b76f81aec7c41ed6d522ee750178f83883d271625" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] name = "pest_meta" -version = "2.8.4" +version = "2.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf1d70880e76bdc13ba52eafa6239ce793d85c8e43896507e43dd8984ff05b82" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" dependencies = [ "pest", "sha2", @@ -5858,12 +6378,23 @@ dependencies = [ [[package]] name = "petgraph" -version = "0.7.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3672b37090dbd86368a4145bc067582552b29c27377cad4e0a306c97f9bd7772" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset", - "indexmap 2.12.1", + "hashbrown 0.15.5", + "indexmap 2.13.0", +] + +[[package]] +name = "pharos" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9567389417feee6ce15dd6527a8a1ecac205ef62c2932bcf3d9f6fc5b78b414" +dependencies = [ + "futures", + "rustc_version 0.4.1", ] [[package]] @@ -5883,7 +6414,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -5965,15 +6496,15 @@ dependencies = [ "concurrent-queue", "hermit-abi", "pin-project-lite", - "rustix 1.1.2", + "rustix 1.1.3", "windows-sys 0.61.2", ] [[package]] name = "portable-atomic" -version = "1.12.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f59e70c4aef1e55797c2e8fd94a4f2a973fc972cfde0e0b05f683667b0cd39dd" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "potential_utf" @@ -6001,9 +6532,9 @@ dependencies = [ [[package]] name = "predicates" -version = "3.1.3" +version = "3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" dependencies = [ "anstyle", "difflib", @@ -6015,15 +6546,15 @@ dependencies = [ [[package]] name = "predicates-core" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" [[package]] name = "predicates-tree" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" dependencies = [ "predicates-core", "termtree", @@ -6036,7 +6567,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -6078,14 +6609,14 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] name = "proc-macro2" -version = "1.0.103" +version = "1.0.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" dependencies = [ "unicode-ident", ] @@ -6110,14 +6641,14 @@ checksum = "440f724eba9f6996b75d63681b0a92b06947f1457076d503a4d2e2c8f56442b8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] name = "proptest" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee689443a2bd0a16ab0348b52ee43e3b2d1b1f931c8aa5c9f8de4c86fbe8c40" +checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532" dependencies = [ "bit-set", "bit-vec", @@ -6144,33 +6675,32 @@ dependencies = [ [[package]] name = "prost" -version = "0.14.1" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7231bd9b3d3d33c86b58adbac74b5ec0ad9f496b19d22801d773636feaa95f3d" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" dependencies = [ "bytes", - "prost-derive 0.14.1", + "prost-derive 0.14.3", ] [[package]] name = "prost-build" -version = "0.14.1" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac6c3320f9abac597dcbc668774ef006702672474aad53c6d596b62e487b40b1" +checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" dependencies = [ "heck", "itertools 0.14.0", "log", "multimap", - "once_cell", "petgraph", "prettyplease", - "prost 0.14.1", + "prost 0.14.3", "prost-types", "pulldown-cmark", "pulldown-cmark-to-cmark", "regex", - "syn 2.0.111", + "syn 2.0.115", "tempfile", ] @@ -6184,29 +6714,29 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] name = "prost-derive" -version = "0.14.1" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9120690fafc389a67ba3803df527d0ec9cbbc9cc45e4cc20b332996dfb672425" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] name = "prost-types" -version = "0.14.1" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9b4db3d6da204ed77bb26ba83b6122a73aeb2e87e25fbf7ad2e84c4ccbf8f72" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" dependencies = [ - "prost 0.14.1", + "prost 0.14.3", ] [[package]] @@ -6226,7 +6756,7 @@ checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -6242,9 +6772,9 @@ dependencies = [ [[package]] name = "pulldown-cmark-to-cmark" -version = "21.1.0" +version = "22.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8246feae3db61428fd0bb94285c690b460e4517d83152377543ca802357785f1" +checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" dependencies = [ "pulldown-cmark", ] @@ -6302,19 +6832,19 @@ dependencies = [ "asynchronous-codec", "bytes", "quick-protobuf", - "thiserror 2.0.17", + "thiserror 2.0.18", "unsigned-varint", ] [[package]] name = "quickcheck" -version = "1.0.3" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "588f6378e4dd99458b60ec275b4477add41ce4fa9f64dcba6f15adccb19b50d6" +checksum = "95c589f335db0f6aaa168a7cd27b1fc6920f5e1470c804f814d9cd6e62a0f70b" dependencies = [ "env_logger", "log", - "rand 0.8.5", + "rand 0.10.0", ] [[package]] @@ -6331,8 +6861,8 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.6.1", - "thiserror 2.0.17", + "socket2 0.6.2", + "thiserror 2.0.18", "tokio", "tracing", "web-time", @@ -6353,7 +6883,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.17", + "thiserror 2.0.18", "tinyvec", "tracing", "web-time", @@ -6368,16 +6898,16 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.1", + "socket2 0.6.2", "tracing", "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.42" +version = "1.0.44" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" dependencies = [ "proc-macro2", ] @@ -6422,10 +6952,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" dependencies = [ "rand_chacha 0.9.0", - "rand_core 0.9.3", + "rand_core 0.9.5", "serde", ] +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "getrandom 0.4.1", + "rand_core 0.10.0", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -6443,7 +6983,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] @@ -6452,33 +6992,39 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", ] [[package]] name = "rand_core" -version = "0.9.3" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99d9a13982dcf210057a8a78572b2217b667c3beacbf3a0d8b454f6f82837d38" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" dependencies = [ "getrandom 0.3.4", "serde", ] +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + [[package]] name = "rand_xorshift" version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core 0.9.3", + "rand_core 0.9.5", ] [[package]] name = "rapidhash" -version = "4.1.1" +version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8e65c75143ce5d47c55b510297eeb1182f3c739b6043c537670e9fc18612dae" +checksum = "84816e4c99c467e92cf984ee6328caa976dfecd33a673544489d79ca2caaefe5" dependencies = [ "rustversion", ] @@ -6501,7 +7047,28 @@ dependencies = [ "strum_macros 0.26.4", "unicode-segmentation", "unicode-truncate", - "unicode-width", + "unicode-width 0.1.14", +] + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags 2.10.0", + "cassowary", + "compact_str", + "crossterm 0.28.1", + "indoc", + "instability", + "itertools 0.13.0", + "lru 0.12.5", + "paste", + "strum 0.26.3", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", ] [[package]] @@ -6562,15 +7129,15 @@ dependencies = [ [[package]] name = "rcgen" -version = "0.14.6" +version = "0.14.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ec0a99f2de91c3cddc84b37e7db80e4d96b743e05607f647eb236fc0455907f" +checksum = "10b99e0098aa4082912d4c649628623db6aba77335e4f4569ff5083a6448b32e" dependencies = [ "pem", "ring", "rustls-pki-types", "time", - "x509-parser 0.18.0", + "x509-parser 0.18.1", "yasna", ] @@ -6585,9 +7152,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.6.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec96166dafa0886eb81fe1c0a388bece180fbef2135f97c1e2cf8302e74b43b5" +checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" dependencies = [ "bitflags 2.10.0", ] @@ -6598,9 +7165,9 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom 0.2.16", + "getrandom 0.2.17", "libredox", - "thiserror 2.0.17", + "thiserror 2.0.18", ] [[package]] @@ -6620,14 +7187,14 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] name = "regex" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -6637,9 +7204,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -6648,9 +7215,9 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" [[package]] name = "rend" @@ -6663,9 +7230,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.12.26" +version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b4c14b2d9afca6a60277086b0cc6a6ae0b568f6f7916c943a8cdc79f8be240f" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64", "bytes", @@ -6701,7 +7268,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "webpki-roots", + "webpki-roots 1.0.6", ] [[package]] @@ -6712,9 +7279,9 @@ checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" [[package]] name = "retry" -version = "2.1.0" +version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e211f878258887b3e65dd3c8ff9f530fe109f441a117ee0cdc27f341355032" +checksum = "1cab9bd343c737660e523ee69f788018f3db686d537d2fd0f99c9f747c1bda4f" dependencies = [ "rand 0.9.2", ] @@ -6737,7 +7304,7 @@ checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", "cfg-if", - "getrandom 0.2.16", + "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", @@ -6745,14 +7312,14 @@ dependencies = [ [[package]] name = "rkyv" -version = "0.8.12" +version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a640b26f007713818e9a9b65d34da1cf58538207b052916a83d80e43f3ffa4" +checksum = "1a30e631b7f4a03dee9056b8ef6982e8ba371dd5bedb74d3ec86df4499132c70" dependencies = [ "bytecheck", "bytes", - "hashbrown 0.15.5", - "indexmap 2.12.1", + "hashbrown 0.16.1", + "indexmap 2.13.0", "munge", "ptr_meta", "rancor", @@ -6764,13 +7331,13 @@ dependencies = [ [[package]] name = "rkyv_derive" -version = "0.8.12" +version = "0.8.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd83f5f173ff41e00337d97f6572e416d022ef8a19f371817259ae960324c482" +checksum = "8100bb34c0a1d0f907143db3149e6b4eea3c33b9ee8b189720168e818303986f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -6797,6 +7364,16 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rsqlite-vfs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.18", +] + [[package]] name = "rtnetlink" version = "0.13.1" @@ -6817,9 +7394,9 @@ dependencies = [ [[package]] name = "ruint" -version = "1.17.0" +version = "1.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68df0380e5c9d20ce49534f292a36a7514ae21350726efe1865bdb1fa91d278" +checksum = "c141e807189ad38a07276942c6623032d3753c8859c146104ac2e4d68865945a" dependencies = [ "alloy-rlp", "ark-ff 0.3.0", @@ -6919,9 +7496,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" dependencies = [ "bitflags 2.10.0", "errno", @@ -6932,9 +7509,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.35" +version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ "aws-lc-rs", "log", @@ -6948,9 +7525,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9980d917ebb0c0536119ba501e90834767bffc3d60641457fd84a1f3fd337923" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -6960,9 +7537,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.13.2" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ "web-time", "zeroize", @@ -6970,9 +7547,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.8" +version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ "aws-lc-rs", "ring", @@ -7010,9 +7587,9 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.20" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "same-file" @@ -7046,9 +7623,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.1.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" dependencies = [ "dyn-clone", "ref-cast", @@ -7106,9 +7683,9 @@ dependencies = [ [[package]] name = "security-framework" -version = "3.5.1" +version = "3.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +checksum = "d17b898a6d6948c3a8ee4372c17cb384f90d2e6e912ef00895b14fd7ab54ec38" dependencies = [ "bitflags 2.10.0", "core-foundation 0.10.1", @@ -7119,9 +7696,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "321c8673b092a9a42605034a9879d73cb79101ed5fd117bc9a597b89b4e9e61a" dependencies = [ "core-foundation-sys", "libc", @@ -7151,6 +7728,12 @@ dependencies = [ "pest", ] +[[package]] +name = "send_wrapper" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" + [[package]] name = "serde" version = "1.0.228" @@ -7210,20 +7793,20 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -7268,9 +7851,9 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.12.1", + "indexmap 2.13.0", "schemars 0.9.0", - "schemars 1.1.0", + "schemars 1.2.1", "serde_core", "serde_json", "serde_with_macros", @@ -7286,7 +7869,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -7333,9 +7916,9 @@ dependencies = [ [[package]] name = "sha3-asm" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28efc5e327c837aa837c59eae585fc250715ef939ac32881bcc11677cd02d46" +checksum = "b31139435f327c93c6038ed350ae4588e2c70a13d50599509fee6349967ba35a" dependencies = [ "cc", "cfg-if", @@ -7379,10 +7962,11 @@ dependencies = [ [[package]] name = "signal-hook-registry" -version = "1.4.7" +version = "1.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7664a098b8e616bdfcc2dc0e9ac44eb231eedf41db4e9fe95d8d32ec728dedad" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" dependencies = [ + "errno", "libc", ] @@ -7422,9 +8006,9 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "slab" -version = "0.4.11" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "slotmap" @@ -7444,6 +8028,28 @@ dependencies = [ "serde", ] +[[package]] +name = "snap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" + +[[package]] +name = "snmalloc-rs" +version = "0.7.4" +source = "git+https://github.com/litlep-nibbyt/snmalloc.git?rev=060d5b9fa1c5777a52deae8dbdd82da91babf35f#060d5b9fa1c5777a52deae8dbdd82da91babf35f" +dependencies = [ + "snmalloc-sys", +] + +[[package]] +name = "snmalloc-sys" +version = "0.7.4" +source = "git+https://github.com/litlep-nibbyt/snmalloc.git?rev=060d5b9fa1c5777a52deae8dbdd82da91babf35f#060d5b9fa1c5777a52deae8dbdd82da91babf35f" +dependencies = [ + "cc", +] + [[package]] name = "socket2" version = "0.5.10" @@ -7456,9 +8062,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" dependencies = [ "libc", "windows-sys 0.60.2", @@ -7483,6 +8089,18 @@ dependencies = [ "der", ] +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4206ed3a67690b9c29b77d728f6acc3ce78f16bf846d83c94f76400320181b" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -7535,7 +8153,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -7547,7 +8165,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -7569,9 +8187,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.111" +version = "2.0.115" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +checksum = "6e614ed320ac28113fa64972c4262d5dbc89deacdfd00c34a3e4cea073243c12" dependencies = [ "proc-macro2", "quote", @@ -7580,14 +8198,14 @@ dependencies = [ [[package]] name = "syn-solidity" -version = "1.5.1" +version = "1.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6b1d2e2059056b66fec4a6bb2b79511d5e8d76196ef49c38996f4b48db7662f" +checksum = "f8658017776544996edc21c8c7cc8bb4f13db60955382f4bac25dc6303b38438" dependencies = [ "paste", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -7607,7 +8225,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -7684,14 +8302,14 @@ dependencies = [ [[package]] name = "tempfile" -version = "3.23.0" +version = "3.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.1", "once_cell", - "rustix 1.1.2", + "rustix 1.1.3", "windows-sys 0.61.2", ] @@ -7716,8 +8334,8 @@ dependencies = [ "lazy-regex", "minimad", "serde", - "thiserror 2.0.17", - "unicode-width", + "thiserror 2.0.18", + "unicode-width 0.1.14", ] [[package]] @@ -7737,11 +8355,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl 2.0.17", + "thiserror-impl 2.0.18", ] [[package]] @@ -7752,18 +8370,18 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] name = "thiserror-impl" -version = "2.0.17" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -7820,30 +8438,30 @@ dependencies = [ [[package]] name = "time" -version = "0.3.44" +version = "0.3.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e7d9e3bb61134e77bde20dd4825b97c010155709965fedf0f49bb138e52a9d" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" dependencies = [ "deranged", "itoa", "num-conv", "powerfmt", - "serde", + "serde_core", "time-core", "time-macros", ] [[package]] name = "time-core" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40868e7c1d2f0b8d73e4a8c7f0ff63af4f6d19be117e90bd73eb1d62cf831c6b" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" [[package]] name = "time-macros" -version = "0.2.24" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30cfb0125f12d9c277f35663a0a33f8c30190f4e4574868a330595412d34ebf3" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" dependencies = [ "num-conv", "time-core", @@ -7895,9 +8513,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.48.0" +version = "1.49.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" dependencies = [ "bytes", "libc", @@ -7905,7 +8523,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.1", + "socket2 0.6.2", "tokio-macros", "tracing", "windows-sys 0.61.2", @@ -7919,7 +8537,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -7934,9 +8552,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.17" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eca58d7bba4a75707817a2c44174253f9236b2d5fbd055602e9d5c07c139a047" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" dependencies = [ "futures-core", "pin-project-lite", @@ -7944,11 +8562,27 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio-tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + [[package]] name = "tokio-util" -version = "0.7.17" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2efa149fe76073d6e8fd97ef4f4eca7b67f599660115591483572e406e165594" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" dependencies = [ "bytes", "futures-core", @@ -7960,11 +8594,11 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.10+spec-1.1.0" +version = "0.9.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0825052159284a1a8b4d6c0c86cbc801f2da5afd2b225fa548c72f2e74002f48" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ - "indexmap 2.12.1", + "indexmap 2.13.0", "serde_core", "serde_spanned", "toml_datetime", @@ -7988,7 +8622,7 @@ version = "0.23.10+spec-1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" dependencies = [ - "indexmap 2.12.1", + "indexmap 2.13.0", "toml_datetime", "toml_parser", "winnow", @@ -7996,9 +8630,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.6+spec-1.1.0" +version = "1.0.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "0742ff5ff03ea7e67c8ae6c93cac239e0d9784833362da3f9a9c1da8dfefcbdc" dependencies = [ "winnow", ] @@ -8037,9 +8671,9 @@ dependencies = [ [[package]] name = "tonic" -version = "0.14.2" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203" +checksum = "a286e33f82f8a1ee2df63f4fa35c0becf4a85a0cb03091a15fd7bf0b402dc94a" dependencies = [ "async-trait", "axum", @@ -8054,7 +8688,7 @@ dependencies = [ "hyper-util", "percent-encoding", "pin-project", - "socket2 0.6.1", + "socket2 0.6.2", "sync_wrapper", "tokio", "tokio-rustls", @@ -8063,84 +8697,84 @@ dependencies = [ "tower-layer", "tower-service", "tracing", - "webpki-roots", + "webpki-roots 1.0.6", ] [[package]] name = "tonic-build" -version = "0.14.2" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c40aaccc9f9eccf2cd82ebc111adc13030d23e887244bc9cfa5d1d636049de3" +checksum = "27aac809edf60b741e2d7db6367214d078856b8a5bff0087e94ff330fb97b6fc" dependencies = [ "prettyplease", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] name = "tonic-health" -version = "0.14.2" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a82868bf299e0a1d2e8dce0dc33a46c02d6f045b2c1f1d6cc8dc3d0bf1812ef" +checksum = "8dbde2c702c4be12b9b2f6f7e6c824a84a7b7be177070cada8ee575a581af359" dependencies = [ - "prost 0.14.1", + "prost 0.14.3", "tokio", "tokio-stream", - "tonic 0.14.2", + "tonic 0.14.3", "tonic-prost", ] [[package]] name = "tonic-prost" -version = "0.14.2" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66bd50ad6ce1252d87ef024b3d64fe4c3cf54a86fb9ef4c631fdd0ded7aeaa67" +checksum = "d6c55a2d6a14174563de34409c9f92ff981d006f56da9c6ecd40d9d4a31500b0" dependencies = [ "bytes", - "prost 0.14.1", - "tonic 0.14.2", + "prost 0.14.3", + "tonic 0.14.3", ] [[package]] name = "tonic-prost-build" -version = "0.14.2" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4a16cba4043dc3ff43fcb3f96b4c5c154c64cbd18ca8dce2ab2c6a451d058a2" +checksum = "a4556786613791cfef4ed134aa670b61a85cfcacf71543ef33e8d801abae988f" dependencies = [ "prettyplease", "proc-macro2", "prost-build", "prost-types", "quote", - "syn 2.0.111", + "syn 2.0.115", "tempfile", "tonic-build", ] [[package]] name = "tonic-reflection" -version = "0.14.2" +version = "0.14.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34da53e8387581d66db16ff01f98a70b426b091fdf76856e289d5c1bd386ed7b" +checksum = "758112f988818866f38face806ebf8c8961ad2c087e2ba89ad30010ba5fd80c1" dependencies = [ - "prost 0.14.1", + "prost 0.14.3", "prost-types", "tokio", "tokio-stream", - "tonic 0.14.2", + "tonic 0.14.3", "tonic-prost", ] [[package]] name = "tower" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 2.12.1", + "indexmap 2.13.0", "pin-project-lite", "slab", "sync_wrapper", @@ -8203,6 +8837,18 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf" +dependencies = [ + "crossbeam-channel", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" @@ -8211,7 +8857,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -8273,9 +8919,9 @@ dependencies = [ [[package]] name = "tracing-test" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "557b891436fe0d5e0e363427fc7f217abf9ccd510d5136549847bdcbcd011d68" +checksum = "19a4c448db514d4f24c5ddb9f73f2ee71bfb24c526cf0c570ba142d1119e0051" dependencies = [ "tracing-core", "tracing-subscriber", @@ -8284,12 +8930,12 @@ dependencies = [ [[package]] name = "tracing-test-macro" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04659ddb06c87d233c566112c1c9c5b9e98256d9af50ec3bc9c8327f873a7568" +checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" dependencies = [ "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -8305,9 +8951,9 @@ dependencies = [ [[package]] name = "tracy-client" -version = "0.18.3" +version = "0.18.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91d722a05fe49b31fef971c4732a7d4aa6a18283d9ba46abddab35f484872947" +checksum = "a4f6fc3baeac5d86ab90c772e9e30620fc653bf1864295029921a15ef478e6a5" dependencies = [ "loom", "once_cell", @@ -8316,9 +8962,9 @@ dependencies = [ [[package]] name = "tracy-client-sys" -version = "0.27.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fb391ac70462b3097a755618fbf9c8f95ecc1eb379a414f7b46f202ed10db1f" +checksum = "c5f7c95348f20c1c913d72157b3c6dee6ea3e30b3d19502c5a7f6d3f160dacbf" dependencies = [ "cc", "windows-targets 0.52.6", @@ -8330,6 +8976,25 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.26.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -8380,15 +9045,15 @@ checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" [[package]] name = "unicase" -version = "2.8.1" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b844d17643ee918803943289730bec8aac480150456169e647ed0b576ba539" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "537dd038a89878be9b64dd4bd1b260315c1bb94f4d784956b81e27a088d9a09e" [[package]] name = "unicode-segmentation" @@ -8404,7 +9069,7 @@ checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" dependencies = [ "itertools 0.13.0", "unicode-segmentation", - "unicode-width", + "unicode-width 0.1.14", ] [[package]] @@ -8413,6 +9078,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -8439,16 +9110,23 @@ checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" [[package]] name = "url" -version = "2.5.7" +version = "2.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" dependencies = [ "form_urlencoded", "idna", "percent-encoding", "serde", + "serde_derive", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -8463,9 +9141,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2e054861b4bd027cd373e18e8d8d8e6548085000e41290d95ce0c373a654b4a" +checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" dependencies = [ "getrandom 0.3.4", "js-sys", @@ -8504,7 +9182,7 @@ checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -8543,18 +9221,27 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.1+wasi-0.2.4" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" dependencies = [ "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" dependencies = [ "cfg-if", "once_cell", @@ -8565,11 +9252,12 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.56" +version = "0.4.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c" +checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" dependencies = [ "cfg-if", + "futures-util", "js-sys", "once_cell", "wasm-bindgen", @@ -8578,9 +9266,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -8588,26 +9276,60 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.106" +version = "0.2.108" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.13.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.10.0", + "hashbrown 0.15.5", + "indexmap 2.13.0", + "semver 1.0.27", +] + [[package]] name = "wasmtimer" version = "0.4.3" @@ -8624,9 +9346,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.83" +version = "0.3.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac" +checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" dependencies = [ "js-sys", "wasm-bindgen", @@ -8644,9 +9366,18 @@ dependencies = [ [[package]] name = "webpki-roots" -version = "1.0.4" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2878ef029c47c6e8cf779119f20fcf52bde7ad42a731b2a304bc221df17571e" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" dependencies = [ "rustls-pki-types", ] @@ -8676,7 +9407,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3fabb953106c3c8eea8306e4393700d7657561cb43122571b172bbfb7c7ba1d" dependencies = [ "env_home", - "rustix 1.1.2", + "rustix 1.1.3", "winsafe", ] @@ -8780,7 +9511,7 @@ checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -8791,7 +9522,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -8802,7 +9533,7 @@ checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -8813,7 +9544,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -9173,9 +9904,91 @@ checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" [[package]] name = "wit-bindgen" -version = "0.46.0" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.13.0", + "prettyplease", + "syn 2.0.115", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.115", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.10.0", + "indexmap 2.13.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.13.0", + "log", + "semver 1.0.27", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" @@ -9183,6 +9996,25 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" +[[package]] +name = "ws_stream_wasm" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c173014acad22e83f16403ee360115b38846fe754e735c5d9d3803fe70c6abc" +dependencies = [ + "async_io_stream", + "futures", + "js-sys", + "log", + "pharos", + "rustc_version 0.4.1", + "send_wrapper", + "thiserror 2.0.18", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wyz" version = "0.5.1" @@ -9199,7 +10031,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "gethostname", - "rustix 1.1.2", + "rustix 1.1.3", "x11rb-protocol", ] @@ -9232,15 +10064,15 @@ dependencies = [ "nom", "oid-registry", "rusticata-macros", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", ] [[package]] name = "x509-parser" -version = "0.18.0" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3e137310115a65136898d2079f003ce33331a6c4b0d51f1531d1be082b6425" +checksum = "d43b0f71ce057da06bc0851b23ee24f3f86190b07203dd8f567d0b706a185202" dependencies = [ "asn1-rs", "data-encoding", @@ -9250,7 +10082,7 @@ dependencies = [ "oid-registry", "ring", "rusticata-macros", - "thiserror 2.0.17", + "thiserror 2.0.18", "time", ] @@ -9261,7 +10093,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" dependencies = [ "libc", - "rustix 1.1.2", + "rustix 1.1.3", ] [[package]] @@ -9333,28 +10165,28 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.31" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd74ec98b9250adb3ca554bdde269adf631549f51d8a8f8f0a10b50f1cb298c3" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.31" +version = "0.8.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8a8d209fdf45cf5138cbb5a506f6b52522a25afccc534d1475dad8e31105c6a" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -9374,7 +10206,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", "synstructure", ] @@ -9389,13 +10221,13 @@ dependencies = [ [[package]] name = "zeroize_derive" -version = "1.4.2" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -9428,7 +10260,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.111", + "syn 2.0.115", ] [[package]] @@ -9454,6 +10286,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + [[package]] name = "zune-core" version = "0.4.12" diff --git a/Cargo.toml b/Cargo.toml index 44b4d7ec4..46f04f9c1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ resolver = "2" [workspace] -members = ["crates/equix-latency", "crates/hoon", "crates/hoonc", "crates/kernels", "crates/nockapp", "crates/nockapp-grpc", "crates/nockapp-grpc-proto", "crates/nockchain", "crates/nockchain-api", "crates/nockchain-explorer-tui", "crates/nockchain-libp2p-io", "crates/nockchain-math", "crates/nockchain-peek", "crates/nockchain-types", "crates/nockchain-wallet", "crates/nockup", "crates/nockvm/rust/ibig", "crates/nockvm/rust/murmur3", "crates/nockvm/rust/nockvm", "crates/nockvm/rust/nockvm_macros", "crates/noun-serde", "crates/noun-serde-derive", "crates/raw-tx-checker", "crates/zkvm-jetpack"] +members = ["crates/bridge", "crates/equix-latency", "crates/hoon", "crates/hoonc", "crates/kernels/bridge", "crates/kernels/dumb", "crates/kernels/miner", "crates/kernels/nockchain-peek", "crates/kernels/wallet", "crates/nockapp", "crates/nockapp-grpc", "crates/nockapp-grpc-proto", "crates/nockchain", "crates/nockchain-api", "crates/nockchain-explorer-tui", "crates/nockchain-libp2p-io", "crates/nockchain-math", "crates/nockchain-peek", "crates/nockchain-types", "crates/nockchain-wallet", "crates/nockup", "crates/nockvm/rust/ibig", "crates/nockvm/rust/murmur3", "crates/nockvm/rust/nockvm", "crates/nockvm/rust/nockvm_macros", "crates/noun-serde", "crates/noun-serde-derive", "crates/raw-tx-checker", "crates/zkvm-jetpack"] [workspace.package] version = "0.1.0" @@ -18,6 +18,7 @@ assert_cmd = "2.0" async-trait = "0.1" axum = "0.8.1" axum-server = { version = "0.8.0", features = ["tls-rustls"] } +backon = { version = "1.6", features = ["tokio-sleep"] } bardecoder = "0.5.0" bincode = "2.0.0-rc.3" bitcoincore-rpc = "0.19.0" @@ -39,6 +40,8 @@ criterion = { git = "https://github.com/vlovich/criterion.rs.git", rev = "9b485a crossterm = "0.29" curve25519-dalek = { version = "4.1.1", default-features = false } dashmap = "6.1.0" +deadpool-diesel = { version = "0.6.1", features = ["rt_tokio_1", "sqlite"] } +diesel = { version = "2.1.4", features = ["sqlite"] } dirs = "6.0.0" divan = "0.1.21" ed25519-dalek = { version = "2.1.0", default-features = false } @@ -62,6 +65,7 @@ intmap = "3.1.0" lazy_static = "1.4.0" libc = "0.2.171" libp2p = { git = "https://github.com/libp2p/rust-libp2p.git", rev = "da0017ee887a868e231ed78c7de892779c17800d" } +libsqlite3-sys = { version = "0.35.0", features = ["bundled"] } memmap2 = "^0.9.5" nu-ansi-term = "0.50" num-bigint = "0.4.6" @@ -120,6 +124,7 @@ signal-hook = "0.3" signal-hook-tokio = "0.3.1" slotmap = "1.0.7" smallvec = "1.15.1" +snmalloc-rs = { git = "https://github.com/litlep-nibbyt/snmalloc.git", rev = "060d5b9fa1c5777a52deae8dbdd82da91babf35f", default-features = false, features = ["build_cc", "usewait-on-address"] } static_assertions = "1.1.0" strum = { version = "0.27.2", features = ["derive"] } syn = { version = "2.0.39", features = ["full"] } @@ -154,6 +159,7 @@ tonic-prost-build = "0.14.0" tonic-reflection = "0.14.0" tower-http = { version = "0.6", features = ["fs"] } tracing = "0.1.41" +tracing-appender = "0.2.4" tracing-core = "0.1" tracing-opentelemetry = { version = "0.31.0", features = ["metrics"] } tracing-subscriber = { version = "0.3.18", features = [ @@ -185,8 +191,20 @@ path = "crates/hoonc" [workspace.dependencies.ibig] path = "crates/nockvm/rust/ibig" -[workspace.dependencies.kernels] -path = "crates/kernels" +[workspace.dependencies.kernels-open-bridge] +path = "crates/kernels/bridge" + +[workspace.dependencies.kernels-open-dumb] +path = "crates/kernels/dumb" + +[workspace.dependencies.kernels-open-miner] +path = "crates/kernels/miner" + +[workspace.dependencies.kernels-open-nockchain-peek] +path = "crates/kernels/nockchain-peek" + +[workspace.dependencies.kernels-open-wallet] +path = "crates/kernels/wallet" [workspace.dependencies.murmur3] path = "crates/nockvm/rust/murmur3" @@ -254,3 +272,13 @@ debug = 1 opt-level = 3 codegen-units = 1 debug = 1 + +# Memory profiling with bytehound. Keeps release optimizations but emits full debug info. +[profile.bytehound] +inherits = "release" +debug = 2 + +[profile.bytehound.package."*"] +opt-level = 3 +codegen-units = 1 +debug = 2 diff --git a/crates/bridge/.gitignore b/crates/bridge/.gitignore new file mode 100644 index 000000000..c43c50bd2 --- /dev/null +++ b/crates/bridge/.gitignore @@ -0,0 +1,41 @@ +# Devenv +.devenv* +devenv.lock +devenv.local.nix + +# direnv +.direnv + +# pre-commit +.pre-commit-config.yaml + +# Devenv +.devenv* +devenv.local.nix + +# direnv +.direnv + +# pre-commit +.pre-commit-config.yaml + +# Test data from scripts +test_run_data/ + +# Environment files with secrets (use .env.example templates) +*.env +!*.env.example +bridge-conf.mainnet.toml +bridge-conf.sepolia.toml + +# Transaction files (test artifacts) +txs/ +scripts/txs/ +contracts/txs/ + +# Deployment outputs (contain addresses, regenerated on deploy) +contracts/deployments/ +contracts/out + +# Contract dependencies (must be built locally) +contracts/lib diff --git a/crates/bridge/Cargo.toml b/crates/bridge/Cargo.toml new file mode 100644 index 000000000..b331f517e --- /dev/null +++ b/crates/bridge/Cargo.toml @@ -0,0 +1,75 @@ +[package] +name = "bridge" +version.workspace = true +edition.workspace = true +publish = false +build = "build.rs" + +[lib] +name = "bridge" +path = "src/lib.rs" + +[features] +bazel_build = [] +snmalloc = ["dep:snmalloc-rs"] +jemalloc = ["dep:tikv-jemallocator"] + +[dependencies] +alloy = { workspace = true, features = ["provider-ws", "signer-local"] } +anyhow.workspace = true +async-trait.workspace = true +backon.workspace = true +bincode.workspace = true +blake3.workspace = true +bs58.workspace = true +chrono.workspace = true +clap = { workspace = true, features = ["derive"] } +crossterm.workspace = true +deadpool-diesel.workspace = true +diesel.workspace = true +futures.workspace = true +getrandom.workspace = true +gnort = { workspace = true } +hex.workspace = true +# Force SQLite to be built from source for the target (Linux) +ibig.workspace = true +kernels-open-bridge = { workspace = true } +libsqlite3-sys = { workspace = true } +nockapp.workspace = true +nockapp-grpc = { workspace = true, default-features = false, features = [ + "client", +] } +nockchain-libp2p-io.workspace = true +nockchain-math.workspace = true +nockchain-types.workspace = true +nockvm.workspace = true +nockvm_macros.workspace = true +noun-serde.workspace = true +nu-ansi-term.workspace = true +num-bigint.workspace = true +op-alloy = { workspace = true, features = ["network"] } +prost = { workspace = true } +ratatui.workspace = true +rustls = { workspace = true, features = ["aws_lc_rs"] } +serde.workspace = true +serde_bytes.workspace = true +serde_json.workspace = true +snmalloc-rs = { workspace = true, optional = true } +thiserror.workspace = true +tikv-jemallocator = { workspace = true, optional = true } +tiny-keccak.workspace = true +tokio = { workspace = true, features = ["full"] } +toml.workspace = true +tonic.workspace = true +tonic-prost = { workspace = true } +tonic-reflection = { workspace = true } +tracing.workspace = true +tracing-appender.workspace = true +tracing-subscriber.workspace = true +zkvm-jetpack.workspace = true + +[build-dependencies] +tonic-prost-build = { workspace = true } + +[dev-dependencies] +tempfile = "3.8" diff --git a/crates/bridge/bridge-conf.example.toml b/crates/bridge/bridge-conf.example.toml new file mode 100644 index 000000000..e28f246f6 --- /dev/null +++ b/crates/bridge/bridge-conf.example.toml @@ -0,0 +1,93 @@ +# Bridge Node Configuration Example +# Copy this file to bridge-conf.toml and fill in with your actual values +# +# Logging: +# --log-dir Custom log directory (default: {data_dir}/logs/) +# RUST_LOG= Log verbosity via env var (default: info) +# Logs rotate daily and files older than 7 days are automatically deleted. + +# Bridge node identity (0-4, must be unique for each node) +node_id = 0 + +# Ethereum Base L2 WebSocket URL +base_ws_url = "wss://virtual.base-sepolia.us-east.rpc.tenderly.co/YOUR-TENDERLY-PROJECT-ID" + +# Address of the MessageInbox contract on Base L2 +inbox_contract_address = "0x68487BbaE6210a7A5d92c1D3D620e7AC9f7Ae29E" +nock_contract_address = "0x9f5D8976f0F89eC92241850A585B313F31Ab8b62" + +# This node's private keys +my_eth_key = "0x..." # Hex encoded Ethereum private key +my_nock_key = "..." # Base58 encoded Schnorr private key for Nockchain + +# gRPC address for communicating with Nockchain node +grpc_address = "http://localhost:5555" + +# Finality / confirmation settings (driver-side). +# The bridge kernel assumes all blocks are final; the Rust driver enforces finality by withholding +# blocks until they reach these confirmation depths. +base_confirmation_depth = 300 +nockchain_confirmation_depth = 100 + +# Runtime nonce epoch base. +# Set this to the last successful deposit nonce on Base at activation time. +# If you omit the start height/tx-id, this must be 0. +nonce_epoch_base = 150 + +# Runtime nonce epoch start height (required when nonce_epoch_base is non-zero). +# This must be the height of the anchor deposit that produced nonce_epoch_base. +nonce_epoch_start_height = 50009 + +# Runtime nonce epoch start tx-id (required when nonce_epoch_base is non-zero). +# This tx-id is the anchor deposit whose nonce is nonce_epoch_base. +# Set it to the tx_id corresponding to the last successful deposit +nonce_epoch_start_tx_id_base58 = "2uYre9HXRP8X6BD7w3GvgfUAU47RSmZDGkz9uJgJmD9CxN7JA69k6MF" + +# Bridge ingress gRPC listen address (must match this node's entry in [[nodes]]) +ingress_listen_address = "127.0.0.1:8001" + +# All 5 bridge nodes configuration +# Each node needs IP address, Base address (eth_pubkey), and Nockchain public key hash (nock_pkh). +# Peers dial these IPs for health checks, so keep each node's ingress_listen_address +# synchronized with its corresponding entry. +# +# nock_pkh is the ~52 character base58-encoded public key hash (NOT the full ~130 char pubkey). +# Operators share their PKH, not their full public key. + +# Example placeholder values - replace with actual operator data +[[nodes]] +ip = "localhost:8001" +eth_pubkey = "0x..." # Operator 0 Base address +nock_pkh = "..." # Operator 0 PKH (~52 char base58) + +[[nodes]] +ip = "localhost:8002" +eth_pubkey = "0x..." # Operator 1 Base address +nock_pkh = "..." # Operator 1 PKH + +[[nodes]] +ip = "localhost:8003" +eth_pubkey = "0x..." # Operator 2 Base address +nock_pkh = "..." # Operator 2 PKH + +[[nodes]] +ip = "localhost:8004" +eth_pubkey = "0x..." # Operator 3 Base address +nock_pkh = "..." # Operator 3 PKH + +[[nodes]] +ip = "localhost:8005" +eth_pubkey = "0x..." # Operator 4 Base address +nock_pkh = "..." # Operator 4 PKH + +# Bridge constants (optional - defaults shown below) +# These configure the bridge kernel's operational parameters. +# Omit this section entirely to use Hoon defaults. +[constants] +min_signers = 3 # Minimum signatures required for proposals +total_signers = 5 # Total number of bridge nodes +minimum_event_nocks = 100000 # Minimum nocks for a bridge event +nicks_fee_per_nock = 195 # Fee per nock in nicks +base_blocks_chunk = 100 # Base L2 blocks per processing chunk +base_start_height = 33387036 # Base L2 block height to start watching +nockchain_start_height = 25 # Nockchain block height to start watching diff --git a/crates/bridge/build.rs b/crates/bridge/build.rs new file mode 100644 index 000000000..0ad2f1733 --- /dev/null +++ b/crates/bridge/build.rs @@ -0,0 +1,312 @@ +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::{env, fs}; + +fn ensure_protoc() -> Result<(), Box> { + println!("cargo:rerun-if-env-changed=PROTOC"); + if env::var_os("PROTOC").is_some() { + return Ok(()); + } + Err("PROTOC is not set; Bazel should pass it via //tools/protoc:protoc".into()) +} + +fn main() -> Result<(), Box> { + println!("cargo:rerun-if-changed=contracts/out/MessageInbox.sol/MessageInbox.json"); + println!("cargo:rerun-if-changed=contracts/out/Nock.sol/Nock.json"); + println!("cargo:rerun-if-changed=contracts/MessageInbox.sol"); + println!("cargo:rerun-if-changed=contracts/Nock.sol"); + println!("cargo:rerun-if-changed=contracts/foundry.toml"); + println!("cargo:rerun-if-changed=proto/bridge_ingress.proto"); + println!("cargo:rerun-if-changed=proto/bridge_status.proto"); + println!("cargo:rerun-if-changed=proto/bridge_tui.proto"); + + ensure_protoc()?; + + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + + // Helper to expand ${pwd} in paths from environment variables + let expand_path = |path_str: &str| -> PathBuf { + if path_str.contains("${pwd}") { + let pwd = env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + PathBuf::from(path_str.replace("${pwd}", &pwd.to_string_lossy())) + } else { + PathBuf::from(path_str) + } + }; + + // Try to get contract file paths from rustc_env first (they contain ${pwd} which we expand) + // If not available, search for them relative to CARGO_MANIFEST_DIR + let mut inbox_src = if let Some(path) = env::var_os("MESSAGE_INBOX_SOL") { + expand_path(&path.to_string_lossy()) + } else { + manifest_dir.join("contracts/MessageInbox.sol") + }; + + let mut nock_src = if let Some(path) = env::var_os("NOCK_SOL") { + expand_path(&path.to_string_lossy()) + } else { + manifest_dir.join("contracts/Nock.sol") + }; + + // If files don't exist at expected location, search more broadly + // compile_data files might be at different locations in the sandbox + if !inbox_src.exists() { + // Try searching from current directory (might be output directory) + if let Ok(current_dir) = env::current_dir() { + for ancestor in current_dir.ancestors() { + let candidate = ancestor.join("open/crates/bridge/contracts/MessageInbox.sol"); + if candidate.exists() { + inbox_src = candidate; + break; + } + } + } + // Also try from manifest_dir + if !inbox_src.exists() { + for ancestor in manifest_dir.ancestors() { + let candidate = ancestor.join("open/crates/bridge/contracts/MessageInbox.sol"); + if candidate.exists() { + inbox_src = candidate; + break; + } + } + } + } + + if !nock_src.exists() { + if let Ok(current_dir) = env::current_dir() { + for ancestor in current_dir.ancestors() { + let candidate = ancestor.join("open/crates/bridge/contracts/Nock.sol"); + if candidate.exists() { + nock_src = candidate; + break; + } + } + } + if !nock_src.exists() { + for ancestor in manifest_dir.ancestors() { + let candidate = ancestor.join("open/crates/bridge/contracts/Nock.sol"); + if candidate.exists() { + nock_src = candidate; + break; + } + } + } + } + + // Determine contracts_root from where we found the source files + let contracts_root = if let Some(parent) = inbox_src.parent() { + parent.to_path_buf() + } else { + manifest_dir.join("contracts") + }; + + let inbox_abi = contracts_root.join("out/MessageInbox.sol/MessageInbox.json"); + let nock_abi = contracts_root.join("out/Nock.sol/Nock.json"); + + // Verify source files exist + if !inbox_src.exists() { + return Err(format!( + "Contract source file not found: {:?} (MESSAGE_INBOX_SOL={:?}, manifest_dir: {:?}, current_dir: {:?})", + inbox_src, env::var_os("MESSAGE_INBOX_SOL"), manifest_dir, env::current_dir().ok() + ).into()); + } + if !nock_src.exists() { + return Err(format!( + "Contract source file not found: {:?} (NOCK_SOL={:?}, manifest_dir: {:?}, current_dir: {:?})", + nock_src, env::var_os("NOCK_SOL"), manifest_dir, env::current_dir().ok() + ).into()); + } + + // Check if artifacts already exist and are up to date. + // + // For normal Cargo builds we can (and should) regenerate stale artifacts to keep the ABI in + // sync with the Solidity sources. + // + // For Bazel builds, contract artifacts are generated by a Bazel genrule and are provided as + // inputs; running `forge build` again inside the sandbox is slow and can be flaky. + let artifacts_exist = inbox_abi.exists() && nock_abi.exists(); + let needs_build = if artifacts_exist { + // If artifacts exist, check if they're stale + is_stale(&inbox_src, &inbox_abi)? || is_stale(&nock_src, &nock_abi)? + } else { + // If artifacts don't exist, we need to build + true + }; + + let is_bazel_build = env::var_os("CARGO_FEATURE_BAZEL_BUILD").is_some(); + if needs_build && !(is_bazel_build && artifacts_exist) { + let forge = resolve_forge_bin()?; + // Verify the file exists before trying to run it + if !forge.exists() { + return Err(format!( + "forge binary not found at {:?} (FORGE_BIN={:?}, CARGO_MANIFEST_DIR={:?})", + forge, + env::var_os("FORGE_BIN"), + env!("CARGO_MANIFEST_DIR") + ) + .into()); + } + if !forge.is_file() { + return Err(format!("forge path {:?} is not a file", forge).into()); + } + + // Try to execute forge - use sh -c to handle path and execution issues. + // Use a per-invocation HOME to avoid cross-process collisions in parallel builds. + // Allow shared SVM_HOME/FOUNDRY_CACHE_PATH for CI caching when explicitly configured. + let forge_path = forge.to_string_lossy().to_string(); + let forge_home = create_unique_temp_dir("bridge-forge")?; + let svm_home = env::var_os("SVM_HOME") + .map(PathBuf::from) + .unwrap_or_else(|| forge_home.join(".svm")); + let foundry_cache = env::var_os("FOUNDRY_CACHE_PATH") + .map(PathBuf::from) + .unwrap_or_else(|| forge_home.join(".foundry/cache")); + fs::create_dir_all(&svm_home)?; + fs::create_dir_all(&foundry_cache)?; + let mut cmd = Command::new("sh"); + cmd.arg("-c") + .arg(format!("{} build --via-ir", forge_path)) + .current_dir(&contracts_root) + .env("HOME", &forge_home) + .env("SVM_HOME", &svm_home) + .env("FOUNDRY_CACHE_PATH", &foundry_cache); + let status = cmd.status().map_err(|e| { + format!( + "Failed to execute forge at {:?} via sh: {} (file exists: {}, is_file: {})", + forge, + e, + forge.exists(), + forge.is_file() + ) + })?; + if !status.success() { + return Err(format!( + "forge build failed with exit code {}", + status.code().unwrap_or(-1) + ) + .into()); + } + } + + // Make sure the ABI JSONs are available under OUT_DIR and tell rustc where to find them. + let out_dir = PathBuf::from(env::var_os("OUT_DIR").unwrap_or_else(|| "../generated".into())); + let abi_out_dir = out_dir.join("abi"); + fs::create_dir_all(&abi_out_dir)?; + let inbox_out_path = abi_out_dir.join("MessageInbox.json"); + let nock_out_path = abi_out_dir.join("Nock.json"); + fs::copy(&inbox_abi, &inbox_out_path)?; + fs::copy(&nock_abi, &nock_out_path)?; + println!( + "cargo:rustc-env=MESSAGE_INBOX_JSON={}", + inbox_out_path.display() + ); + println!("cargo:rustc-env=NOCK_JSON={}", nock_out_path.display()); + + tonic_prost_build::configure() + .file_descriptor_set_path(out_dir.join("bridge_descriptor.bin")) + .build_server(true) + .compile_protos( + &[ + "proto/bridge_ingress.proto", "proto/bridge_status.proto", "proto/bridge_tui.proto", + ], + &["proto"], + )?; + Ok(()) +} + +fn is_stale(src: &Path, artifact: &Path) -> Result> { + // If source doesn't exist, error (source must exist) + let src_meta = + fs::metadata(src).map_err(|e| format!("Source file {:?} not found: {}", src, e))?; + // If artifact doesn't exist, it's stale (needs building) + let artifact_meta = match fs::metadata(artifact) { + Ok(m) => m, + Err(_) => return Ok(true), + }; + Ok(src_meta.modified()? > artifact_meta.modified()?) +} + +fn resolve_forge_bin() -> Result> { + // Check FORGE_BIN environment variable first (set by Bazel via rustc_env) + // This should give us the correct path in the sandbox + // Note: ${pwd} in the path will be expanded by the shell, but we need to handle it + if let Some(bin) = env::var_os("FORGE_BIN") { + let bin_str = bin.to_string_lossy(); + // Replace ${pwd} with actual current working directory if present + let expanded = if bin_str.contains("${pwd}") { + let pwd = env::current_dir() + .map_err(|e| format!("Failed to get current directory: {}", e))?; + bin_str.replace("${pwd}", &pwd.to_string_lossy()) + } else { + bin_str.to_string() + }; + let path = PathBuf::from(&expanded); + if path.is_file() { + return Ok(path); + } + // Try canonicalizing in case it's a symlink or needs resolution + if let Ok(canonical) = path.canonicalize() { + if canonical.is_file() { + return Ok(canonical); + } + } + } + + // Fall back to PATH lookup (most reliable in sandbox) + if let Some(paths) = env::var_os("PATH") { + for entry in env::split_paths(&paths) { + let candidate = entry.join("forge"); + if candidate.is_file() { + return Ok(candidate); + } + } + } + + // In Bazel sandbox, compile_data files are available relative to CARGO_MANIFEST_DIR + // Try to find forge relative to manifest dir + let manifest_dir = Path::new(env!("CARGO_MANIFEST_DIR")); + for ancestor in manifest_dir.ancestors() { + let candidate = ancestor.join("tools/bin/forge_bin"); // genrule output name + if candidate.is_file() { + return Ok(candidate); + } + let candidate_placeholder = ancestor.join("tools/bin/forge_placeholder"); + if candidate_placeholder.is_file() { + return Ok(candidate_placeholder); + } + } + + Err(format!( + "forge not found; checked FORGE_BIN={:?}, PATH, ancestors of manifest_dir={:?}", + env::var_os("FORGE_BIN"), + manifest_dir + ) + .into()) +} + +fn create_unique_temp_dir(prefix: &str) -> std::io::Result { + use std::io; + use std::time::{SystemTime, UNIX_EPOCH}; + + let base = env::temp_dir(); + let pid = std::process::id(); + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos(); + + for attempt in 0..1000u32 { + let candidate = base.join(format!("{prefix}-{pid}-{now}-{attempt}")); + match fs::create_dir(&candidate) { + Ok(()) => return Ok(candidate), + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => continue, + Err(err) => return Err(err), + } + } + + Err(io::Error::new( + io::ErrorKind::AlreadyExists, + "failed to allocate unique temp dir", + )) +} diff --git a/crates/bridge/contracts/.env.template b/crates/bridge/contracts/.env.template new file mode 100644 index 000000000..81b6cab63 --- /dev/null +++ b/crates/bridge/contracts/.env.template @@ -0,0 +1,93 @@ +# Bridge Contracts Environment Configuration +# Copy this file to .env and fill in the values +# cp .env.template .env + +# ============================================================================= +# REQUIRED FOR DEPLOYMENT +# ============================================================================= + +# Tenderly RPC endpoint (fork or devnet) +# Get this from: tenderly devnet spawn --template +TENDERLY_RPC_URL= + +# Deployer private key (with 0x prefix) +# This account will become the owner of MessageInbox +TENDERLY_PRIVATE_KEY= + +# Bridge node addresses (5 required for 3-of-5 multisig) +# These addresses sign deposit messages from Nockchain +# IMPORTANT: All 5 addresses must be unique +BRIDGE_NODE_0= +BRIDGE_NODE_1= +BRIDGE_NODE_2= +BRIDGE_NODE_3= +BRIDGE_NODE_4= + +# Wrapped Nock token configuration +NOCK_NAME="Wrapped Nock" +NOCK_SYMBOL="wNOCK" + +# ============================================================================= +# OPTIONAL FOR DEPLOYMENT +# ============================================================================= + +# Network identifier for deployment file naming +# Default: tenderly-devnet +# Examples: tenderly-devnet, base-sepolia, base-mainnet +DEPLOY_TARGET_NETWORK=tenderly-devnet + +# Deployer address (for metadata only, derived from TENDERLY_PRIVATE_KEY if not set) +DEPLOYER_ADDRESS= + +# Custom deployment file path (auto-generated if not set) +# Default: deployments/${DEPLOY_TARGET_NETWORK}.json +# DEPLOYMENTS_PATH= + +# ============================================================================= +# REQUIRED FOR CONTRACT VERIFICATION +# ============================================================================= + +# Tenderly access key for API authentication +# Get from: https://dashboard.tenderly.co/account/authorization (API Keys tab) +TENDERLY_ACCESS_KEY= + +# ============================================================================= +# OPTIONAL FOR TENDERLY VERIFICATION +# ============================================================================= + +# Tenderly account ID for contract verification +# Find at: https://dashboard.tenderly.co/account/settings +TENDERLY_ACCOUNT_ID= + +# Tenderly project slug for contract verification +# Find at: https://dashboard.tenderly.co/account/projects +TENDERLY_PROJECT_SLUG= + +# ============================================================================= +# REQUIRED FOR ADMIN OPERATIONS (upgrade, emergency, rotate) +# ============================================================================= + +# Private key of the MessageInbox owner +# Required for: upgrade, emergency-disable, emergency-enable, rotate +INBOX_PRIVATE_KEY= + +# ============================================================================= +# REQUIRED FOR INTEGRATION TESTING +# ============================================================================= + +# Test account private key (for receiving deposits and burning) +TEST_ACCOUNT_PRIVATE_KEY= + +# Bridge node private keys (need at least 3 for threshold) +# Must correspond to BRIDGE_NODE_0, BRIDGE_NODE_1, BRIDGE_NODE_2 addresses +BRIDGE_NODE_KEY_0= +BRIDGE_NODE_KEY_1= +BRIDGE_NODE_KEY_2= + +# ============================================================================= +# OPTIONAL FOR TESTING +# ============================================================================= + +# Gas baseline for deposit operations (default: 200000) +# Increase if deploying to networks with higher gas costs +# GAS_BASELINE=200000 diff --git a/crates/bridge/contracts/DEPLOYMENT.md b/crates/bridge/contracts/DEPLOYMENT.md new file mode 100644 index 000000000..ed32d7a27 --- /dev/null +++ b/crates/bridge/contracts/DEPLOYMENT.md @@ -0,0 +1,596 @@ +# Bridge Contract Deployment Guide + +This guide covers deploying, upgrading, and managing the bridge contracts +(`MessageInbox` behind an ERC-1967 proxy plus the `Nock` ERC-20) on Tenderly +networks (devnets, simulations, or proxied mainnets). + +## Quick Start + +For a quick deployment to a new Tenderly devnet: + +```bash +cd open/crates/bridge/contracts + +# 1. Install dependencies +make install + +# 2. Configure environment +cp .env.template .env +# Edit .env with your values + +# 3. Spawn Tenderly devnet +tenderly devnet spawn --network base-sepolia --project bridge-contracts + +# 4. Update .env with RPC URL from above + +# 5. Deploy +make deploy +``` + +## Table of Contents + +1. [Prerequisites](#1-prerequisites) +2. [Environment Configuration](#2-environment-configuration) +3. [Deployment](#3-deployment) +4. [Post-Deployment Validation](#4-post-deployment-validation) +5. [Upgrading Contracts](#5-upgrading-contracts) +6. [Operational Procedures](#6-operational-procedures) +7. [Multi-Network Deployment Tracking](#7-multi-network-deployment-tracking) +8. [Testing](#8-testing) +9. [Troubleshooting](#9-troubleshooting) + +## 1. Prerequisites + +### Install Tools + +```bash +cd open/crates/bridge/contracts +make install +``` + +This installs: + +- Foundry (forge, cast) +- Tenderly CLI +- Contract dependencies (forge-std, OpenZeppelin) + +### Authenticate Tenderly + +One-time setup per machine: + +```bash +tenderly login +tenderly project link littel_wolfur/bridge-contracts +``` + +## 2. Environment Configuration + +### Option A: Use .env file (Recommended) + +1. Copy the template: + + ```bash + cp .env.template .env + ``` + +2. Edit `.env` with your values (see `.env.template` for all options) + +3. The Makefile automatically sources `.env` for all commands + +### Option B: Use environment files + +Pre-configured examples are in `environments/`: + +```bash +source environments/devnet.example +# or +source environments/base-sepolia.example +``` + +### Option C: Export variables manually + +```bash +export TENDERLY_RPC_URL="" +export TENDERLY_PRIVATE_KEY="0x..." +export BRIDGE_NODE_0="0x..." +# ... etc +``` + +### Required Variables + +- `TENDERLY_RPC_URL` - RPC endpoint from Tenderly +- `TENDERLY_PRIVATE_KEY` - Deployer account private key (hex, with 0x) +- `BRIDGE_NODE_0` through `BRIDGE_NODE_4` - Five bridge node addresses +- `NOCK_NAME` - Token name (e.g., "Nock") +- `NOCK_SYMBOL` - Token symbol (e.g., "NOCK") + +### Optional Variables + +- `DEPLOY_TARGET_NETWORK` - Network identifier (default: "tenderly-devnet") +- `DEPLOYER_ADDRESS` - Deployer address for metadata (default: zero address) +- `DEPLOYMENTS_PATH` - Custom deployment file path +- `TENDERLY_ACCOUNT_ID` - For automatic verification +- `TENDERLY_PROJECT_SLUG` - For automatic verification +- `INBOX_PRIVATE_KEY` - Owner key for upgrades/admin operations +- `TEST_ACCOUNT_PRIVATE_KEY` - For integration tests +- `BRIDGE_NODE_KEY_*` - Bridge node private keys for testing + +## 3. Deployment + +### Spawn or Select Tenderly Network + +For a devnet fork: + +```bash +tenderly devnet spawn --network base-sepolia --project bridge-contracts +``` + +Copy the returned `rpc_url`. For live networks proxied through Tenderly, use +the RPC URL shown in the Tenderly dashboard. + +### Fund Test Accounts (Devnets Only) + +Tenderly devnets start with zero balances. Before running integration tests, +fund the bridge node and test accounts using the `tenderly_setBalance` RPC: + +```bash +# Fund bridge node 0 and test account with 10 ETH each +curl -X POST "$TENDERLY_RPC_URL" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "method": "tenderly_setBalance", + "params": [ + ["0xBridgeNode0Address", "0xTestAccountAddress"], + "0x8AC7230489E80000" + ], + "id": 1 + }' +``` + +The hex value `0x8AC7230489E80000` equals 10 ETH (10 × 10^18 wei). You can +fund multiple addresses in a single call by adding them to the array. + +**Note:** This is only needed for devnets. Mainnet forks inherit real balances. + +### Preview Deployment (Dry Run) + +Before deploying, preview what will happen: + +```bash +make deploy EXTRA_FLAGS="--dry-run" +``` + +This shows: + +- Network and RPC configuration +- Token configuration (name, symbol) +- All 5 bridge node addresses +- Deployment file path +- Whether an existing deployment will be backed up + +### Deploy Contracts + +```bash +make deploy +``` + +This performs: + +1. Validates required environment variables +2. Builds contracts (`forge build`) +3. Deploys via `forge/Deploy.s.sol` +4. Saves addresses to `deployments/{network}.json` +5. Optionally verifies contracts on Tenderly + +### Custom Deployment Path + +```bash +export DEPLOYMENTS_PATH="deployments/my-custom-network.json" +make deploy +``` + +### Additional Forge Flags + +```bash +make deploy EXTRA_FLAGS="--skip-simulation --gas-price 1000000" +``` + +### Manual Deployment + +For full control, call forge directly: + +```bash + forge script forge/Deploy.s.sol:Deploy \ + --rpc-url "$TENDERLY_RPC_URL" \ + --private-key "$TENDERLY_PRIVATE_KEY" \ + --broadcast \ + --slow +``` + +## 4. Post-Deployment Validation + +Always validate after deployment: + +```bash +make validate +``` + +Or with custom path: + +```bash +make validate DEPLOYMENTS_PATH=deployments/base-sepolia.json +``` + +The validation script checks: + +- Proxy points to correct implementation +- Bridge nodes are configured +- Nock token connected to inbox +- Ownership is set +- Initial state is correct (withdrawals enabled, zero burns) + +### Inspect Deployment + +- Review `deployments/{network}.json` for addresses +- Open Tenderly dashboard to inspect transactions +- Check contract verification status + +### Contract Verification + +Contracts are automatically verified during deployment if `TENDERLY_ACCESS_KEY` is set. +To verify contracts manually (e.g., if deployment succeeded but verification failed): + +```bash +make verify +``` + +Or with custom deployment path: + +```bash +make verify DEPLOYMENTS_PATH=deployments/base-sepolia.json +``` + +**Required for verification:** + +- `TENDERLY_ACCESS_KEY` - Get from [Tenderly Authorization](https://dashboard.tenderly.co/account/authorization) (API Keys tab) +- `TENDERLY_RPC_URL` - Same RPC URL used for deployment + +The verification script verifies all three contracts: + +- Nock token +- MessageInbox implementation +- ERC1967Proxy + +**Note:** `cbor_metadata = true` must be set in `foundry.toml` for verification to succeed. +This is already configured in the project. + +## 5. Upgrading Contracts + +The MessageInbox uses UUPS (Universal Upgradeable Proxy Standard) for upgrades. + +### Pre-Upgrade Checklist + +- [ ] New implementation tested thoroughly +- [ ] Storage layout compatible (no reordering) +- [ ] Fork tests pass +- [ ] Gas usage acceptable (<200k for deposits) +- [ ] Owner key secure and accessible + +See [UPGRADE_GUIDE.md](docs/UPGRADE_GUIDE.md) for detailed upgrade procedures. + +### Quick Upgrade + +```bash +# Set INBOX_PRIVATE_KEY in .env (must be owner) +make upgrade +``` + +### Upgrade Steps + +1. **Test on fork** (recommended): + + ```bash + tenderly fork spawn --network base-sepolia + export TENDERLY_RPC_URL="" + export DEPLOYMENTS_PATH="deployments/fork-test.json" + make upgrade + make validate + make integration-test + ``` + +2. **Upgrade production**: + + ```bash + export DEPLOYMENTS_PATH="deployments/base-sepolia.json" + make upgrade + ``` + +3. **Validate upgrade**: + ```bash + make validate + make integration-test + ``` + +## 6. Operational Procedures + +### List All Deployments + +```bash +make list-deployments +``` + +Shows all deployment files in `deployments/` directory. + +### Rotate Bridge Node + +Update a bridge node address: + +```bash +# Set all bridge node addresses (only target index changes) +export BRIDGE_NODE_ADDR_0="0x..." +export BRIDGE_NODE_ADDR_1="0x..." +# ... etc + +# Rotate node at index 0 +./scripts/rotate_bridge_node.sh 0 0xNewAddress deployments/base-sepolia.json +``` + +Or use the SetBridgeNodes script directly: + +```bash +forge script forge/SetBridgeNodes.s.sol:SetBridgeNodes \ + --rpc-url "$TENDERLY_RPC_URL" \ + --private-key "$INBOX_PRIVATE_KEY" \ + --broadcast +``` + +### Emergency: Disable Withdrawals + +If critical issues are detected: + +```bash +make emergency-disable +``` + +This immediately disables withdrawals. Users cannot burn tokens until +re-enabled. Requires typing "DISABLE" to confirm. + +### Emergency: Re-enable Withdrawals + +After resolving the emergency: + +```bash +make emergency-enable +``` + +Requires typing "ENABLE" to confirm. + +### Transfer Ownership + +Transfer ownership of MessageInbox to a new address (e.g., multisig): + +```bash +make transfer-ownership NEW_OWNER=0x1234...abcd +``` + +Preview first with dry-run: + +```bash +./scripts/transfer_ownership.sh --dry-run 0x1234...abcd +``` + +**Important:** This initiates a two-step transfer. The new owner must call +`acceptOwnership()` on the MessageInbox contract to complete the transfer. +Until then, the original owner retains control. + +## 7. Multi-Network Deployment Tracking + +Deployments are tracked per network in `deployments/{network}.json`. + +### Default Behavior + +- If `DEPLOYMENTS_PATH` is not set, defaults to `deployments/{DEPLOY_TARGET_NETWORK}.json` +- Each network gets its own file +- Legacy `deployments.json` is still supported + +### Example Structure + +``` +deployments/ + ├── tenderly-devnet.json + ├── base-sepolia.json + ├── base-mainnet.json + └── history/ + ├── tenderly-devnet/ + │ ├── 20241209-143500.json + │ └── 20241209-150000.json + └── base-sepolia/ + └── 20241210-120000.json +``` + +### Deployment Backup + +Every deployment automatically backs up the previous deployment file before +overwriting. Backups are stored in `deployments/history/{network}/` with +timestamps. + +To restore a previous deployment: + +```bash +cp deployments/history/tenderly-devnet/20241209-143500.json deployments/tenderly-devnet.json +``` + +### List Deployments + +```bash +make list-deployments +``` + +Shows all current deployment files. + +To include deployment history: + +```bash +make list-deployments HISTORY=1 +``` + +This shows all current deployments plus historical backups with their proxy +addresses. + +## 8. Testing + +### Unit Tests + +```bash +make test +``` + +Runs Foundry tests with gas reporting. Every bridge contract change must keep +deposit flow under 200k gas. + +### Integration Tests + +Test against deployed contracts: + +```bash +# Set TEST_ACCOUNT_PRIVATE_KEY and BRIDGE_NODE_KEY_* in .env +make integration-test +``` + +Or with custom deployment: + +```bash +export DEPLOYMENTS_PATH="deployments/base-sepolia.json" +make integration-test +``` + +**Note for devnets:** Bridge node and test accounts need ETH for gas. See +[Fund Test Accounts](#fund-test-accounts-devnets-only) above to fund them +using `tenderly_setBalance`. + +The integration test: + +- Tests full deposit + burn cycle +- Validates gas usage +- Verifies state changes + +### Gas Baselines + +Every bridge contract change must keep the deposit flow under 200k gas. Run +tests with gas report: + +```bash +make test +``` + +The Foundry tests use `vm.pauseGasMetering()`/`vm.resumeGasMetering()` to +exclude signature fabrication overhead, ensuring measured gas approximates +on-chain execution. Treat any regression above 200k gas as a release blocker. + +## 9. Troubleshooting + +### Deployment Fails: Missing Variables + +Error: `Missing required environment variables` + +Solution: Check `.env` file or export variables. See `.env.template` for all +required variables. + +### Validation Fails: Proxy Mismatch + +Error: `Proxy implementation mismatch` + +Solution: Verify the implementation address in deployments.json matches the +proxy's current implementation. Check Tenderly dashboard. + +### Upgrade Fails: Not Owner + +Error: Transaction reverts with access control error + +Solution: Ensure `INBOX_PRIVATE_KEY` is set to the owner address. Check +ownership: + +```bash +cast call $PROXY_ADDRESS "owner()" --rpc-url "$TENDERLY_RPC_URL" +``` + +### Integration Test Fails: Signature Mismatch + +Error: `Bridge node key mismatch` + +Solution: Ensure `BRIDGE_NODE_KEY_*` variables match the addresses in +`BRIDGE_NODE_*`. The keys must correspond to the bridge node addresses. + +### Script Not Found + +Error: `./scripts/deploy_tenderly.sh: No such file` + +Solution: Ensure you're in the `contracts/` directory and scripts are +executable: + +```bash +chmod +x scripts/*.sh +``` + +### Deployment File Not Found + +Error: `Deployment file not found` + +Solution: Check `DEPLOYMENTS_PATH` is set correctly. Default is +`deployments/{DEPLOY_TARGET_NETWORK}.json`. Ensure the directory exists: + +```bash +mkdir -p deployments +``` + +## Deployment Checklist + +Before deploying to production: + +- [ ] All tests pass (`make test`) +- [ ] Gas usage under 200k for deposits +- [ ] Environment variables configured +- [ ] Bridge node addresses verified (all 5 unique) +- [ ] Bridge node private keys match addresses (for testing) +- [ ] Owner key secure and backed up +- [ ] Deployment tested on fork/devnet +- [ ] Test accounts funded (devnets only) +- [ ] Post-deployment validation passes +- [ ] Integration tests pass +- [ ] Contracts verified on Tenderly +- [ ] Deployment addresses recorded + +## Architecture Notes + +- `MessageInbox` is deployed behind an `ERC1967Proxy` (UUPS upgradeable) +- `Nock` token is non-upgradeable for immutability +- Nock token is connected to inbox proxy via `updateInbox` +- All bridge node addresses must be provided at deployment +- Bridge nodes can be rotated via `updateBridgeNode` after deployment +- The deployment script is network-agnostic: works with any Tenderly RPC + +## Manual Operations + +### Direct Contract Calls + +Use `cast` for manual operations: + +```bash +# Check owner +cast call $PROXY_ADDRESS "owner()" --rpc-url "$TENDERLY_RPC_URL" + +# Check bridge node +cast call $PROXY_ADDRESS "bridgeNodes(uint256)" 0 --rpc-url "$TENDERLY_RPC_URL" + +# Update bridge node (requires owner) +cast send $PROXY_ADDRESS "updateBridgeNode(uint256,address)" 0 0xNewAddress \ + --rpc-url "$TENDERLY_RPC_URL" \ + --private-key "$INBOX_PRIVATE_KEY" +``` + +## Additional Resources + +- [UPGRADE_GUIDE.md](docs/UPGRADE_GUIDE.md) - Detailed upgrade procedures +- [tenderly.env.example](tenderly.env.example) - Legacy env example +- [environments/](environments/) - Pre-configured environment examples diff --git a/crates/bridge/contracts/Makefile b/crates/bridge/contracts/Makefile new file mode 100644 index 000000000..547b5c7a0 --- /dev/null +++ b/crates/bridge/contracts/Makefile @@ -0,0 +1,143 @@ +# Makefile for Foundry + Tenderly integration + +.PHONY: help build test clean verify install deps deploy upgrade validate integration-test list-deployments emergency-disable emergency-enable transfer-ownership + +help: + @echo "Available commands:" + @echo " build - Build the project" + @echo " test - Run tests" + @echo " clean - Clean build artifacts" + @echo " verify - Verify contracts on Tenderly" + @echo " deploy - Deploy contracts using Tenderly RPC" + @echo " upgrade - Upgrade MessageInbox implementation" + @echo " validate - Validate deployed contract configuration" + @echo " integration-test - Run integration tests against deployed contracts" + @echo " list-deployments - List all deployment files (use HISTORY=1 for history)" + @echo " emergency-disable - Disable withdrawals (emergency only)" + @echo " emergency-enable - Re-enable withdrawals after emergency" + @echo " transfer-ownership - Transfer ownership to new address (e.g., multisig)" + @echo " install - Install forge, tenderly, and deps" + @echo " deps - Install forge dependencies" + +build: + forge build --optimize true + +build-strict: + forge build --optimize true --deny-warnings + +test: + forge test --gas-report -vvv + +clean: + forge clean + rm -f deployments.json + rm -rf deployments/*.json + +verify: + @if [ -f .env ]; then \ + set -a; \ + . ./.env; \ + set +a; \ + fi; \ + ./scripts/verify_contracts.sh $(DEPLOYMENTS_PATH) + +deploy: + @if [ -f .env ]; then \ + set -a; \ + . ./.env; \ + set +a; \ + fi; \ + ./scripts/deploy_tenderly.sh $(EXTRA_FLAGS) + +upgrade: + @if [ -f .env ]; then \ + set -a; \ + . ./.env; \ + set +a; \ + fi; \ + ./scripts/upgrade_tenderly.sh $(DEPLOYMENTS_PATH) + +validate: + @if [ -f .env ]; then \ + set -a; \ + . ./.env; \ + set +a; \ + fi; \ + ./scripts/validate_deployment.sh $(DEPLOYMENTS_PATH) + +integration-test: + @if [ -f .env ]; then \ + set -a; \ + . ./.env; \ + set +a; \ + fi; \ + forge script forge/IntegrationTest.s.sol:IntegrationTest \ + --rpc-url "$${TENDERLY_RPC_URL}" \ + --private-key "$${TEST_ACCOUNT_PRIVATE_KEY:-$${TENDERLY_PRIVATE_KEY}}" \ + --broadcast \ + --slow + +list-deployments: + @./scripts/list_deployments.sh $(if $(HISTORY),--history,) + +emergency-disable: + @if [ -f .env ]; then \ + set -a; \ + . ./.env; \ + set +a; \ + fi; \ + ./scripts/emergency_disable_withdrawals.sh $(DEPLOYMENTS_PATH) + +emergency-enable: + @if [ -f .env ]; then \ + set -a; \ + . ./.env; \ + set +a; \ + fi; \ + ./scripts/emergency_enable_withdrawals.sh $(DEPLOYMENTS_PATH) + +transfer-ownership: + @if [ -z "$(NEW_OWNER)" ]; then \ + echo "Error: NEW_OWNER not set. Usage: make transfer-ownership NEW_OWNER=0x..." >&2; \ + exit 1; \ + fi; \ + if [ -f .env ]; then \ + set -a; \ + . ./.env; \ + set +a; \ + fi; \ + ./scripts/transfer_ownership.sh $(NEW_OWNER) $(DEPLOYMENTS_PATH) + +install: + @echo "Installing Foundry (forge/cast) if missing..." + @if command -v foundryup >/dev/null 2>&1; then \ + true; \ + else \ + curl -L https://foundry.paradigm.xyz | bash; \ + fi + @if [ -x "$$HOME/.foundry/bin/foundryup" ]; then \ + "$$HOME/.foundry/bin/foundryup"; \ + elif command -v foundryup >/dev/null 2>&1; then \ + foundryup; \ + else \ + echo "foundryup not found after install" >&2; exit 1; \ + fi + @echo "Installing Tenderly CLI if missing..." + @command -v tenderly >/dev/null 2>&1 || ( \ + if [ "$$(uname)" = "Darwin" ]; then \ + curl -s https://raw.githubusercontent.com/Tenderly/tenderly-cli/master/scripts/install-macos.sh | sh; \ + elif [ "$$(uname)" = "Linux" ]; then \ + curl -s https://raw.githubusercontent.com/Tenderly/tenderly-cli/master/scripts/install-linux.sh | sh; \ + else \ + echo "Unsupported OS for Tenderly installer" >&2; exit 1; \ + fi \ + ) + $(MAKE) deps + +deps: + @echo "Installing forge dependencies..." + @mkdir -p lib + @test -d lib/forge-std || forge install --no-git --shallow foundry-rs/forge-std@v1.12.0 + @test -d lib/openzeppelin-contracts || forge install --no-git --shallow OpenZeppelin/openzeppelin-contracts@v5.5.0 + @test -d lib/openzeppelin-contracts-upgradeable || forge install --no-git --shallow OpenZeppelin/openzeppelin-contracts-upgradeable@v5.5.0 + @echo "Dependencies are ready." diff --git a/crates/bridge/contracts/MessageInbox.sol b/crates/bridge/contracts/MessageInbox.sol new file mode 100644 index 000000000..304db8c91 --- /dev/null +++ b/crates/bridge/contracts/MessageInbox.sol @@ -0,0 +1,332 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import "./Nock.sol"; + +struct Tip5Hash { + uint64[5] limbs; +} + +/** + * @title MessageInbox + * @dev Upgradeable contract for bridge message processing + * + * This contract receives deposits from bridge nodes and mints wrapped nock. + * The contract is upgradeable but only the bridge multisig can upgrade it. + */ +contract MessageInbox is Initializable, UUPSUpgradeable, OwnableUpgradeable { + string public constant VERSION = "1.0.0"; + + // Enforce canonical ECDSA signatures: secp256k1 group order (N) defines the + // valid range for `s`, and values above N/2 represent the malleated form of + // the same signature. Rejecting s > N/2 and non-standard v (anything other + // than 27/28) prevents attackers from replaying a mutated signature that + // still passes ecrecover but points to the same signer. + uint256 private constant SECP256K1_N = + 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141; + uint256 private constant SECP256K1_HALF_N = SECP256K1_N / 2; + + // Bridge node addresses (5 multisig addresses) + address[5] public bridgeNodes; + + // Signature threshold (3-of-5) + uint256 public constant THRESHOLD = 3; + + /** + * @dev Replay protection for deposit bundles. + * + * SECURITY CRITICAL: This mapping is the ONLY on-chain mechanism preventing + * double-mint attacks. Bridge node signatures do not expire, so without this + * check, any valid signed bundle could be resubmitted to mint tokens again. + * + * The off-chain Hoon state machine tracks deposits but cannot prevent replay + * because it is not the final arbiter of token minting - this contract is. + * + * Key: keccak256 of Nockchain txId (Tip5Hash encoded as 5 uint64 limbs) + * Value: true if this deposit has been processed + */ + mapping(bytes32 => bool) public processedDeposits; + uint256 public lastDepositNonce; + + // Withdrawals toggle: when false, burns on Nock.sol revert atomically + // since notifyBurn is called during the burn transaction + bool public withdrawalsEnabled; + + // Nock token contract + Nock public nock; + + // Events + event DepositProcessed( + bytes32 indexed txId, + bytes32 indexed nameFirstHash, + address indexed recipient, + Tip5Hash txIdFull, + Tip5Hash nameFirst, + Tip5Hash nameLast, + uint256 amount, + uint256 blockHeight, + Tip5Hash asOf, + uint256 nonce + ); + + event BridgeNodeUpdated( + uint256 indexed index, + address indexed oldNode, + address indexed newNode + ); + + event WithdrawalsToggled(bool enabled); + + /** + * @dev Initialize the contract + * @param _bridgeNodes Array of 5 bridge node addresses + * @param _nock Address of the Nock token contract + */ + function initialize( + address[5] memory _bridgeNodes, + address _nock + ) public initializer { + __Ownable_init(msg.sender); + + // Validate all bridge nodes are non-zero and unique + for (uint256 i = 0; i < 5; i++) { + require(_bridgeNodes[i] != address(0), "Bridge node cannot be zero address"); + for (uint256 j = i + 1; j < 5; j++) { + require(_bridgeNodes[i] != _bridgeNodes[j], "Duplicate bridge node address"); + } + } + + bridgeNodes = _bridgeNodes; + nock = Nock(_nock); + withdrawalsEnabled = true; + } + + /** + * @dev Submit a deposit bundle from bridge nodes + * @param txId Nockchain transaction ID (5 field elements) + * @param nameFirst First component of Nockchain note name (5 field elements) + * @param nameLast Last component of Nockchain note name (5 field elements) + * @param recipient Ethereum recipient address + * @param amount Amount of nock to mint (in nicks) + * @param blockHeight Nockchain block height + * @param asOf Hash establishing causality (5 field elements) + * @param depositNonce Strictly monotonic nonce for event ordering + * @param ethSigs Array of Ethereum signatures from bridge nodes + */ + function submitDeposit( + Tip5Hash calldata txId, + Tip5Hash calldata nameFirst, + Tip5Hash calldata nameLast, + address recipient, + uint256 amount, + uint256 blockHeight, + Tip5Hash calldata asOf, + uint256 depositNonce, + bytes[] calldata ethSigs + ) external { + require(ethSigs.length >= THRESHOLD, "Insufficient Ethereum signatures"); + require(_isValidTip5(txId), "Invalid txId"); + require(_isValidTip5(nameFirst), "Invalid nameFirst"); + require(_isValidTip5(nameLast), "Invalid nameLast"); + require(_isValidTip5(asOf), "Invalid asOf"); + require(!_isZeroTip5(asOf), "Invalid as-of hash"); + require(amount > 0, "Amount must be positive"); + require(recipient != address(0), "Invalid recipient"); + + bytes32 txIdHash = _hashTip5(txId); + + require(!processedDeposits[txIdHash], "Deposit already processed"); + require(depositNonce > lastDepositNonce, "Nonce must be strictly greater"); + + require( + _verifySignatures( + _computeDepositHash(txId, nameFirst, nameLast, recipient, amount, blockHeight, asOf, depositNonce), + ethSigs + ), + "Invalid Ethereum signatures" + ); + + processedDeposits[txIdHash] = true; + lastDepositNonce = depositNonce; + nock.mint(recipient, amount); + + emit DepositProcessed(txIdHash, _hashTip5(nameFirst), recipient, txId, nameFirst, nameLast, amount, blockHeight, asOf, depositNonce); + } + + + /** + * @dev Update bridge node address + * @param index Index of bridge node (0-4) + * @param newNode New bridge node address + */ + function updateBridgeNode( + uint256 index, + address newNode + ) external onlyOwner { + require(index < 5, "Invalid bridge node index"); + require(newNode != address(0), "Invalid bridge node address"); + + // Check that newNode doesn't duplicate any existing node + for (uint256 i = 0; i < 5; i++) { + if (i != index) { + require(bridgeNodes[i] != newNode, "Duplicate bridge node address"); + } + } + + address oldNode = bridgeNodes[index]; + bridgeNodes[index] = newNode; + + emit BridgeNodeUpdated(index, oldNode, newNode); + } + + /** + * @dev Toggle withdrawals (Base -> Nockchain) on/off. + * When disabled, Nock.burn() reverts atomically since it calls notifyBurn. + * @param enabled Whether withdrawals should be enabled + */ + function setWithdrawalsEnabled(bool enabled) external onlyOwner { + withdrawalsEnabled = enabled; + emit WithdrawalsToggled(enabled); + } + + /** + * @dev Notify of burn for withdrawal (called by Nock contract). + * Reverts if withdrawals disabled, causing the entire burn tx to revert. + */ + function notifyBurn() external view { + require(withdrawalsEnabled, "Withdrawals are disabled"); + require( + msg.sender == address(nock), + "Only Nock contract can notify burns" + ); + } + + function _isValidTip5(Tip5Hash calldata h) internal pure returns (bool) { + uint64 PRIME = 0xffffffff00000001; + return h.limbs[0] < PRIME && h.limbs[1] < PRIME && h.limbs[2] < PRIME && h.limbs[3] < PRIME && h.limbs[4] < PRIME; + } + + function _isZeroTip5(Tip5Hash calldata h) internal pure returns (bool) { + return h.limbs[0] == 0 && h.limbs[1] == 0 && h.limbs[2] == 0 && h.limbs[3] == 0 && h.limbs[4] == 0; + } + + function _encodeTip5(Tip5Hash calldata h) internal pure returns (bytes memory) { + return abi.encodePacked(h.limbs[0], h.limbs[1], h.limbs[2], h.limbs[3], h.limbs[4]); + } + + function _hashTip5(Tip5Hash calldata h) internal pure returns (bytes32) { + return keccak256(_encodeTip5(h)); + } + + function _computeDepositHash( + Tip5Hash calldata txId, + Tip5Hash calldata nameFirst, + Tip5Hash calldata nameLast, + address recipient, + uint256 amount, + uint256 blockHeight, + Tip5Hash calldata asOf, + uint256 depositNonce + ) internal pure returns (bytes32) { + return keccak256(abi.encodePacked( + _encodeTip5(txId), + _encodeTip5(nameFirst), + _encodeTip5(nameLast), + recipient, amount, blockHeight, + _encodeTip5(asOf), + depositNonce + )); + } + + function _verifySignatures(bytes32 messageHash, bytes[] calldata sigs) internal view returns (bool) { + bytes32 ethSignedMessageHash = keccak256( + abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash) + ); + + uint256 validSigs = 0; + bool[5] memory seen; + for (uint256 i = 0; i < sigs.length; i++) { + address signer = recoverSigner(ethSignedMessageHash, sigs[i]); + (bool isNode, uint256 index) = findNodeIndex(signer); + if (isNode && !seen[index]) { + seen[index] = true; + validSigs++; + } + } + + return validSigs >= THRESHOLD; + } + + /** + * @dev Check if address is a valid bridge node + */ + function isBridgeNode(address node) internal view returns (bool) { + for (uint256 i = 0; i < 5; i++) { + if (bridgeNodes[i] == node) { + return true; + } + } + return false; + } + + function findNodeIndex( + address signer + ) internal view returns (bool, uint256) { + for (uint256 i = 0; i < 5; i++) { + if (bridgeNodes[i] == signer) { + return (true, i); + } + } + return (false, 0); + } + + /** + * @dev Recover signer from signature + */ + function recoverSigner( + bytes32 messageHash, + bytes memory signature + ) internal pure returns (address) { + require(signature.length == 65, "Invalid signature length"); + + bytes32 r; + bytes32 s; + uint8 v; + + assembly { + r := mload(add(signature, 32)) + s := mload(add(signature, 64)) + v := byte(0, mload(add(signature, 96))) + } + + require( + uint256(s) > 0 && uint256(s) <= SECP256K1_HALF_N, + "Invalid signature s value" + ); + require(v == 27 || v == 28, "Invalid signature v value"); + + address signer = ecrecover(messageHash, v, r, s); + require(signer != address(0), "Invalid signature"); + return signer; + } + + /** + * @dev Authorize upgrade (only owner) + */ + function _authorizeUpgrade( + address newImplementation + ) internal override onlyOwner {} + + /** + * @dev Upgrade the implementation (only owner, through proxy) + * This is a convenience function that calls upgradeToAndCall with empty data + */ + function upgradeTo( + address newImplementation + ) public onlyOwner { + upgradeToAndCall(newImplementation, new bytes(0)); + } +} diff --git a/crates/bridge/contracts/Nock.sol b/crates/bridge/contracts/Nock.sol new file mode 100644 index 000000000..ed6151c25 --- /dev/null +++ b/crates/bridge/contracts/Nock.sol @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import "@openzeppelin/contracts/access/Ownable.sol"; + +interface IMessageInbox { + function notifyBurn() external; +} + +/** + * @title Nock + * @dev ERC-20 token representing wrapped Nockchain assets + * + * This contract is NOT upgradeable to ensure immutability of the token. + * Users can burn tokens to initiate withdrawals to Nockchain. + * Only the MessageInbox contract can mint new tokens. + */ +contract Nock is ERC20, Ownable { + // MessageInbox contract address (only it can mint) + address public inbox; + + // Events + event BurnForWithdrawal( + address indexed burner, + uint256 amount, + bytes32 indexed lockRoot + ); + + event InboxUpdated(address indexed oldInbox, address indexed newInbox); + + /** + * @dev Constructor + * @param _name Token name + * @param _symbol Token symbol + * @param _inbox Address of MessageInbox contract + */ + constructor( + string memory _name, + string memory _symbol, + address _inbox + ) ERC20(_name, _symbol) Ownable(msg.sender) { + inbox = _inbox; + } + + /** + * @dev Mint tokens (only MessageInbox can call this) + * @param to Recipient address + * @param amount Amount to mint + */ + function mint(address to, uint256 amount) external { + require(msg.sender == inbox, "Only inbox can mint"); + require(to != address(0), "Cannot mint to zero address"); + require(amount > 0, "Amount must be positive"); + + _mint(to, amount); + } + + /** + * @dev Burn tokens to initiate withdrawal to Nockchain + * @param amount Amount to burn + * @param lockRoot Lock script root hash for Nockchain note + */ + function burn(uint256 amount, bytes32 lockRoot) external { + require(amount > 0, "Amount must be positive"); + require(balanceOf(msg.sender) >= amount, "Insufficient balance"); + + _burn(msg.sender, amount); + + emit BurnForWithdrawal(msg.sender, amount, lockRoot); + + // Notify inbox for withdrawal toggle check + IMessageInbox(inbox).notifyBurn(); + } + + /** + * @dev Update MessageInbox address (only owner) + * @param _newInbox New MessageInbox address + */ + function updateInbox(address _newInbox) external onlyOwner { + require(_newInbox != address(0), "Invalid inbox address"); + + address oldInbox = inbox; + inbox = _newInbox; + + emit InboxUpdated(oldInbox, _newInbox); + } + + /** + * @dev Get token decimals (16 for Nockchain alignment) + */ + function decimals() public pure override returns (uint8) { + return 16; + } + + /** + * @dev Get total supply cap (no cap for simplicity) + */ + function cap() public pure returns (uint256) { + return 0; // No cap + } +} diff --git a/crates/bridge/contracts/UPGRADE_GUIDE.md b/crates/bridge/contracts/UPGRADE_GUIDE.md new file mode 100644 index 000000000..238234d79 --- /dev/null +++ b/crates/bridge/contracts/UPGRADE_GUIDE.md @@ -0,0 +1,232 @@ +# MessageInbox Upgrade Guide + +This guide covers the process for upgrading the MessageInbox implementation via +UUPS proxy. + +## Pre-Upgrade Checklist + +Before upgrading, ensure: + +- [ ] New implementation has been thoroughly tested +- [ ] Storage layout is compatible (no storage variable reordering) +- [ ] All critical functions work correctly in fork tests +- [ ] Gas usage remains acceptable (<200k for deposits) +- [ ] Owner private key is secure and accessible +- [ ] Backup of current deployment addresses + +## Fork Testing Procedure + +1. Fork the target network in Tenderly: + ```bash + tenderly fork spawn --network base-sepolia --project bridge-contracts + ``` + +2. Deploy new implementation to fork: + ```bash + export TENDERLY_RPC_URL="" + export INBOX_PRIVATE_KEY="" + export DEPLOYMENTS_PATH="deployments/fork-test.json" + + # Deploy new implementation + forge script forge/Upgrade.s.sol:Upgrade \ + --rpc-url "$TENDERLY_RPC_URL" \ + --private-key "$INBOX_PRIVATE_KEY" \ + --broadcast + ``` + +3. Run validation: + ```bash + make validate DEPLOYMENTS_PATH=deployments/fork-test.json + ``` + +4. Run integration tests: + ```bash + make integration-test DEPLOYMENTS_PATH=deployments/fork-test.json + ``` + +5. Verify critical functions: + - Deposit flow works correctly + - Withdrawal flow works correctly + - Bridge node updates work + - Ownership is preserved + +## Upgrade Execution Steps + +1. **Prepare environment**: + ```bash + cd open/crates/bridge/contracts + source .env # or source environments/{network}.example + ``` + +2. **Set deployment path** (if not using default): + ```bash + export DEPLOYMENTS_PATH="deployments/base-sepolia.json" + ``` + +3. **Build contracts**: + ```bash + make build + ``` + +4. **Execute upgrade**: + ```bash + make upgrade + ``` + + Or manually: + ```bash + ./scripts/upgrade_tenderly.sh deployments/base-sepolia.json + ``` + +5. **Verify upgrade in Tenderly**: + - Check transaction succeeded + - Verify proxy implementation address updated + - Review storage layout + +6. **Run post-upgrade validation**: + ```bash + make validate + ``` + +7. **Run integration tests**: + ```bash + make integration-test + ``` + +## Post-Upgrade Validation + +After upgrading, verify: + +- [ ] Proxy points to new implementation +- [ ] All bridge nodes still configured correctly +- [ ] Nock token still connected to inbox +- [ ] Ownership unchanged +- [ ] Withdrawals still enabled (if expected) +- [ ] Deposit flow works end-to-end +- [ ] Withdrawal flow works end-to-end + +Use the validation script: +```bash +make validate DEPLOYMENTS_PATH=deployments/{network}.json +``` + +## Emergency Rollback Procedure + +If an upgrade introduces critical issues: + +1. **Immediately disable withdrawals** (if needed): + ```bash + make emergency-disable DEPLOYMENTS_PATH=deployments/{network}.json + ``` + +2. **Deploy previous implementation**: + - Locate previous implementation address from deployment history + - Deploy previous version code + - Upgrade proxy back to previous implementation: + ```bash + # Set NEW_IMPL_ADDRESS to previous implementation + forge script forge/Upgrade.s.sol:Upgrade \ + --rpc-url "$TENDERLY_RPC_URL" \ + --private-key "$INBOX_PRIVATE_KEY" \ + --broadcast \ + --sig "upgradeTo(address)" "$NEW_IMPL_ADDRESS" + ``` + +3. **Verify rollback**: + ```bash + make validate + make integration-test + ``` + +4. **Re-enable withdrawals** (if disabled): + ```bash + make emergency-enable DEPLOYMENTS_PATH=deployments/{network}.json + ``` + +## Storage Layout Compatibility + +**CRITICAL**: Never reorder storage variables or change types in a way that +shifts storage slots. The UUPS pattern allows upgrades, but storage layout +must remain compatible. + +### MessageInbox Storage Layout (v1.0.0) + +The contract inherits from OpenZeppelin's upgradeable contracts which use +namespaced storage (EIP-7201). Our custom storage starts after the inherited +slots. + +``` +┌──────────────────────────────────────────────────────────────────────────────┐ +│ INHERITED STORAGE (OpenZeppelin Upgradeable Contracts) │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ Initializable: Uses EIP-7201 namespaced storage │ +│ OwnableUpgradeable: Uses EIP-7201 namespaced storage (owner address) │ +│ UUPSUpgradeable: Uses EIP-7201 namespaced storage │ +└──────────────────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────────────────┐ +│ MESSAGEINBOX CUSTOM STORAGE │ +├──────┬──────────────────────┬────────────────────────────┬───────────────────┤ +│ Slot │ Variable │ Type │ Size (bytes) │ +├──────┼──────────────────────┼────────────────────────────┼───────────────────┤ +│ 0-4 │ bridgeNodes │ address[5] │ 160 (5 slots) │ +│ 5 │ processedDeposits │ mapping(bytes32 => bool) │ 32 (slot only) │ +│ 6 │ withdrawalsEnabled │ bool │ 1 (offset 0) │ +│ 6 │ nock │ address (Nock contract) │ 20 (offset 1) │ +└──────┴──────────────────────┴────────────────────────────┴───────────────────┘ +``` + +**Storage Notes:** +- `bridgeNodes` occupies slots 0-4 (5 addresses × 32 bytes/slot) +- `processedDeposits` mapping uses slot 5 as its base slot; actual values are + stored at `keccak256(key . slot)` +- `withdrawalsEnabled` and `nock` are packed into slot 6 (1 byte + 20 bytes = 21 bytes) +- Constants (`VERSION`, `THRESHOLD`, `SECP256K1_N`, `SECP256K1_HALF_N`) do NOT + use storage slots + +**Regenerate this layout:** +```bash +forge inspect MessageInbox storage-layout +``` + +### Safe Upgrade Rules + +When adding new storage variables: +- Add them at the end (after slot 7) +- Never remove or reorder existing variables +- Use storage gaps if removing variables +- New variables will start at slot 8 + +**Example of safe addition:** +```solidity +// SAFE: Adding at the end +uint256 public newVariable; // Will be slot 8 + +// UNSAFE: Inserting between existing variables +// This would shift all subsequent slots and corrupt storage! +``` + +### Upgrade Checklist for Storage + +- [ ] Run `forge inspect MessageInbox storage-layout` before and after changes +- [ ] Verify no existing slots have changed position +- [ ] Verify no types have changed in existing slots +- [ ] New variables are added at the end only +- [ ] Increment VERSION constant + +## Version Tracking + +The `VERSION` constant in MessageInbox tracks the implementation version. +Increment it when deploying a new implementation: + +```solidity +string public constant VERSION = "1.1.0"; // Increment for new version +``` + +## Notes + +- Upgrades are irreversible once executed (except by upgrading again) +- Always test upgrades on forks before production +- Keep deployment records for all upgrades +- Document any breaking changes or migration requirements + diff --git a/crates/bridge/contracts/docs/UPGRADE_GUIDE.md b/crates/bridge/contracts/docs/UPGRADE_GUIDE.md new file mode 100644 index 000000000..859ce236c --- /dev/null +++ b/crates/bridge/contracts/docs/UPGRADE_GUIDE.md @@ -0,0 +1,231 @@ +# MessageInbox Upgrade Guide + +This guide covers the process for upgrading the MessageInbox implementation via +UUPS proxy. + +## Pre-Upgrade Checklist + +Before upgrading, ensure: + +- [ ] New implementation has been thoroughly tested +- [ ] Storage layout is compatible (no storage variable reordering) +- [ ] All critical functions work correctly in fork tests +- [ ] Gas usage remains acceptable (<200k for deposits) +- [ ] Owner private key is secure and accessible +- [ ] Backup of current deployment addresses + +## Fork Testing Procedure + +1. Fork the target network in Tenderly: + ```bash + tenderly fork spawn --network base-sepolia --project bridge-contracts + ``` + +2. Deploy new implementation to fork: + ```bash + export TENDERLY_RPC_URL="" + export INBOX_PRIVATE_KEY="" + export DEPLOYMENTS_PATH="deployments/fork-test.json" + + # Deploy new implementation + forge script forge/Upgrade.s.sol:Upgrade \ + --rpc-url "$TENDERLY_RPC_URL" \ + --private-key "$INBOX_PRIVATE_KEY" \ + --broadcast + ``` + +3. Run validation: + ```bash + make validate DEPLOYMENTS_PATH=deployments/fork-test.json + ``` + +4. Run integration tests: + ```bash + make integration-test DEPLOYMENTS_PATH=deployments/fork-test.json + ``` + +5. Verify critical functions: + - Deposit flow works correctly + - Withdrawal flow works correctly + - Bridge node updates work + - Ownership is preserved + +## Upgrade Execution Steps + +1. **Prepare environment**: + ```bash + cd open/crates/bridge/contracts + source .env # or source environments/{network}.example + ``` + +2. **Set deployment path** (if not using default): + ```bash + export DEPLOYMENTS_PATH="deployments/base-sepolia.json" + ``` + +3. **Build contracts**: + ```bash + make build + ``` + +4. **Execute upgrade**: + ```bash + make upgrade + ``` + + Or manually: + ```bash + ./scripts/upgrade_tenderly.sh deployments/base-sepolia.json + ``` + +5. **Verify upgrade in Tenderly**: + - Check transaction succeeded + - Verify proxy implementation address updated + - Review storage layout + +6. **Run post-upgrade validation**: + ```bash + make validate + ``` + +7. **Run integration tests**: + ```bash + make integration-test + ``` + +## Post-Upgrade Validation + +After upgrading, verify: + +- [ ] Proxy points to new implementation +- [ ] All bridge nodes still configured correctly +- [ ] Nock token still connected to inbox +- [ ] Ownership unchanged +- [ ] Withdrawals still enabled (if expected) +- [ ] Deposit flow works end-to-end +- [ ] Withdrawal flow works end-to-end + +Use the validation script: +```bash +make validate DEPLOYMENTS_PATH=deployments/{network}.json +``` + +## Emergency Rollback Procedure + +If an upgrade introduces critical issues: + +1. **Immediately disable withdrawals** (if needed): + ```bash + make emergency-disable DEPLOYMENTS_PATH=deployments/{network}.json + ``` + +2. **Deploy previous implementation**: + - Locate previous implementation address from deployment history + - Deploy previous version code + - Upgrade proxy back to previous implementation: + ```bash + # Set NEW_IMPL_ADDRESS to previous implementation + forge script forge/Upgrade.s.sol:Upgrade \ + --rpc-url "$TENDERLY_RPC_URL" \ + --private-key "$INBOX_PRIVATE_KEY" \ + --broadcast \ + --sig "upgradeTo(address)" "$NEW_IMPL_ADDRESS" + ``` + +3. **Verify rollback**: + ```bash + make validate + make integration-test + ``` + +4. **Re-enable withdrawals** (if disabled): + ```bash + make emergency-enable DEPLOYMENTS_PATH=deployments/{network}.json + ``` + +## Storage Layout Compatibility + +**CRITICAL**: Never reorder storage variables or change types in a way that +shifts storage slots. The UUPS pattern allows upgrades, but storage layout +must remain compatible. + +### MessageInbox Storage Layout (v1.0.0) + +The contract inherits from OpenZeppelin's upgradeable contracts which use +namespaced storage (EIP-7201). Our custom storage starts after the inherited +slots. + +``` +┌──────────────────────────────────────────────────────────────────────────────┐ +│ INHERITED STORAGE (OpenZeppelin Upgradeable Contracts) │ +├──────────────────────────────────────────────────────────────────────────────┤ +│ Initializable: Uses EIP-7201 namespaced storage │ +│ OwnableUpgradeable: Uses EIP-7201 namespaced storage (owner address) │ +│ UUPSUpgradeable: Uses EIP-7201 namespaced storage │ +└──────────────────────────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────────────────────────┐ +│ MESSAGEINBOX CUSTOM STORAGE │ +├──────┬──────────────────────┬────────────────────────────┬───────────────────┤ +│ Slot │ Variable │ Type │ Size (bytes) │ +├──────┼──────────────────────┼────────────────────────────┼───────────────────┤ +│ 0-4 │ bridgeNodes │ address[5] │ 160 (5 slots) │ +│ 5 │ processedDeposits │ mapping(bytes32 => bool) │ 32 (slot only) │ +│ 6 │ withdrawalsEnabled │ bool │ 1 (offset 0) │ +│ 6 │ nock │ address (Nock contract) │ 20 (offset 1) │ +└──────┴──────────────────────┴────────────────────────────┴───────────────────┘ +``` + +**Storage Notes:** +- `bridgeNodes` occupies slots 0-4 (5 addresses × 32 bytes/slot) +- `processedDeposits` mapping uses slot 5 as its base slot; actual values are + stored at `keccak256(key . slot)` +- `withdrawalsEnabled` and `nock` are packed into slot 6 (1 byte + 20 bytes = 21 bytes) +- Constants (`VERSION`, `THRESHOLD`, `SECP256K1_N`, `SECP256K1_HALF_N`) do NOT + use storage slots + +**Regenerate this layout:** +```bash +forge inspect MessageInbox storage-layout +``` + +### Safe Upgrade Rules + +When adding new storage variables: +- Add them at the end (after slot 7) +- Never remove or reorder existing variables +- Use storage gaps if removing variables +- New variables will start at slot 8 + +**Example of safe addition:** +```solidity +// SAFE: Adding at the end +uint256 public newVariable; // Will be slot 8 + +// UNSAFE: Inserting between existing variables +// This would shift all subsequent slots and corrupt storage! +``` + +### Upgrade Checklist for Storage + +- [ ] Run `forge inspect MessageInbox storage-layout` before and after changes +- [ ] Verify no existing slots have changed position +- [ ] Verify no types have changed in existing slots +- [ ] New variables are added at the end only +- [ ] Increment VERSION constant + +## Version Tracking + +The `VERSION` constant in MessageInbox tracks the implementation version. +Increment it when deploying a new implementation: + +```solidity +string public constant VERSION = "1.1.0"; // Increment for new version +``` + +## Notes + +- Upgrades are irreversible once executed (except by upgrading again) +- Always test upgrades on forks before production +- Keep deployment records for all upgrades +- Document any breaking changes or migration requirements diff --git a/crates/bridge/contracts/environments/base-sepolia-testnet-accounts.md b/crates/bridge/contracts/environments/base-sepolia-testnet-accounts.md new file mode 100644 index 000000000..449b6311b --- /dev/null +++ b/crates/bridge/contracts/environments/base-sepolia-testnet-accounts.md @@ -0,0 +1,62 @@ +# Base Sepolia Testnet Deployment + +Bridge contracts deployed to real Base Sepolia network via Tenderly gateway RPC. + +## Funding Instructions + +The **deployer account** needs Base Sepolia ETH to deploy contracts. Use one of these faucets: + +1. **Alchemy** (recommended): https://www.alchemy.com/faucets/base-sepolia +2. **QuickNode**: https://faucet.quicknode.com/base/sepolia +3. **GetBlock**: https://getblock.io/faucet/base-sepolia/ (requires 0.005 mainnet ETH) + +Fund the deployer address: `$BASE_SEPOLIA_DEPLOYER_ADDRESS` (see devenv.nix) + +## Accounts + +All private keys and addresses are stored in `devenv.nix` under the `BASE_SEPOLIA_*` prefix. + +### Environment Variables (from devenv.nix) + +| Variable | Description | +| ------------------------------------ | -------------------------- | +| `BASE_SEPOLIA_DEPLOYER_ADDRESS` | Deployer account address | +| `BASE_SEPOLIA_DEPLOYER_KEY` | Deployer private key | +| `BASE_SEPOLIA_BRIDGE_NODE_ADDR_0..4` | Bridge node addresses | +| `BASE_SEPOLIA_BRIDGE_NODE_KEY_0..4` | Bridge node private keys | +| `BASE_SEPOLIA_RPC_URL` | Tenderly gateway HTTPS URL | +| `BASE_SEPOLIA_WS_URL` | Tenderly gateway WSS URL | + +## Check Balance + +```bash +cast balance $BASE_SEPOLIA_DEPLOYER_ADDRESS --rpc-url $BASE_SEPOLIA_RPC_URL +``` + +## Quick Deploy (after funding) + +```bash +cd open/crates/bridge/contracts +source environments/base-sepolia.env +make deploy +``` + +## Deployed Contracts (2025-12-17) + +| Contract | Address | Basescan | +| -------------------- | -------------------------------------------- | -------------------------------------------------------------------------------------------- | +| MessageInbox (Proxy) | `0x9b1becA13c39b9Be10dB616F1bE10C3CeF9Dfb36` | [View](https://sepolia.basescan.org/address/0x9b1becA13c39b9Be10dB616F1bE10C3CeF9Dfb36#code) | +| MessageInbox (Impl) | `0x7627Db3A99596668c9d42693efa352Ca69F089e3` | [View](https://sepolia.basescan.org/address/0x7627Db3A99596668c9d42693efa352Ca69F089e3#code) | +| Nock Token | `0xA9cd4087D9B050D8B35727AAf810296CA957c7B3` | [View](https://sepolia.basescan.org/address/0xA9cd4087D9B050D8B35727AAf810296CA957c7B3#code) | + +**Tenderly Dashboard**: https://dashboard.tenderly.co/zorp/bridge/contracts + +## Verification + +Contracts are verified on Basescan using Etherscan API V2. To verify new deployments: + +```bash +forge verify-contract
--chain base-sepolia --verifier etherscan --watch +``` + +Requires `ETHERSCAN_API_KEY` environment variable (set in devenv.nix). diff --git a/crates/bridge/contracts/environments/base-sepolia.example b/crates/bridge/contracts/environments/base-sepolia.example new file mode 100644 index 000000000..4667aea3f --- /dev/null +++ b/crates/bridge/contracts/environments/base-sepolia.example @@ -0,0 +1,29 @@ +# Real Base Sepolia (via Tenderly Node RPC - unlimited blocks) +# Source this file: source environments/base-sepolia.example +# +# Get your access key from: https://dashboard.tenderly.co -> Node RPCs + +# Tenderly Node RPC URL (real Base Sepolia, no block limits) +export TENDERLY_RPC_URL="https://base-sepolia.gateway.tenderly.co/YOUR_ACCESS_KEY" + +# Deployer private key (must have Base Sepolia ETH from faucet) +export TENDERLY_PRIVATE_KEY="0x..." + +# Bridge node Ethereum public keys +export BRIDGE_NODE_0="0x..." +export BRIDGE_NODE_1="0x..." +export BRIDGE_NODE_2="0x..." +export BRIDGE_NODE_3="0x..." +export BRIDGE_NODE_4="0x..." + +export NOCK_NAME="Nock" +export NOCK_SYMBOL="NOCK" + +export DEPLOY_TARGET_NETWORK="base-sepolia" +export DEPLOYER_ADDRESS="0x..." + +# Optional: for contract verification +export TENDERLY_ACCESS_KEY="YOUR_ACCESS_KEY" +export TENDERLY_ACCOUNT_ID="littel_wolfur" +export TENDERLY_PROJECT_SLUG="bridge-contracts" + diff --git a/crates/bridge/contracts/environments/devnet.example b/crates/bridge/contracts/environments/devnet.example new file mode 100644 index 000000000..2385740bd --- /dev/null +++ b/crates/bridge/contracts/environments/devnet.example @@ -0,0 +1,29 @@ +# Tenderly Virtual Testnet (50 block limit, state manipulation available) +# Source this file: source environments/devnet.example +# +# Create a Virtual Testnet at: https://dashboard.tenderly.co -> Virtual Testnets + +# Virtual Testnet RPC URL (HTTP version for deployment) +# Format: https://virtual.base-sepolia..rpc.tenderly.co/ +export TENDERLY_RPC_URL="https://virtual.base-sepolia.us-east.rpc.tenderly.co/YOUR_VIRTUAL_TESTNET_ID" + +# Deployer private key (fund with tenderly_setBalance RPC call) +export TENDERLY_PRIVATE_KEY="0x..." + +# Bridge node Ethereum public keys +export BRIDGE_NODE_0="0x..." +export BRIDGE_NODE_1="0x..." +export BRIDGE_NODE_2="0x..." +export BRIDGE_NODE_3="0x..." +export BRIDGE_NODE_4="0x..." + +export NOCK_NAME="Nock" +export NOCK_SYMBOL="NOCK" + +export DEPLOY_TARGET_NETWORK="tenderly-devnet" +export DEPLOYER_ADDRESS="0x..." + +# Optional verification +export TENDERLY_ACCOUNT_ID="littel_wolfur" +export TENDERLY_PROJECT_SLUG="bridge-contracts" + diff --git a/crates/bridge/contracts/forge/Deploy.s.sol b/crates/bridge/contracts/forge/Deploy.s.sol new file mode 100644 index 000000000..7551fb354 --- /dev/null +++ b/crates/bridge/contracts/forge/Deploy.s.sol @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Script.sol"; +import "forge-std/StdJson.sol"; +import "forge-std/console2.sol"; +import {ERC1967Proxy} from "openzeppelin-contracts/proxy/ERC1967/ERC1967Proxy.sol"; + +import {MessageInbox} from "../MessageInbox.sol"; +import {Nock} from "../Nock.sol"; + +/// @notice Deploys MessageInbox behind an ERC1967 proxy and a paired Nock token. +/// Expects the following environment variables to be set before execution: +/// - BRIDGE_NODE_0 .. BRIDGE_NODE_4 +/// - NOCK_NAME +/// - NOCK_SYMBOL +/// Optional helpers: +/// - DEPLOY_TARGET_NETWORK (defaults to "tenderly-devnet") +/// - DEPLOYMENTS_PATH (defaults to "deployments.json") +/// - DEPLOYER_ADDRESS (used only for metadata; defaults to zero address) +contract Deploy is Script { + using stdJson for string; + + function run() public { + address[5] memory bridgeNodes = _loadBridgeNodes(); + string memory nockName = vm.envString("NOCK_NAME"); + string memory nockSymbol = vm.envString("NOCK_SYMBOL"); + + vm.startBroadcast(); + + Nock nock = new Nock(nockName, nockSymbol, address(0)); + MessageInbox inboxImplementation = new MessageInbox(); + + bytes memory initData = abi.encodeCall( + MessageInbox.initialize, + (bridgeNodes, address(nock)) + ); + + ERC1967Proxy proxy = new ERC1967Proxy(address(inboxImplementation), initData); + MessageInbox inbox = MessageInbox(address(proxy)); + + nock.updateInbox(address(inbox)); + + vm.stopBroadcast(); + + _persistDeployment( + address(inboxImplementation), + address(inbox), + address(nock) + ); + } + + function _loadBridgeNodes() internal view returns (address[5] memory nodes) { + nodes[0] = vm.envAddress("BRIDGE_NODE_0"); + nodes[1] = vm.envAddress("BRIDGE_NODE_1"); + nodes[2] = vm.envAddress("BRIDGE_NODE_2"); + nodes[3] = vm.envAddress("BRIDGE_NODE_3"); + nodes[4] = vm.envAddress("BRIDGE_NODE_4"); + } + + function _persistDeployment( + address implementation, + address proxy, + address nock + ) internal { + string memory root = "deployment"; + string memory network = vm.envOr("DEPLOY_TARGET_NETWORK", string("tenderly-devnet")); + address deployer = vm.envOr("DEPLOYER_ADDRESS", address(0)); + string memory path = vm.envOr("DEPLOYMENTS_PATH", string("deployments.json")); + + vm.serializeAddress(root, "messageInboxImplementation", implementation); + vm.serializeAddress(root, "messageInboxProxy", proxy); + vm.serializeAddress(root, "nock", nock); + vm.serializeAddress(root, "deployer", deployer); + vm.writeJson(vm.serializeString(root, "network", network), path); + + console2.log("MessageInbox implementation:", implementation); + console2.log("MessageInbox proxy:", proxy); + console2.log("Nock token:", nock); + console2.log("Deployment metadata written to:", path); + } +} + diff --git a/crates/bridge/contracts/forge/IntegrationTest.s.sol b/crates/bridge/contracts/forge/IntegrationTest.s.sol new file mode 100644 index 000000000..4644b7a75 --- /dev/null +++ b/crates/bridge/contracts/forge/IntegrationTest.s.sol @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Script.sol"; +import "forge-std/StdJson.sol"; +import "forge-std/console2.sol"; + +import {MessageInbox, Tip5Hash} from "../MessageInbox.sol"; +import {Nock} from "../Nock.sol"; + +/// @notice Integration test against deployed contracts +/// Tests full deposit + burn cycle +/// Validates gas usage stays under 200k for deposits +contract IntegrationTest is Script { + function _b32ToTip5(bytes32 value) internal pure returns (Tip5Hash memory h) { + h.limbs[0] = uint64(uint256(value) >> 192); + h.limbs[1] = uint64(uint256(value) >> 128); + h.limbs[2] = uint64(uint256(value) >> 64); + h.limbs[3] = uint64(uint256(value)); + h.limbs[4] = 0; + } + using stdJson for string; + + uint256 private constant TEST_AMOUNT = 1_000 * 1e16; + bytes32 private constant LOCK_ROOT = + keccak256("integration-test-lock-root"); + uint256 private constant THRESHOLD = 3; + uint256 private constant MAX_DEPOSIT_GAS = 200_000; + + function run() external { + string memory deploymentsPath = vm.envOr( + "DEPLOYMENTS_PATH", + string("deployments.json") + ); + + string memory fullPath = bytes(deploymentsPath).length > 0 + ? deploymentsPath + : "deployments.json"; + + if (bytes(fullPath)[0] != "/") { + fullPath = string.concat(vm.projectRoot(), "/", fullPath); + } + + string memory deploymentsFile = vm.readFile(fullPath); + address proxy = deploymentsFile.readAddress("$.messageInboxProxy"); + address nock = deploymentsFile.readAddress("$.nock"); + + MessageInbox inbox = MessageInbox(proxy); + Nock nockToken = Nock(nock); + + uint256 testKey = vm.envUint("TEST_ACCOUNT_PRIVATE_KEY"); + address testAccount = vm.addr(testKey); + + uint256[THRESHOLD] memory bridgeKeys; + for (uint256 i = 0; i < THRESHOLD; i++) { + string memory keyName = string.concat( + "BRIDGE_NODE_KEY_", + vm.toString(i) + ); + bridgeKeys[i] = vm.envUint(keyName); + require( + inbox.bridgeNodes(i) == vm.addr(bridgeKeys[i]), + "Bridge node key mismatch" + ); + } + + bytes32 txIdSeed = keccak256(abi.encodePacked(block.timestamp, testAccount, TEST_AMOUNT)); + Tip5Hash memory txId = _b32ToTip5(txIdSeed); + uint256 blockHeight = block.number; + Tip5Hash memory noteName = _b32ToTip5(keccak256(abi.encodePacked("integration-test", txIdSeed))); + Tip5Hash memory asOf = _b32ToTip5(keccak256(abi.encodePacked("integration-as-of", blockHeight))); + + uint256 depositNonce = inbox.lastDepositNonce() + 1; + + bytes memory part1 = abi.encodePacked(txId.limbs[0], txId.limbs[1], txId.limbs[2], txId.limbs[3], txId.limbs[4]); + bytes memory part2 = abi.encodePacked(noteName.limbs[0], noteName.limbs[1], noteName.limbs[2], noteName.limbs[3], noteName.limbs[4]); + bytes memory part3 = abi.encodePacked(testAccount, TEST_AMOUNT, blockHeight); + bytes memory part4 = abi.encodePacked(asOf.limbs[0], asOf.limbs[1], asOf.limbs[2], asOf.limbs[3], asOf.limbs[4]); + bytes32 messageHash = keccak256(abi.encodePacked(part1, part2, part3, part4, depositNonce)); + bytes32 ethSignedMessageHash = keccak256( + abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash) + ); + + bytes[] memory ethSigs = new bytes[](THRESHOLD); + for (uint256 i = 0; i < THRESHOLD; i++) { + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + bridgeKeys[i], + ethSignedMessageHash + ); + ethSigs[i] = abi.encodePacked(r, s, v); + } + + uint256 gasBefore = gasleft(); + vm.startBroadcast(bridgeKeys[0]); + inbox.submitDeposit( + txId, + noteName, + testAccount, + TEST_AMOUNT, + blockHeight, + asOf, + depositNonce, + ethSigs + ); + vm.stopBroadcast(); + uint256 gasUsed = gasBefore - gasleft(); + + require( + gasUsed <= MAX_DEPOSIT_GAS, + "Deposit gas exceeds 200k limit" + ); + + require(nockToken.balanceOf(testAccount) == TEST_AMOUNT, "Balance mismatch"); + + vm.startBroadcast(testKey); + nockToken.burn(TEST_AMOUNT, LOCK_ROOT); + vm.stopBroadcast(); + + require(nockToken.balanceOf(testAccount) == 0, "Balance not zero after burn"); + } +} + diff --git a/crates/bridge/contracts/forge/MintAndBurn.s.sol b/crates/bridge/contracts/forge/MintAndBurn.s.sol new file mode 100644 index 000000000..54b947e0b --- /dev/null +++ b/crates/bridge/contracts/forge/MintAndBurn.s.sol @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Script.sol"; +import "forge-std/StdJson.sol"; +import "forge-std/console2.sol"; + +import {Nock} from "../Nock.sol"; +import {MessageInbox, Tip5Hash} from "../MessageInbox.sol"; + +contract MintAndBurn is Script { + using stdJson for string; + + bytes32 private constant LOCK_ROOT = + keccak256("wrapped-nock-test-lock-root"); + uint256 private constant AMOUNT = 1_000 * 1e16; // 1000 NOCK with 16 decimals + uint256 private constant THRESHOLD = 3; + + function run() external { + string memory deploymentsPath = vm.envOr( + "DEPLOYMENTS_PATH", + string("deployments.json") + ); + + string memory fullPath = bytes(deploymentsPath).length > 0 + ? deploymentsPath + : "deployments.json"; + + if (bytes(fullPath)[0] != "/") { + fullPath = string.concat(vm.projectRoot(), "/", fullPath); + } + + string memory deploymentsFile = vm.readFile(fullPath); + + address nockAddress = deploymentsFile.readAddress("$.nock"); + address inboxAddress = deploymentsFile.readAddress( + "$.messageInboxProxy" + ); + + Nock nock = Nock(nockAddress); + MessageInbox inbox = MessageInbox(inboxAddress); + + require(nock.inbox() == inboxAddress, "MintAndBurn: inbox mismatch"); + + // Load bridge node private keys for signature generation + uint256[THRESHOLD] memory bridgeKeys; + for (uint256 i = 0; i < THRESHOLD; i++) { + string memory keyName = string.concat( + "BRIDGE_NODE_KEY_", + vm.toString(i) + ); + uint256 pk = vm.envOr(keyName, uint256(0)); + require(pk != 0, string.concat("MintAndBurn: set ", keyName)); + bridgeKeys[i] = pk; + require( + inbox.bridgeNodes(i) == vm.addr(pk), + string.concat( + "MintAndBurn: bridge node mismatch at index ", + vm.toString(i) + ) + ); + } + + uint256 holderPrivateKey = vm.envUint("NOCK_HOLDER_PRIVATE_KEY"); + address holder = vm.addr(holderPrivateKey); + + bytes32 txIdSeed = keccak256(abi.encodePacked(block.timestamp, holder, AMOUNT)); + Tip5Hash memory txId; + txId.limbs[0] = uint64(uint256(txIdSeed) >> 192); + txId.limbs[1] = uint64(uint256(txIdSeed) >> 128); + txId.limbs[2] = uint64(uint256(txIdSeed) >> 64); + txId.limbs[3] = uint64(uint256(txIdSeed)); + txId.limbs[4] = 0; + + uint256 blockHeight = block.number; + + bytes32 noteNameSeed = keccak256(abi.encodePacked("test-bundle", txIdSeed)); + Tip5Hash memory noteName; + noteName.limbs[0] = uint64(uint256(noteNameSeed) >> 192); + noteName.limbs[1] = uint64(uint256(noteNameSeed) >> 128); + noteName.limbs[2] = uint64(uint256(noteNameSeed) >> 64); + noteName.limbs[3] = uint64(uint256(noteNameSeed)); + noteName.limbs[4] = 0; + + bytes32 asOfSeed = keccak256(abi.encodePacked("test-as-of", blockHeight)); + Tip5Hash memory asOf; + asOf.limbs[0] = uint64(uint256(asOfSeed) >> 192); + asOf.limbs[1] = uint64(uint256(asOfSeed) >> 128); + asOf.limbs[2] = uint64(uint256(asOfSeed) >> 64); + asOf.limbs[3] = uint64(uint256(asOfSeed)); + asOf.limbs[4] = 0; + + bytes memory part1 = abi.encodePacked(txId.limbs[0], txId.limbs[1], txId.limbs[2], txId.limbs[3], txId.limbs[4]); + bytes memory part2 = abi.encodePacked(noteName.limbs[0], noteName.limbs[1], noteName.limbs[2], noteName.limbs[3], noteName.limbs[4]); + bytes memory part3 = abi.encodePacked(holder, AMOUNT, blockHeight); + bytes memory part4 = abi.encodePacked(asOf.limbs[0], asOf.limbs[1], asOf.limbs[2], asOf.limbs[3], asOf.limbs[4]); + bytes32 messageHash = keccak256(abi.encodePacked(part1, part2, part3, part4)); + bytes32 ethSignedMessageHash = keccak256( + abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash) + ); + + bytes[] memory ethSigs = new bytes[](THRESHOLD); + for (uint256 i = 0; i < THRESHOLD; i++) { + (uint8 v, bytes32 r, bytes32 s) = vm.sign( + bridgeKeys[i], + ethSignedMessageHash + ); + ethSigs[i] = _encodeSignature(v, r, s); + } + + console2.log( + "Submitting deposit for", + AMOUNT / 1e16, + "NOCK to", + holder + ); + vm.startBroadcast(bridgeKeys[0]); + inbox.submitDeposit( + txId, + noteName, + holder, + AMOUNT, + blockHeight, + asOf, + ethSigs + ); + vm.stopBroadcast(); + + console2.log("Holder balance after deposit", nock.balanceOf(holder)); + + vm.startBroadcast(holderPrivateKey); + nock.burn(AMOUNT, LOCK_ROOT); + vm.stopBroadcast(); + + console2.log("Holder balance after burn", nock.balanceOf(holder)); + } + + function _encodeSignature( + uint8 v, + bytes32 r, + bytes32 s + ) internal pure returns (bytes memory) { + return abi.encodePacked(r, s, v); + } +} diff --git a/crates/bridge/contracts/forge/SetBridgeNodes.s.sol b/crates/bridge/contracts/forge/SetBridgeNodes.s.sol new file mode 100644 index 000000000..01b8cb405 --- /dev/null +++ b/crates/bridge/contracts/forge/SetBridgeNodes.s.sol @@ -0,0 +1,80 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Script.sol"; +import "forge-std/StdJson.sol"; +import "forge-std/console2.sol"; + +import {MessageInbox} from "../MessageInbox.sol"; + +contract SetBridgeNodes is Script { + using stdJson for string; + + uint256 private constant NODE_COUNT = 5; + + function run() external { + string memory deploymentsPath = vm.envOr( + "DEPLOYMENTS_PATH", + string("deployments.json") + ); + + string memory fullPath = bytes(deploymentsPath).length > 0 + ? deploymentsPath + : "deployments.json"; + + if (bytes(fullPath)[0] != "/") { + fullPath = string.concat(vm.projectRoot(), "/", fullPath); + } + + string memory deploymentsFile = vm.readFile(fullPath); + address inboxAddress = deploymentsFile.readAddress( + "$.messageInboxProxy" + ); + MessageInbox inbox = MessageInbox(inboxAddress); + + address[] memory desiredNodes = new address[](NODE_COUNT); + for (uint256 i = 0; i < NODE_COUNT; i++) { + string memory envName = string.concat( + "BRIDGE_NODE_ADDR_", + vm.toString(i) + ); + desiredNodes[i] = vm.envAddress(envName); + } + + uint256 ownerKey = vm.envUint("INBOX_PRIVATE_KEY"); + address owner = vm.addr(ownerKey); + console2.log( + string.concat("Updating bridge nodes as ", vm.toString(owner)) + ); + + vm.startBroadcast(ownerKey); + for (uint256 i = 0; i < NODE_COUNT; i++) { + address current = inbox.bridgeNodes(i); + if (current != desiredNodes[i]) { + console2.log( + string.concat( + "Updating node ", + vm.toString(i), + " from ", + vm.toString(current), + " to ", + vm.toString(desiredNodes[i]) + ) + ); + inbox.updateBridgeNode(i, desiredNodes[i]); + } else { + console2.log( + string.concat( + "Node ", + vm.toString(i), + " already set to ", + vm.toString(current) + ) + ); + } + } + vm.stopBroadcast(); + + console2.log("Bridge node update complete"); + } +} diff --git a/crates/bridge/contracts/forge/Upgrade.s.sol b/crates/bridge/contracts/forge/Upgrade.s.sol new file mode 100644 index 000000000..e33c9fcca --- /dev/null +++ b/crates/bridge/contracts/forge/Upgrade.s.sol @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Script.sol"; +import "forge-std/StdJson.sol"; +import "forge-std/console2.sol"; + +import {MessageInbox} from "../MessageInbox.sol"; + +/// @notice Upgrades the MessageInbox implementation via UUPS proxy +/// Reads deployment addresses from DEPLOYMENTS_PATH +/// Requires INBOX_PRIVATE_KEY to be set (must be owner) +contract Upgrade is Script { + using stdJson for string; + + function run() external { + string memory deploymentsPath = vm.envOr( + "DEPLOYMENTS_PATH", + string("deployments.json") + ); + + string memory fullPath = bytes(deploymentsPath).length > 0 + ? deploymentsPath + : "deployments.json"; + + if (bytes(fullPath)[0] != "/") { + fullPath = string.concat(vm.projectRoot(), "/", fullPath); + } + + string memory deploymentsFile = vm.readFile(fullPath); + address proxy = deploymentsFile.readAddress("$.messageInboxProxy"); + + uint256 ownerKey = vm.envUint("INBOX_PRIVATE_KEY"); + + vm.startBroadcast(ownerKey); + MessageInbox newImplementation = new MessageInbox(); + MessageInbox inbox = MessageInbox(proxy); + inbox.upgradeTo(address(newImplementation)); + vm.stopBroadcast(); + + console2.log("Upgraded to:", address(newImplementation)); + } +} + diff --git a/crates/bridge/contracts/forge/Validate.s.sol b/crates/bridge/contracts/forge/Validate.s.sol new file mode 100644 index 000000000..48d1ca175 --- /dev/null +++ b/crates/bridge/contracts/forge/Validate.s.sol @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import "forge-std/Script.sol"; +import "forge-std/StdJson.sol"; +import "forge-std/console2.sol"; + +import {MessageInbox} from "../MessageInbox.sol"; +import {Nock} from "../Nock.sol"; + +/// @notice Validates a deployed bridge contract configuration +/// Reads deployment addresses from DEPLOYMENTS_PATH (defaults to deployments.json) +contract Validate is Script { + using stdJson for string; + + function run() external view { + string memory deploymentsPath = vm.envOr( + "DEPLOYMENTS_PATH", + string("deployments.json") + ); + + string memory fullPath = bytes(deploymentsPath).length > 0 + ? deploymentsPath + : "deployments.json"; + + if (bytes(fullPath)[0] != "/") { + fullPath = string.concat(vm.projectRoot(), "/", fullPath); + } + + string memory deploymentsFile = vm.readFile(fullPath); + address implementation = deploymentsFile.readAddress( + "$.messageInboxImplementation" + ); + address proxy = deploymentsFile.readAddress("$.messageInboxProxy"); + address nock = deploymentsFile.readAddress("$.nock"); + + MessageInbox inbox = MessageInbox(proxy); + Nock nockToken = Nock(nock); + + bytes32 implSlot = 0x360894a13ba1a3210667c828492db98dca3e2076cc3735a920a3ca505d382bbc; + bytes32 slotValue = vm.load(proxy, implSlot); + address currentImpl = address(uint160(uint256(slotValue))); + require( + currentImpl == implementation, + "Proxy implementation mismatch" + ); + + require( + nockToken.inbox() == proxy, + "Nock token not connected to inbox proxy" + ); + + // Verify all bridge nodes are set and unique + address[5] memory nodes; + for (uint256 i = 0; i < 5; i++) { + nodes[i] = inbox.bridgeNodes(i); + require(nodes[i] != address(0), "Bridge node not set"); + } + + // Check for duplicates + for (uint256 i = 0; i < 5; i++) { + for (uint256 j = i + 1; j < 5; j++) { + require( + nodes[i] != nodes[j], + "Duplicate bridge node addresses detected" + ); + } + } + + require(inbox.THRESHOLD() == 3, "Invalid threshold"); + require(inbox.withdrawalsEnabled(), "Withdrawals not enabled"); + require(inbox.owner() != address(0), "Owner not set"); + } +} + diff --git a/crates/bridge/contracts/foundry.lock b/crates/bridge/contracts/foundry.lock new file mode 100644 index 000000000..075c40656 --- /dev/null +++ b/crates/bridge/contracts/foundry.lock @@ -0,0 +1,26 @@ +{ + "../../znock/contracts/lib/forge-std": { + "rev": "77041d2ce690e692d6e03cc812b57d1ddaa4d505" + }, + "../../znock/contracts/lib/openzeppelin-contracts": { + "rev": "e4f70216d759d8e6a64144a9e1f7bbeed78e7079" + }, + "../../znock/contracts/lib/openzeppelin-contracts-upgradeable": { + "rev": "e725abddf1e01cf05ace496e950fc8e243cc7cab" + }, + "../../znock/contracts/lib/openzeppelin-foundry-upgrades": { + "rev": "cbce1e00305e943aa1661d43f41e5ac72c662b07" + }, + "../../znock/contracts/lib/prb-math": { + "rev": "aad73cfc6cdc2c9b660199b5b1e9db391ea48640" + }, + "lib/forge-std": { + "rev": "7117c90c8cf6c68e5acce4f09a6b24715cea4de6" + }, + "lib/openzeppelin-contracts": { + "rev": "fcbae5394ae8ad52d8e580a3477db99814b9d565" + }, + "lib/openzeppelin-contracts-upgradeable": { + "rev": "aa677e9d28ed78fc427ec47ba2baef2030c58e7c" + } +} \ No newline at end of file diff --git a/crates/bridge/contracts/foundry.toml b/crates/bridge/contracts/foundry.toml new file mode 100644 index 000000000..2f3653715 --- /dev/null +++ b/crates/bridge/contracts/foundry.toml @@ -0,0 +1,30 @@ +[profile.default] +src = "src" +out = "out" +libs = ["lib"] +solc = "0.8.30" + +# Enable build info for Tenderly integration +build_info = true +build_info_path = "out/build-info" + +# Enhanced settings for Tenderly +extra_output = ["storageLayout", "metadata"] +extra_output_files = ["metadata"] + +# Tenderly simulation settings +gas_reports = ["*"] + +# Optimizer settings for better analysis +optimizer = true +optimizer_runs = 200 +via-ir = true + +# Required for Tenderly contract verification +cbor_metadata = true + +# Filesystem access for deployment scripts +fs_permissions = [ + { access = "read-write", path = "./" }, + { access = "read-write", path = "./deployments.json" }, +] diff --git a/crates/bridge/contracts/remappings.txt b/crates/bridge/contracts/remappings.txt new file mode 100644 index 000000000..587afd3ba --- /dev/null +++ b/crates/bridge/contracts/remappings.txt @@ -0,0 +1,5 @@ +forge-std/=lib/forge-std/src/ +openzeppelin-contracts/=lib/openzeppelin-contracts/contracts/ +@openzeppelin/contracts/=lib/openzeppelin-contracts/contracts/ +openzeppelin-contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/ +@openzeppelin/contracts-upgradeable/=lib/openzeppelin-contracts-upgradeable/contracts/ diff --git a/crates/bridge/contracts/tenderly.env.example b/crates/bridge/contracts/tenderly.env.example new file mode 100644 index 000000000..bc583702f --- /dev/null +++ b/crates/bridge/contracts/tenderly.env.example @@ -0,0 +1,25 @@ +# Required Tenderly deployment configuration +TENDERLY_RPC_URL="https://your-tenderly-rpc" +TENDERLY_PRIVATE_KEY="0xabc..." + +# Bridge node addresses (5 entries) +BRIDGE_NODE_0="0x0000000000000000000000000000000000000001" +BRIDGE_NODE_1="0x0000000000000000000000000000000000000002" +BRIDGE_NODE_2="0x0000000000000000000000000000000000000003" +BRIDGE_NODE_3="0x0000000000000000000000000000000000000004" +BRIDGE_NODE_4="0x0000000000000000000000000000000000000005" + +# Nock token metadata +NOCK_NAME="Nock" +NOCK_SYMBOL="NOCK" + +# Optional deployment metadata +# DEPLOY_TARGET_NETWORK="base-sepolia-devnet" +# DEPLOYER_ADDRESS="0x0000000000000000000000000000000000000000" +# DEPLOYMENTS_PATH="$(pwd)/deployments.json" + +# Optional Tenderly automatic verification parameters +# TENDERLY_ACCOUNT_ID="littel_wolfur" +# TENDERLY_PROJECT_SLUG="bridge-contracts" + + diff --git a/crates/bridge/contracts/tenderly.yaml b/crates/bridge/contracts/tenderly.yaml new file mode 100644 index 000000000..97ec38e2c --- /dev/null +++ b/crates/bridge/contracts/tenderly.yaml @@ -0,0 +1,49 @@ +# Tenderly configuration for Foundry project +compiler: + name: "foundry" + version: "auto" + out_dir: "out" + build_dir_path: "out/build-info" + sources_dir: "src" + +# Project settings +project_slug: "bridge" +account_id: "zorp" + +# Networks configuration +networks: + mainnet: + network_id: "1" + sepolia: + network_id: "11155111" + base: + network_id: "8453" + base-sepolia: + network_id: "84532" + local: + network_id: "31337" + +# Optional: Enable automatic verification +auto_verify: true + +# Deployment settings +deployments: + auto_export: true + include_traces: true + +# Simulation settings +simulations: + enable_forks: true + gas_limit: 30000000 + +# Contracts to verify (Base Sepolia deployment - 2025-12-17) +contracts: + - contract_name: "Nock" + address: "0xA9cd4087D9B050D8B35727AAf810296CA957c7B3" + network_id: "84532" + - contract_name: "MessageInbox" + address: "0x7627Db3A99596668c9d42693efa352Ca69F089e3" + network_id: "84532" + - contract_name: "ERC1967Proxy" + address: "0x9b1becA13c39b9Be10dB616F1bE10C3CeF9Dfb36" + network_id: "84532" diff --git a/crates/bridge/contracts/test/BridgeTestBase.t.sol b/crates/bridge/contracts/test/BridgeTestBase.t.sol new file mode 100644 index 000000000..b80c6c087 --- /dev/null +++ b/crates/bridge/contracts/test/BridgeTestBase.t.sol @@ -0,0 +1,154 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Test} from "forge-std/Test.sol"; + +import {MessageInbox, Tip5Hash} from "../MessageInbox.sol"; +import {Nock} from "../Nock.sol"; + +abstract contract BridgeTestBase is Test { + MessageInbox internal inbox; + Nock internal nock; + + uint256[5] internal bridgeNodePrivateKeys; + address[5] internal bridgeNodeAddresses; + + uint256 internal constant ONE_NOCK = 1e16; + uint64 internal constant PRIME = 0xffffffff00000001; + + uint256 internal nextDepositNonce = 1; + + function setUp() public virtual { + _initBridgeNodes(); + _deployContracts(); + } + + function _initBridgeNodes() internal { + for (uint256 i = 0; i < 5; i++) { + uint256 pk = uint256(keccak256(abi.encodePacked("bridge-node", i))); + bridgeNodePrivateKeys[i] = pk; + bridgeNodeAddresses[i] = vm.addr(pk); + } + } + + function _deployContracts() internal { + inbox = new MessageInbox(); + nock = new Nock("Nock", "NOCK", address(inbox)); + + address[5] memory nodes; + for (uint256 i = 0; i < 5; i++) { + nodes[i] = bridgeNodeAddresses[i]; + } + inbox.initialize(nodes, address(nock)); + } + + function bridgeNode(uint256 index) internal view returns (address) { + require(index < 5, "bridge node index out of range"); + return bridgeNodeAddresses[index]; + } + + function _b32ToTip5(bytes32 value) internal pure returns (Tip5Hash memory h) { + h.limbs[0] = uint64(uint256(value) >> 192) % PRIME; + h.limbs[1] = uint64(uint256(value) >> 128) % PRIME; + h.limbs[2] = uint64(uint256(value) >> 64) % PRIME; + h.limbs[3] = uint64(uint256(value)) % PRIME; + h.limbs[4] = 1; // Non-zero to avoid _isZeroTip5 rejection for asOf + } + + function _depositMessageHash( + Tip5Hash memory txId, + Tip5Hash memory nameFirst, + Tip5Hash memory nameLast, + address recipient, + uint256 amount, + uint256 blockHeight, + Tip5Hash memory asOf, + uint256 depositNonce + ) internal pure returns (bytes32) { + bytes memory part1 = abi.encodePacked(txId.limbs[0], txId.limbs[1], txId.limbs[2], txId.limbs[3], txId.limbs[4]); + bytes memory part2 = abi.encodePacked(nameFirst.limbs[0], nameFirst.limbs[1], nameFirst.limbs[2], nameFirst.limbs[3], nameFirst.limbs[4]); + bytes memory part3 = abi.encodePacked(nameLast.limbs[0], nameLast.limbs[1], nameLast.limbs[2], nameLast.limbs[3], nameLast.limbs[4]); + bytes memory part4 = abi.encodePacked(recipient, amount, blockHeight); + bytes memory part5 = abi.encodePacked(asOf.limbs[0], asOf.limbs[1], asOf.limbs[2], asOf.limbs[3], asOf.limbs[4]); + return keccak256(abi.encodePacked(part1, part2, part3, part4, part5, depositNonce)); + } + + function _ethSignedDepositHash( + Tip5Hash memory txId, + Tip5Hash memory nameFirst, + Tip5Hash memory nameLast, + address recipient, + uint256 amount, + uint256 blockHeight, + Tip5Hash memory asOf, + uint256 depositNonce + ) internal pure returns (bytes32) { + bytes32 messageHash = _depositMessageHash(txId, nameFirst, nameLast, recipient, amount, blockHeight, asOf, depositNonce); + return keccak256(abi.encodePacked("\x19Ethereum Signed Message:\n32", messageHash)); + } + + function buildDepositSignature( + uint256 nodeIndex, + Tip5Hash memory txId, + Tip5Hash memory nameFirst, + Tip5Hash memory nameLast, + address recipient, + uint256 amount, + uint256 blockHeight, + Tip5Hash memory asOf, + uint256 depositNonce + ) internal view returns (bytes memory) { + require(nodeIndex < 5, "bridge node index out of range"); + bytes32 digest = _ethSignedDepositHash(txId, nameFirst, nameLast, recipient, amount, blockHeight, asOf, depositNonce); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(bridgeNodePrivateKeys[nodeIndex], digest); + return abi.encodePacked(r, s, v); + } + + function buildDepositSignatureSet( + uint256[] memory nodeIndexes, + Tip5Hash memory txId, + Tip5Hash memory nameFirst, + Tip5Hash memory nameLast, + address recipient, + uint256 amount, + uint256 blockHeight, + Tip5Hash memory asOf, + uint256 depositNonce + ) internal view returns (bytes[] memory sigs) { + sigs = new bytes[](nodeIndexes.length); + for (uint256 i = 0; i < nodeIndexes.length; i++) { + sigs[i] = buildDepositSignature(nodeIndexes[i], txId, nameFirst, nameLast, recipient, amount, blockHeight, asOf, depositNonce); + } + } + + function _nextDepositNonce() internal returns (uint256) { + return nextDepositNonce++; + } + + function _defaultSignerIndexes() internal pure returns (uint256[] memory) { + uint256[] memory signerIndexes = new uint256[](3); + signerIndexes[0] = 0; + signerIndexes[1] = 1; + signerIndexes[2] = 2; + return signerIndexes; + } + + function mintFromInbox(address to, uint256 amount) internal { + vm.prank(address(inbox)); + nock.mint(to, amount); + } + + function bridgeNodesArray() + internal + view + returns (address[5] memory nodes) + { + for (uint256 i = 0; i < 5; i++) { + nodes[i] = bridgeNodeAddresses[i]; + } + } + + function nockAmount(uint256 amount) internal pure returns (uint256) { + return amount * ONE_NOCK; + } +} diff --git a/crates/bridge/contracts/test/MessageInboxBridgeNodeTest.t.sol b/crates/bridge/contracts/test/MessageInboxBridgeNodeTest.t.sol new file mode 100644 index 000000000..6964e9c66 --- /dev/null +++ b/crates/bridge/contracts/test/MessageInboxBridgeNodeTest.t.sol @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Test} from "forge-std/Test.sol"; +import {MessageInbox} from "../MessageInbox.sol"; +import {Nock} from "../Nock.sol"; + +/// @notice Tests for bridge node validation and deduplication +contract MessageInboxBridgeNodeTest is Test { + MessageInbox internal inbox; + Nock internal nock; + + uint256[5] internal bridgeNodePrivateKeys; + address[5] internal bridgeNodeAddresses; + + function setUp() public { + for (uint256 i = 0; i < 5; i++) { + uint256 pk = uint256(keccak256(abi.encodePacked("bridge-node", i))); + bridgeNodePrivateKeys[i] = pk; + bridgeNodeAddresses[i] = vm.addr(pk); + } + } + + function test_initialize_rejects_duplicate_bridge_nodes() public { + inbox = new MessageInbox(); + nock = new Nock("Nock", "NOCK", address(inbox)); + + // Create nodes array with duplicate at index 2 and 4 + address[5] memory nodes; + nodes[0] = bridgeNodeAddresses[0]; + nodes[1] = bridgeNodeAddresses[1]; + nodes[2] = bridgeNodeAddresses[2]; + nodes[3] = bridgeNodeAddresses[3]; + nodes[4] = bridgeNodeAddresses[2]; // Duplicate! + + vm.expectRevert("Duplicate bridge node address"); + inbox.initialize(nodes, address(nock)); + } + + function test_initialize_rejects_zero_address_bridge_node() public { + inbox = new MessageInbox(); + nock = new Nock("Nock", "NOCK", address(inbox)); + + address[5] memory nodes; + nodes[0] = bridgeNodeAddresses[0]; + nodes[1] = bridgeNodeAddresses[1]; + nodes[2] = address(0); // Zero address! + nodes[3] = bridgeNodeAddresses[3]; + nodes[4] = bridgeNodeAddresses[4]; + + vm.expectRevert("Bridge node cannot be zero address"); + inbox.initialize(nodes, address(nock)); + } + + function test_initialize_accepts_unique_bridge_nodes() public { + inbox = new MessageInbox(); + nock = new Nock("Nock", "NOCK", address(inbox)); + + address[5] memory nodes; + for (uint256 i = 0; i < 5; i++) { + nodes[i] = bridgeNodeAddresses[i]; + } + + inbox.initialize(nodes, address(nock)); + + // Verify all nodes were set correctly + for (uint256 i = 0; i < 5; i++) { + assertEq(inbox.bridgeNodes(i), bridgeNodeAddresses[i]); + } + } + + function test_updateBridgeNode_rejects_duplicate() public { + inbox = new MessageInbox(); + nock = new Nock("Nock", "NOCK", address(inbox)); + + address[5] memory nodes; + for (uint256 i = 0; i < 5; i++) { + nodes[i] = bridgeNodeAddresses[i]; + } + inbox.initialize(nodes, address(nock)); + + // Try to update node 0 to be the same as node 1 + vm.expectRevert("Duplicate bridge node address"); + inbox.updateBridgeNode(0, bridgeNodeAddresses[1]); + } + + function test_updateBridgeNode_allows_same_address_in_same_slot() public { + inbox = new MessageInbox(); + nock = new Nock("Nock", "NOCK", address(inbox)); + + address[5] memory nodes; + for (uint256 i = 0; i < 5; i++) { + nodes[i] = bridgeNodeAddresses[i]; + } + inbox.initialize(nodes, address(nock)); + + // Updating a slot to its current value should work (no-op) + inbox.updateBridgeNode(0, bridgeNodeAddresses[0]); + assertEq(inbox.bridgeNodes(0), bridgeNodeAddresses[0]); + } + + function test_updateBridgeNode_allows_new_unique_address() public { + inbox = new MessageInbox(); + nock = new Nock("Nock", "NOCK", address(inbox)); + + address[5] memory nodes; + for (uint256 i = 0; i < 5; i++) { + nodes[i] = bridgeNodeAddresses[i]; + } + inbox.initialize(nodes, address(nock)); + + // Create a new unique address + address newNode = vm.addr(uint256(keccak256(abi.encodePacked("new-bridge-node")))); + + inbox.updateBridgeNode(0, newNode); + assertEq(inbox.bridgeNodes(0), newNode); + } + + function test_updateBridgeNode_rejects_zero_address() public { + inbox = new MessageInbox(); + nock = new Nock("Nock", "NOCK", address(inbox)); + + address[5] memory nodes; + for (uint256 i = 0; i < 5; i++) { + nodes[i] = bridgeNodeAddresses[i]; + } + inbox.initialize(nodes, address(nock)); + + vm.expectRevert("Invalid bridge node address"); + inbox.updateBridgeNode(0, address(0)); + } + + function test_updateBridgeNode_only_owner() public { + inbox = new MessageInbox(); + nock = new Nock("Nock", "NOCK", address(inbox)); + + address[5] memory nodes; + for (uint256 i = 0; i < 5; i++) { + nodes[i] = bridgeNodeAddresses[i]; + } + inbox.initialize(nodes, address(nock)); + + address newNode = vm.addr(uint256(keccak256(abi.encodePacked("new-bridge-node")))); + address notOwner = vm.addr(uint256(keccak256(abi.encodePacked("not-owner")))); + + vm.prank(notOwner); + vm.expectRevert(); + inbox.updateBridgeNode(0, newNode); + } +} diff --git a/crates/bridge/contracts/test/MessageInboxDepositTest.t.sol b/crates/bridge/contracts/test/MessageInboxDepositTest.t.sol new file mode 100644 index 000000000..b6726e7ce --- /dev/null +++ b/crates/bridge/contracts/test/MessageInboxDepositTest.t.sol @@ -0,0 +1,605 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {MessageInbox, Tip5Hash} from "../MessageInbox.sol"; +import {BridgeTestBase} from "./BridgeTestBase.t.sol"; + +contract MessageInboxDepositTest is BridgeTestBase { + uint256 private constant SECP256K1_HALF_N = + 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF5D576E7357A4501DDFE92F46681B20A0; + + function setUp() public override { + super.setUp(); + } + + function testSubmitDepositHappyPath() public { + Tip5Hash memory txId = _b32ToTip5(keccak256("tx-id")); + Tip5Hash memory nameFirst = _b32ToTip5(keccak256("name-first")); + Tip5Hash memory nameLast = _b32ToTip5(keccak256("name-last")); + address recipient = makeAddr("recipient"); + uint256 amount = nockAmount(1000); + uint256 blockHeight = 42; + Tip5Hash memory asOf = _b32ToTip5(keccak256("hashchain-tip")); + uint256 depositNonce = _nextDepositNonce(); + + uint256[] memory signerIndexes = new uint256[](3); + signerIndexes[0] = 0; + signerIndexes[1] = 1; + signerIndexes[2] = 2; + + vm.pauseGasMetering(); + bytes[] memory sigs = buildDepositSignatureSet( + signerIndexes, + txId, + nameFirst, + nameLast, + recipient, + amount, + blockHeight, + asOf, + depositNonce + ); + vm.resumeGasMetering(); + + bytes32 txIdHash = keccak256(abi.encodePacked(txId.limbs[0], txId.limbs[1], txId.limbs[2], txId.limbs[3], txId.limbs[4])); + bytes32 nameFirstHash = keccak256(abi.encodePacked(nameFirst.limbs[0], nameFirst.limbs[1], nameFirst.limbs[2], nameFirst.limbs[3], nameFirst.limbs[4])); + + vm.expectEmit(true, true, true, true, address(inbox)); + emit MessageInbox.DepositProcessed( + txIdHash, + nameFirstHash, + recipient, + txId, + nameFirst, + nameLast, + amount, + blockHeight, + asOf, + depositNonce + ); + + inbox.submitDeposit(txId, nameFirst, nameLast, recipient, amount, blockHeight, asOf, depositNonce, sigs); + + assertTrue(inbox.processedDeposits(txIdHash)); + assertEq(nock.balanceOf(recipient), amount); + assertEq(inbox.lastDepositNonce(), depositNonce); + } + + function testSubmitDepositGasBelowTarget() public { + Tip5Hash memory txId = _b32ToTip5(keccak256("gas-cap")); + Tip5Hash memory nameFirst = _b32ToTip5(keccak256("name-first")); + Tip5Hash memory nameLast = _b32ToTip5(keccak256("name-last")); + address recipient = makeAddr("recipient"); + uint256 amount = nockAmount(500); + uint256 blockHeight = 64; + Tip5Hash memory asOf = _b32ToTip5(keccak256("gas-tip")); + uint256 depositNonce = _nextDepositNonce(); + + bytes[] memory sigs = buildDepositSignatureSet( + _defaultSignerIndexes(), + txId, + nameFirst, + nameLast, + recipient, + amount, + blockHeight, + asOf, + depositNonce + ); + + uint256 gasBefore = gasleft(); + inbox.submitDeposit(txId, nameFirst, nameLast, recipient, amount, blockHeight, asOf, depositNonce, sigs); + uint256 gasAfter = gasleft(); + + uint256 gasUsed; + unchecked { + gasUsed = gasBefore - gasAfter; + } + + assertLt(gasUsed, 200_000, "submitDeposit gas regression"); + } + + function testSubmitDepositRequiresThresholdSignatures() public { + Tip5Hash memory txId = _b32ToTip5(keccak256("threshold")); + Tip5Hash memory nameFirst = _b32ToTip5(keccak256("name-first")); + Tip5Hash memory nameLast = _b32ToTip5(keccak256("name-last")); + address recipient = makeAddr("recipient"); + uint256 amount = nockAmount(1); + uint256 blockHeight = 1; + Tip5Hash memory asOf = _b32ToTip5(keccak256("as-of")); + uint256 depositNonce = _nextDepositNonce(); + + bytes[] memory sigs = new bytes[](2); + vm.expectRevert("Insufficient Ethereum signatures"); + inbox.submitDeposit(txId, nameFirst, nameLast, recipient, amount, blockHeight, asOf, depositNonce, sigs); + } + + function testSubmitDepositRejectsDuplicateSigners() public { + Tip5Hash memory txId = _b32ToTip5(keccak256("duplicate")); + Tip5Hash memory nameFirst = _b32ToTip5(keccak256("name-first")); + Tip5Hash memory nameLast = _b32ToTip5(keccak256("name-last")); + address recipient = makeAddr("recipient"); + uint256 amount = nockAmount(2); + uint256 blockHeight = 2; + Tip5Hash memory asOf = _b32ToTip5(keccak256("as-of")); + uint256 depositNonce = _nextDepositNonce(); + + uint256[] memory signerIndexes = new uint256[](3); + signerIndexes[0] = 0; + signerIndexes[1] = 1; + signerIndexes[2] = 1; // duplicate signer + + bytes[] memory sigs = buildDepositSignatureSet( + signerIndexes, + txId, + nameFirst, + nameLast, + recipient, + amount, + blockHeight, + asOf, + depositNonce + ); + + vm.expectRevert("Invalid Ethereum signatures"); + inbox.submitDeposit(txId, nameFirst, nameLast, recipient, amount, blockHeight, asOf, depositNonce, sigs); + } + + function testSubmitDepositRejectsMismatchedAsOf() public { + Tip5Hash memory signedAsOf = _b32ToTip5(keccak256("signed")); + Tip5Hash memory providedAsOf = _b32ToTip5(keccak256("provided")); + Tip5Hash memory txId = _b32ToTip5(keccak256("asof-mismatch")); + Tip5Hash memory nameFirst = _b32ToTip5(keccak256("name-first")); + Tip5Hash memory nameLast = _b32ToTip5(keccak256("name-last")); + address recipient = makeAddr("recipient"); + uint256 depositNonce = _nextDepositNonce(); + + bytes[] memory sigs = buildDepositSignatureSet( + _defaultSignerIndexes(), + txId, + nameFirst, + nameLast, + recipient, + nockAmount(4), + 4, + signedAsOf, + depositNonce + ); + + vm.expectRevert("Invalid Ethereum signatures"); + inbox.submitDeposit(txId, nameFirst, nameLast, recipient, nockAmount(4), 4, providedAsOf, depositNonce, sigs); + } + + function testSubmitDepositRejectsNonBridgeSigner() public { + Tip5Hash memory txId = _b32ToTip5(keccak256("outsider")); + Tip5Hash memory nameFirst = _b32ToTip5(keccak256("name-first")); + Tip5Hash memory nameLast = _b32ToTip5(keccak256("name-last")); + address recipient = makeAddr("recipient"); + uint256 amount = nockAmount(3); + uint256 blockHeight = 3; + Tip5Hash memory asOf = _b32ToTip5(keccak256("as-of")); + uint256 depositNonce = _nextDepositNonce(); + + bytes[] memory sigs = new bytes[](3); + uint256[] memory signerIndexes = new uint256[](2); + signerIndexes[0] = 0; + signerIndexes[1] = 1; + + bytes[] memory legit = buildDepositSignatureSet( + signerIndexes, + txId, + nameFirst, + nameLast, + recipient, + amount, + blockHeight, + asOf, + depositNonce + ); + sigs[0] = legit[0]; + sigs[1] = legit[1]; + + uint256 outsiderPk = uint256(keccak256("outsider-pk")); + bytes32 digest = _ethSignedDepositHash( + txId, + nameFirst, + nameLast, + recipient, + amount, + blockHeight, + asOf, + depositNonce + ); + (uint8 v, bytes32 r, bytes32 s) = vm.sign(outsiderPk, digest); + sigs[2] = abi.encodePacked(r, s, v); + + vm.expectRevert("Invalid Ethereum signatures"); + inbox.submitDeposit(txId, nameFirst, nameLast, recipient, amount, blockHeight, asOf, depositNonce, sigs); + } + + function testSubmitDepositRejectsZeroRecipient() public { + uint256 depositNonce = _nextDepositNonce(); + bytes[] memory sigs = new bytes[](3); + vm.expectRevert("Invalid recipient"); + inbox.submitDeposit( + _b32ToTip5(keccak256("tx")), + _b32ToTip5(keccak256("name-first")), + _b32ToTip5(keccak256("name-last")), + address(0), + nockAmount(1), + 1, + _b32ToTip5(keccak256("asof")), + depositNonce, + sigs + ); + } + + function testSubmitDepositRejectsZeroAmount() public { + uint256 depositNonce = _nextDepositNonce(); + bytes[] memory sigs = new bytes[](3); + vm.expectRevert("Amount must be positive"); + inbox.submitDeposit( + _b32ToTip5(keccak256("tx")), + _b32ToTip5(keccak256("name-first")), + _b32ToTip5(keccak256("name-last")), + makeAddr("recipient"), + 0, + 1, + _b32ToTip5(keccak256("asof")), + depositNonce, + sigs + ); + } + + function testSubmitDepositRejectsInvalidTip5() public { + Tip5Hash memory invalid; + invalid.limbs[0] = PRIME; + uint256 depositNonce = _nextDepositNonce(); + + bytes[] memory sigs = new bytes[](3); + vm.expectRevert("Invalid txId"); + inbox.submitDeposit( + invalid, + _b32ToTip5(keccak256("name-first")), + _b32ToTip5(keccak256("name-last")), + makeAddr("recipient"), + nockAmount(1), + 1, + _b32ToTip5(keccak256("asof")), + depositNonce, + sigs + ); + } + + function testSubmitDepositRejectsZeroAsOf() public { + Tip5Hash memory zeroHash; + // All limbs default to 0 + uint256 depositNonce = _nextDepositNonce(); + + bytes[] memory sigs = new bytes[](3); + vm.expectRevert("Invalid as-of hash"); + inbox.submitDeposit( + _b32ToTip5(keccak256("tx")), + _b32ToTip5(keccak256("name-first")), + _b32ToTip5(keccak256("name-last")), + makeAddr("recipient"), + nockAmount(1), + 1, + zeroHash, + depositNonce, + sigs + ); + } + + function testSubmitDepositRejectsDuplicateTxId() public { + Tip5Hash memory txId = _b32ToTip5(keccak256("dupe")); + Tip5Hash memory nameFirst = _b32ToTip5(keccak256("name-first")); + Tip5Hash memory nameLast = _b32ToTip5(keccak256("name-last")); + address recipient = makeAddr("recipient"); + uint256 amount = nockAmount(5); + uint256 blockHeight = 5; + Tip5Hash memory asOf = _b32ToTip5(keccak256("as-of")); + uint256 depositNonce1 = _nextDepositNonce(); + + uint256[] memory signerIndexes = new uint256[](3); + signerIndexes[0] = 0; + signerIndexes[1] = 1; + signerIndexes[2] = 2; + bytes[] memory sigs = buildDepositSignatureSet( + signerIndexes, + txId, + nameFirst, + nameLast, + recipient, + amount, + blockHeight, + asOf, + depositNonce1 + ); + + inbox.submitDeposit(txId, nameFirst, nameLast, recipient, amount, blockHeight, asOf, depositNonce1, sigs); + + uint256 depositNonce2 = _nextDepositNonce(); + bytes[] memory sigs2 = buildDepositSignatureSet( + signerIndexes, + txId, + nameFirst, + nameLast, + recipient, + amount, + blockHeight, + asOf, + depositNonce2 + ); + + vm.expectRevert("Deposit already processed"); + inbox.submitDeposit(txId, nameFirst, nameLast, recipient, amount, blockHeight, asOf, depositNonce2, sigs2); + } + + function testSubmitDepositRejectsStaleNonce() public { + Tip5Hash memory txId1 = _b32ToTip5(keccak256("tx1")); + Tip5Hash memory nameFirst = _b32ToTip5(keccak256("name-first")); + Tip5Hash memory nameLast = _b32ToTip5(keccak256("name-last")); + address recipient = makeAddr("recipient"); + uint256 amount = nockAmount(1); + uint256 blockHeight = 1; + Tip5Hash memory asOf = _b32ToTip5(keccak256("as-of")); + uint256 nonce10 = 10; + + bytes[] memory sigs1 = buildDepositSignatureSet( + _defaultSignerIndexes(), + txId1, + nameFirst, + nameLast, + recipient, + amount, + blockHeight, + asOf, + nonce10 + ); + + inbox.submitDeposit(txId1, nameFirst, nameLast, recipient, amount, blockHeight, asOf, nonce10, sigs1); + assertEq(inbox.lastDepositNonce(), 10); + + Tip5Hash memory txId2 = _b32ToTip5(keccak256("tx2")); + uint256 nonce5 = 5; + + bytes[] memory sigs2 = buildDepositSignatureSet( + _defaultSignerIndexes(), + txId2, + nameFirst, + nameLast, + recipient, + amount, + blockHeight, + asOf, + nonce5 + ); + + vm.expectRevert("Nonce must be strictly greater"); + inbox.submitDeposit(txId2, nameFirst, nameLast, recipient, amount, blockHeight, asOf, nonce5, sigs2); + } + + function testSubmitDepositRejectsEqualNonce() public { + Tip5Hash memory txId1 = _b32ToTip5(keccak256("tx-eq1")); + Tip5Hash memory nameFirst = _b32ToTip5(keccak256("name-first")); + Tip5Hash memory nameLast = _b32ToTip5(keccak256("name-last")); + address recipient = makeAddr("recipient"); + uint256 amount = nockAmount(1); + uint256 blockHeight = 1; + Tip5Hash memory asOf = _b32ToTip5(keccak256("as-of")); + uint256 nonce10 = 10; + + bytes[] memory sigs1 = buildDepositSignatureSet( + _defaultSignerIndexes(), + txId1, + nameFirst, + nameLast, + recipient, + amount, + blockHeight, + asOf, + nonce10 + ); + + inbox.submitDeposit(txId1, nameFirst, nameLast, recipient, amount, blockHeight, asOf, nonce10, sigs1); + + Tip5Hash memory txId2 = _b32ToTip5(keccak256("tx-eq2")); + + bytes[] memory sigs2 = buildDepositSignatureSet( + _defaultSignerIndexes(), + txId2, + nameFirst, + nameLast, + recipient, + amount, + blockHeight, + asOf, + nonce10 + ); + + vm.expectRevert("Nonce must be strictly greater"); + inbox.submitDeposit(txId2, nameFirst, nameLast, recipient, amount, blockHeight, asOf, nonce10, sigs2); + } + + function testSubmitDepositAllowsNonContiguousNonces() public { + Tip5Hash memory txId1 = _b32ToTip5(keccak256("tx-nc1")); + Tip5Hash memory nameFirst = _b32ToTip5(keccak256("name-first")); + Tip5Hash memory nameLast = _b32ToTip5(keccak256("name-last")); + address recipient = makeAddr("recipient"); + uint256 amount = nockAmount(1); + uint256 blockHeight = 1; + Tip5Hash memory asOf = _b32ToTip5(keccak256("as-of")); + + bytes[] memory sigs1 = buildDepositSignatureSet( + _defaultSignerIndexes(), + txId1, + nameFirst, + nameLast, + recipient, + amount, + blockHeight, + asOf, + 5 + ); + + inbox.submitDeposit(txId1, nameFirst, nameLast, recipient, amount, blockHeight, asOf, 5, sigs1); + assertEq(inbox.lastDepositNonce(), 5); + + Tip5Hash memory txId2 = _b32ToTip5(keccak256("tx-nc2")); + + bytes[] memory sigs2 = buildDepositSignatureSet( + _defaultSignerIndexes(), + txId2, + nameFirst, + nameLast, + recipient, + amount, + blockHeight, + asOf, + 100 + ); + + inbox.submitDeposit(txId2, nameFirst, nameLast, recipient, amount, blockHeight, asOf, 100, sigs2); + assertEq(inbox.lastDepositNonce(), 100); + } + + function testSubmitDepositRejectsMalleatedSignature() public { + Tip5Hash memory txId = _b32ToTip5(keccak256("malleated")); + Tip5Hash memory nameFirst = _b32ToTip5(keccak256("name-first")); + Tip5Hash memory nameLast = _b32ToTip5(keccak256("name-last")); + address recipient = makeAddr("recipient"); + uint256 amount = nockAmount(6); + uint256 blockHeight = 6; + Tip5Hash memory asOf = _b32ToTip5(keccak256("tip")); + uint256 depositNonce = _nextDepositNonce(); + + bytes[] memory sigs = buildDepositSignatureSet( + _defaultSignerIndexes(), + txId, + nameFirst, + nameLast, + recipient, + amount, + blockHeight, + asOf, + depositNonce + ); + + (bytes32 r, , uint8 v) = _signatureComponents(sigs[0]); + bytes32 highS = bytes32(SECP256K1_HALF_N + 1); + sigs[0] = _repackSignature(r, highS, v); + + vm.expectRevert("Invalid signature s value"); + inbox.submitDeposit(txId, nameFirst, nameLast, recipient, amount, blockHeight, asOf, depositNonce, sigs); + } + + function testSubmitDepositRejectsInvalidSignatureV() public { + Tip5Hash memory txId = _b32ToTip5(keccak256("invalid-v")); + Tip5Hash memory nameFirst = _b32ToTip5(keccak256("name-first")); + Tip5Hash memory nameLast = _b32ToTip5(keccak256("name-last")); + address recipient = makeAddr("recipient"); + uint256 amount = nockAmount(7); + uint256 blockHeight = 7; + Tip5Hash memory asOf = _b32ToTip5(keccak256("tip")); + uint256 depositNonce = _nextDepositNonce(); + + bytes[] memory sigs = buildDepositSignatureSet( + _defaultSignerIndexes(), + txId, + nameFirst, + nameLast, + recipient, + amount, + blockHeight, + asOf, + depositNonce + ); + + (bytes32 r, bytes32 s, ) = _signatureComponents(sigs[0]); + sigs[0] = _repackSignature(r, s, 29); + + vm.expectRevert("Invalid signature v value"); + inbox.submitDeposit(txId, nameFirst, nameLast, recipient, amount, blockHeight, asOf, depositNonce, sigs); + } + + function testSubmitDepositRejectsInvalidSignatureLength() public { + Tip5Hash memory txId = _b32ToTip5(keccak256("sig-length")); + Tip5Hash memory nameFirst = _b32ToTip5(keccak256("name-first")); + Tip5Hash memory nameLast = _b32ToTip5(keccak256("name-last")); + address recipient = makeAddr("recipient"); + uint256 amount = nockAmount(8); + uint256 blockHeight = 8; + Tip5Hash memory asOf = _b32ToTip5(keccak256("tip")); + uint256 depositNonce = _nextDepositNonce(); + + uint256[] memory signerIndexes = _defaultSignerIndexes(); + bytes[] memory sigs = buildDepositSignatureSet( + signerIndexes, + txId, + nameFirst, + nameLast, + recipient, + amount, + blockHeight, + asOf, + depositNonce + ); + + sigs[0] = new bytes(64); + + vm.expectRevert("Invalid signature length"); + inbox.submitDeposit(txId, nameFirst, nameLast, recipient, amount, blockHeight, asOf, depositNonce, sigs); + } + + function testUpdateBridgeNodeOnlyOwner() public { + address newNode = makeAddr("new-node"); + + vm.expectEmit(true, true, true, true, address(inbox)); + emit MessageInbox.BridgeNodeUpdated(0, bridgeNode(0), newNode); + + inbox.updateBridgeNode(0, newNode); + assertEq(inbox.bridgeNodes(0), newNode); + } + + function testUpdateBridgeNodeRejectsInvalidIndex() public { + vm.expectRevert("Invalid bridge node index"); + inbox.updateBridgeNode(5, makeAddr("node")); + } + + function testUpdateBridgeNodeRejectsZeroAddress() public { + vm.expectRevert("Invalid bridge node address"); + inbox.updateBridgeNode(0, address(0)); + } + + function testUpdateBridgeNodeRequiresOwner() public { + address attacker = makeAddr("attacker"); + vm.prank(attacker); + vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, attacker)); + inbox.updateBridgeNode(0, makeAddr("node")); + } + + function testNotifyBurnOnlyNockCanCall() public { + vm.expectRevert("Only Nock contract can notify burns"); + inbox.notifyBurn(); + } + + function _signatureComponents( + bytes memory sig + ) internal pure returns (bytes32 r, bytes32 s, uint8 v) { + assembly { + r := mload(add(sig, 32)) + s := mload(add(sig, 64)) + v := byte(0, mload(add(sig, 96))) + } + } + + function _repackSignature( + bytes32 r, + bytes32 s, + uint8 v + ) internal pure returns (bytes memory) { + return abi.encodePacked(r, s, v); + } +} diff --git a/crates/bridge/contracts/test/MessageInboxFuzzTest.t.sol b/crates/bridge/contracts/test/MessageInboxFuzzTest.t.sol new file mode 100644 index 000000000..52939a54f --- /dev/null +++ b/crates/bridge/contracts/test/MessageInboxFuzzTest.t.sol @@ -0,0 +1,415 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Tip5Hash} from "../MessageInbox.sol"; +import {BridgeTestBase} from "./BridgeTestBase.t.sol"; + +/// @notice Fuzz tests for MessageInbox signature validation and Tip5Hash handling +contract MessageInboxFuzzTest is BridgeTestBase { + uint256 private constant SECP256K1_N = + 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141; + uint256 private constant SECP256K1_HALF_N = SECP256K1_N / 2; + uint64 private constant TIP5_PRIME = 0xffffffff00000001; + + function setUp() public override { + super.setUp(); + } + + /// @notice Fuzz test: any signature with s > SECP256K1_N/2 should be rejected + function testFuzz_rejectMalleatedSignatureS(uint256 sValue) public { + // Bound s to be > HALF_N (malleated range) + sValue = bound(sValue, SECP256K1_HALF_N + 1, SECP256K1_N - 1); + + Tip5Hash memory txId = _b32ToTip5(keccak256("fuzz-malleated")); + Tip5Hash memory nameFirst = _b32ToTip5(keccak256("name-first")); + Tip5Hash memory nameLast = _b32ToTip5(keccak256("name-last")); + address recipient = makeAddr("recipient"); + uint256 amount = nockAmount(1); + uint256 blockHeight = 1; + Tip5Hash memory asOf = _b32ToTip5(keccak256("asof")); + uint256 depositNonce = _nextDepositNonce(); + + // Build valid signatures first + uint256[] memory signerIndexes = new uint256[](3); + signerIndexes[0] = 0; + signerIndexes[1] = 1; + signerIndexes[2] = 2; + + bytes[] memory sigs = buildDepositSignatureSet( + signerIndexes, txId, nameFirst, nameLast, recipient, amount, blockHeight, asOf, depositNonce + ); + + // Extract r and v from first signature, replace s with malleated value + bytes32 r; + uint8 v; + assembly { + let sigPtr := mload(add(sigs, 32)) + r := mload(add(sigPtr, 32)) + v := byte(0, mload(add(sigPtr, 96))) + } + + // Repack with malleated s + sigs[0] = abi.encodePacked(r, bytes32(sValue), v); + + vm.expectRevert("Invalid signature s value"); + inbox.submitDeposit(txId, nameFirst, nameLast, recipient, amount, blockHeight, asOf, depositNonce, sigs); + } + + /// @notice Fuzz test: any signature with invalid v (not 27 or 28) should be rejected + function testFuzz_rejectInvalidSignatureV(uint8 vValue) public { + vm.assume(vValue != 27 && vValue != 28); + + Tip5Hash memory txId = _b32ToTip5(keccak256("fuzz-invalid-v")); + Tip5Hash memory nameFirst = _b32ToTip5(keccak256("name-first")); + Tip5Hash memory nameLast = _b32ToTip5(keccak256("name-last")); + address recipient = makeAddr("recipient"); + uint256 amount = nockAmount(1); + uint256 blockHeight = 1; + Tip5Hash memory asOf = _b32ToTip5(keccak256("asof")); + uint256 depositNonce = _nextDepositNonce(); + + uint256[] memory signerIndexes = new uint256[](3); + signerIndexes[0] = 0; + signerIndexes[1] = 1; + signerIndexes[2] = 2; + + bytes[] memory sigs = buildDepositSignatureSet( + signerIndexes, txId, nameFirst, nameLast, recipient, amount, blockHeight, asOf, depositNonce + ); + + // Extract r and s from first signature + bytes32 r; + bytes32 s; + assembly { + let sigPtr := mload(add(sigs, 32)) + r := mload(add(sigPtr, 32)) + s := mload(add(sigPtr, 64)) + } + + // Repack with invalid v + sigs[0] = abi.encodePacked(r, s, vValue); + + vm.expectRevert("Invalid signature v value"); + inbox.submitDeposit(txId, nameFirst, nameLast, recipient, amount, blockHeight, asOf, depositNonce, sigs); + } + + /// @notice Fuzz test: signatures with wrong length should be rejected + function testFuzz_rejectInvalidSignatureLength(uint8 length) public { + vm.assume(length != 65 && length < 200); // Avoid huge allocations + + Tip5Hash memory txId = _b32ToTip5(keccak256("fuzz-length")); + Tip5Hash memory nameFirst = _b32ToTip5(keccak256("name-first")); + Tip5Hash memory nameLast = _b32ToTip5(keccak256("name-last")); + address recipient = makeAddr("recipient"); + uint256 amount = nockAmount(1); + uint256 blockHeight = 1; + Tip5Hash memory asOf = _b32ToTip5(keccak256("asof")); + uint256 depositNonce = _nextDepositNonce(); + + uint256[] memory signerIndexes = new uint256[](3); + signerIndexes[0] = 0; + signerIndexes[1] = 1; + signerIndexes[2] = 2; + + bytes[] memory sigs = buildDepositSignatureSet( + signerIndexes, txId, nameFirst, nameLast, recipient, amount, blockHeight, asOf, depositNonce + ); + + // Replace first signature with wrong length + sigs[0] = new bytes(length); + + vm.expectRevert("Invalid signature length"); + inbox.submitDeposit(txId, nameFirst, nameLast, recipient, amount, blockHeight, asOf, depositNonce, sigs); + } + + /// @notice Fuzz test: Tip5Hash with any limb >= PRIME should be rejected as txId + function testFuzz_rejectInvalidTip5TxId(uint64 invalidLimb, uint8 limbIndex) public { + vm.assume(invalidLimb >= TIP5_PRIME); + limbIndex = limbIndex % 5; // Ensure valid index + + Tip5Hash memory txId; + // Set valid values for all limbs first + txId.limbs[0] = 1; + txId.limbs[1] = 2; + txId.limbs[2] = 3; + txId.limbs[3] = 4; + txId.limbs[4] = 5; + // Then set the invalid limb + txId.limbs[limbIndex] = invalidLimb; + + uint256 depositNonce = _nextDepositNonce(); + bytes[] memory sigs = new bytes[](3); + vm.expectRevert("Invalid txId"); + inbox.submitDeposit( + txId, + _b32ToTip5(keccak256("name-first")), + _b32ToTip5(keccak256("name-last")), + makeAddr("recipient"), + nockAmount(1), + 1, + _b32ToTip5(keccak256("asof")), + depositNonce, + sigs + ); + } + + /// @notice Fuzz test: Tip5Hash with any limb >= PRIME should be rejected as nameFirst + function testFuzz_rejectInvalidTip5NameFirst(uint64 invalidLimb, uint8 limbIndex) public { + vm.assume(invalidLimb >= TIP5_PRIME); + limbIndex = limbIndex % 5; + + Tip5Hash memory nameFirst; + nameFirst.limbs[0] = 1; + nameFirst.limbs[1] = 2; + nameFirst.limbs[2] = 3; + nameFirst.limbs[3] = 4; + nameFirst.limbs[4] = 5; + nameFirst.limbs[limbIndex] = invalidLimb; + + uint256 depositNonce = _nextDepositNonce(); + bytes[] memory sigs = new bytes[](3); + vm.expectRevert("Invalid nameFirst"); + inbox.submitDeposit( + _b32ToTip5(keccak256("tx")), + nameFirst, + _b32ToTip5(keccak256("name-last")), + makeAddr("recipient"), + nockAmount(1), + 1, + _b32ToTip5(keccak256("asof")), + depositNonce, + sigs + ); + } + + /// @notice Fuzz test: Tip5Hash with any limb >= PRIME should be rejected as nameLast + function testFuzz_rejectInvalidTip5NameLast(uint64 invalidLimb, uint8 limbIndex) public { + vm.assume(invalidLimb >= TIP5_PRIME); + limbIndex = limbIndex % 5; + + Tip5Hash memory nameLast; + nameLast.limbs[0] = 1; + nameLast.limbs[1] = 2; + nameLast.limbs[2] = 3; + nameLast.limbs[3] = 4; + nameLast.limbs[4] = 5; + nameLast.limbs[limbIndex] = invalidLimb; + + uint256 depositNonce = _nextDepositNonce(); + bytes[] memory sigs = new bytes[](3); + vm.expectRevert("Invalid nameLast"); + inbox.submitDeposit( + _b32ToTip5(keccak256("tx")), + _b32ToTip5(keccak256("name-first")), + nameLast, + makeAddr("recipient"), + nockAmount(1), + 1, + _b32ToTip5(keccak256("asof")), + depositNonce, + sigs + ); + } + + /// @notice Fuzz test: Tip5Hash with any limb >= PRIME should be rejected as asOf + function testFuzz_rejectInvalidTip5AsOf(uint64 invalidLimb, uint8 limbIndex) public { + vm.assume(invalidLimb >= TIP5_PRIME); + limbIndex = limbIndex % 5; + + Tip5Hash memory asOf; + asOf.limbs[0] = 1; + asOf.limbs[1] = 2; + asOf.limbs[2] = 3; + asOf.limbs[3] = 4; + asOf.limbs[4] = 5; + asOf.limbs[limbIndex] = invalidLimb; + + uint256 depositNonce = _nextDepositNonce(); + bytes[] memory sigs = new bytes[](3); + vm.expectRevert("Invalid asOf"); + inbox.submitDeposit( + _b32ToTip5(keccak256("tx")), + _b32ToTip5(keccak256("name-first")), + _b32ToTip5(keccak256("name-last")), + makeAddr("recipient"), + nockAmount(1), + 1, + asOf, + depositNonce, + sigs + ); + } + + /// @notice Test: zero Tip5Hash (all limbs = 0) should be rejected as asOf + /// Note: This is not a fuzz test since we need all limbs to be exactly zero + function test_rejectZeroAsOf() public { + Tip5Hash memory asOf; + // All limbs default to 0 + + uint256 depositNonce = _nextDepositNonce(); + bytes[] memory sigs = new bytes[](3); + vm.expectRevert("Invalid as-of hash"); + inbox.submitDeposit( + _b32ToTip5(keccak256("tx")), + _b32ToTip5(keccak256("name-first")), + _b32ToTip5(keccak256("name-last")), + makeAddr("recipient"), + nockAmount(1), + 1, + asOf, + depositNonce, + sigs + ); + } + + /// @notice Fuzz test: valid Tip5Hash values should be accepted (sanity check) + function testFuzz_acceptValidTip5( + uint64 limb0, uint64 limb1, uint64 limb2, uint64 limb3, uint64 limb4 + ) public { + // Bound all limbs to valid range + limb0 = uint64(bound(limb0, 1, TIP5_PRIME - 1)); + limb1 = uint64(bound(limb1, 0, TIP5_PRIME - 1)); + limb2 = uint64(bound(limb2, 0, TIP5_PRIME - 1)); + limb3 = uint64(bound(limb3, 0, TIP5_PRIME - 1)); + limb4 = uint64(bound(limb4, 0, TIP5_PRIME - 1)); + + Tip5Hash memory txId; + txId.limbs[0] = limb0; + txId.limbs[1] = limb1; + txId.limbs[2] = limb2; + txId.limbs[3] = limb3; + txId.limbs[4] = limb4; + + Tip5Hash memory nameFirst = _b32ToTip5(keccak256("name-first")); + Tip5Hash memory nameLast = _b32ToTip5(keccak256("name-last")); + Tip5Hash memory asOf = _b32ToTip5(keccak256("asof")); + address recipient = makeAddr("recipient"); + uint256 amount = nockAmount(1); + uint256 blockHeight = 1; + uint256 depositNonce = _nextDepositNonce(); + + uint256[] memory signerIndexes = new uint256[](3); + signerIndexes[0] = 0; + signerIndexes[1] = 1; + signerIndexes[2] = 2; + + bytes[] memory sigs = buildDepositSignatureSet( + signerIndexes, txId, nameFirst, nameLast, recipient, amount, blockHeight, asOf, depositNonce + ); + + // Should succeed without reverting + inbox.submitDeposit(txId, nameFirst, nameLast, recipient, amount, blockHeight, asOf, depositNonce, sigs); + + // Verify it was processed + bytes32 txIdHash = keccak256(abi.encodePacked( + txId.limbs[0], txId.limbs[1], txId.limbs[2], txId.limbs[3], txId.limbs[4] + )); + assertTrue(inbox.processedDeposits(txIdHash)); + } + + /// @notice Fuzz test: random bytes as signature should fail gracefully + function testFuzz_rejectRandomSignatureBytes(bytes32 rand1, bytes32 rand2, uint8 rand3) public { + Tip5Hash memory txId = _b32ToTip5(keccak256(abi.encodePacked("fuzz-random", rand1))); + Tip5Hash memory nameFirst = _b32ToTip5(keccak256("name-first")); + Tip5Hash memory nameLast = _b32ToTip5(keccak256("name-last")); + address recipient = makeAddr("recipient"); + uint256 amount = nockAmount(1); + uint256 blockHeight = 1; + Tip5Hash memory asOf = _b32ToTip5(keccak256("asof")); + uint256 depositNonce = _nextDepositNonce(); + + bytes[] memory sigs = new bytes[](3); + // First two are valid + uint256[] memory signerIndexes = new uint256[](2); + signerIndexes[0] = 0; + signerIndexes[1] = 1; + bytes[] memory validSigs = buildDepositSignatureSet( + signerIndexes, txId, nameFirst, nameLast, recipient, amount, blockHeight, asOf, depositNonce + ); + sigs[0] = validSigs[0]; + sigs[1] = validSigs[1]; + + // Third is random garbage (but correct length) + sigs[2] = abi.encodePacked(rand1, rand2, rand3); + + // Should revert with one of several possible errors depending on the random values + // Either invalid s, invalid v, or invalid signatures (wrong signer) + vm.expectRevert(); + inbox.submitDeposit(txId, nameFirst, nameLast, recipient, amount, blockHeight, asOf, depositNonce, sigs); + } + + /// @notice Fuzz test: signature with s = 0 should be rejected + function testFuzz_rejectZeroS() public { + Tip5Hash memory txId = _b32ToTip5(keccak256("fuzz-zero-s")); + Tip5Hash memory nameFirst = _b32ToTip5(keccak256("name-first")); + Tip5Hash memory nameLast = _b32ToTip5(keccak256("name-last")); + address recipient = makeAddr("recipient"); + uint256 amount = nockAmount(1); + uint256 blockHeight = 1; + Tip5Hash memory asOf = _b32ToTip5(keccak256("asof")); + uint256 depositNonce = _nextDepositNonce(); + + uint256[] memory signerIndexes = new uint256[](3); + signerIndexes[0] = 0; + signerIndexes[1] = 1; + signerIndexes[2] = 2; + + bytes[] memory sigs = buildDepositSignatureSet( + signerIndexes, txId, nameFirst, nameLast, recipient, amount, blockHeight, asOf, depositNonce + ); + + // Extract r and v, set s to 0 + bytes32 r; + uint8 v; + assembly { + let sigPtr := mload(add(sigs, 32)) + r := mload(add(sigPtr, 32)) + v := byte(0, mload(add(sigPtr, 96))) + } + + sigs[0] = abi.encodePacked(r, bytes32(0), v); + + vm.expectRevert("Invalid signature s value"); + inbox.submitDeposit(txId, nameFirst, nameLast, recipient, amount, blockHeight, asOf, depositNonce, sigs); + } + + /// @notice Fuzz test: valid s values in low range should be accepted + function testFuzz_acceptValidLowS(uint256 sValue) public { + // Bound s to valid range: 0 < s <= HALF_N + sValue = bound(sValue, 1, SECP256K1_HALF_N); + + // This test just verifies our bounds - actual signature verification + // requires the s to match the actual signed message, so we can't + // easily fuzz valid signatures. Instead, we verify the boundary logic + // by checking that our test helper generates valid low-s signatures. + + Tip5Hash memory txId = _b32ToTip5(keccak256(abi.encodePacked("fuzz-valid-s", sValue))); + Tip5Hash memory nameFirst = _b32ToTip5(keccak256("name-first")); + Tip5Hash memory nameLast = _b32ToTip5(keccak256("name-last")); + address recipient = makeAddr("recipient"); + uint256 amount = nockAmount(1); + uint256 blockHeight = 1; + Tip5Hash memory asOf = _b32ToTip5(keccak256("asof")); + uint256 depositNonce = _nextDepositNonce(); + + uint256[] memory signerIndexes = new uint256[](3); + signerIndexes[0] = 0; + signerIndexes[1] = 1; + signerIndexes[2] = 2; + + bytes[] memory sigs = buildDepositSignatureSet( + signerIndexes, txId, nameFirst, nameLast, recipient, amount, blockHeight, asOf, depositNonce + ); + + // Verify that our test helper generates low-s signatures + bytes32 s; + assembly { + let sigPtr := mload(add(sigs, 32)) + s := mload(add(sigPtr, 64)) + } + assertTrue(uint256(s) <= SECP256K1_HALF_N, "Test helper should generate low-s signatures"); + + // Should succeed + inbox.submitDeposit(txId, nameFirst, nameLast, recipient, amount, blockHeight, asOf, depositNonce, sigs); + } +} diff --git a/crates/bridge/contracts/test/MessageInboxUpgradeTest.t.sol b/crates/bridge/contracts/test/MessageInboxUpgradeTest.t.sol new file mode 100644 index 000000000..247e52b0b --- /dev/null +++ b/crates/bridge/contracts/test/MessageInboxUpgradeTest.t.sol @@ -0,0 +1,57 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; +import {OwnableUpgradeable} from "@openzeppelin/contracts-upgradeable/access/OwnableUpgradeable.sol"; +import {MessageInbox} from "../MessageInbox.sol"; +import {Nock} from "../Nock.sol"; + +import {BridgeTestBase} from "./BridgeTestBase.t.sol"; + +contract MessageInboxUpgradeTest is BridgeTestBase { + MessageInbox internal implementation; + + function setUp() public override { + _initBridgeNodes(); + + implementation = new MessageInbox(); + nock = new Nock("Nock", "NOCK", address(implementation)); + + address[5] memory nodes = bridgeNodesArray(); + bytes memory initCalldata = abi.encodeWithSelector( + MessageInbox.initialize.selector, + nodes, + address(nock) + ); + + ERC1967Proxy proxy = new ERC1967Proxy( + address(implementation), + initCalldata + ); + + inbox = MessageInbox(address(proxy)); + nock.updateInbox(address(inbox)); + } + + function testOwnerCanUpgrade() public { + MessageInboxV2 newImpl = new MessageInboxV2(); + inbox.upgradeTo(address(newImpl)); + + assertEq(MessageInboxV2(address(inbox)).version(), 2); + } + + function testNonOwnerCannotUpgrade() public { + MessageInboxV2 newImpl = new MessageInboxV2(); + address attacker = makeAddr("attacker"); + + vm.prank(attacker); + vm.expectRevert(abi.encodeWithSelector(OwnableUpgradeable.OwnableUnauthorizedAccount.selector, attacker)); + inbox.upgradeTo(address(newImpl)); + } +} + +contract MessageInboxV2 is MessageInbox { + function version() external pure returns (uint8) { + return 2; + } +} diff --git a/crates/bridge/contracts/test/NockWithdrawalTest.t.sol b/crates/bridge/contracts/test/NockWithdrawalTest.t.sol new file mode 100644 index 000000000..4a9613f8d --- /dev/null +++ b/crates/bridge/contracts/test/NockWithdrawalTest.t.sol @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; +import {Nock} from "../Nock.sol"; +import {BridgeTestBase} from "./BridgeTestBase.t.sol"; + +contract NockWithdrawalTest is BridgeTestBase { + function setUp() public override { + super.setUp(); + } + + function testBurnEmitsEventAndNotifiesInbox() public { + address burner = makeAddr("burner"); + uint256 amount = nockAmount(25); + bytes32 lockRoot = keccak256("lock-root"); + + mintFromInbox(burner, amount); + + vm.expectEmit(true, true, true, true, address(nock)); + emit Nock.BurnForWithdrawal(burner, amount, lockRoot); + + vm.prank(burner); + nock.burn(amount, lockRoot); + + assertEq(nock.balanceOf(burner), 0); + } + + function testBurnRequiresPositiveAmount() public { + address burner = makeAddr("burner"); + mintFromInbox(burner, nockAmount(1)); + + vm.prank(burner); + vm.expectRevert("Amount must be positive"); + nock.burn(0, keccak256("lock")); + } + + function testBurnRequiresSufficientBalance() public { + address burner = makeAddr("burner"); + vm.prank(burner); + vm.expectRevert("Insufficient balance"); + nock.burn(nockAmount(1), keccak256("lock")); + } + + function testMintOnlyInboxCanCall() public { + vm.expectRevert("Only inbox can mint"); + nock.mint(makeAddr("recipient"), nockAmount(1)); + } + + function testMintRequiresPositiveAmount() public { + vm.prank(address(inbox)); + vm.expectRevert("Amount must be positive"); + nock.mint(makeAddr("recipient"), 0); + } + + function testUpdateInboxOnlyOwner() public { + address newInbox = makeAddr("new-inbox"); + vm.expectEmit(true, true, false, true, address(nock)); + emit Nock.InboxUpdated(address(inbox), newInbox); + nock.updateInbox(newInbox); + assertEq(nock.inbox(), newInbox); + } + + function testUpdateInboxRejectsZeroAddress() public { + vm.expectRevert("Invalid inbox address"); + nock.updateInbox(address(0)); + } + + function testUpdateInboxRequiresOwner() public { + address attacker = makeAddr("attacker"); + vm.prank(attacker); + vm.expectRevert(abi.encodeWithSelector(Ownable.OwnableUnauthorizedAccount.selector, attacker)); + nock.updateInbox(makeAddr("new")); + } + + function testDecimalsReturns16() public view { + assertEq(nock.decimals(), 16); + } +} diff --git a/crates/bridge/proto/bridge_ingress.proto b/crates/bridge/proto/bridge_ingress.proto new file mode 100644 index 000000000..9c36cc5f1 --- /dev/null +++ b/crates/bridge/proto/bridge_ingress.proto @@ -0,0 +1,124 @@ +syntax = "proto3"; + +package bridge.ingress.v1; + +enum ProposalKind { + PROPOSAL_KIND_UNSPECIFIED = 0; + PROPOSAL_KIND_BASE = 1; + // PROPOSAL_KIND_NOCKCHAIN = 2; // Removed: bridge only handles deposit proposals +} + +message ProposalRequest { + string proposer = 1; + bytes payload = 2; + uint64 request_id = 3; + ProposalKind kind = 4; +} + +message ProposalResponse { + string event_id = 1; + // Signature from this node if the proposal was validated and signed + bytes signature = 2; + // The proposal hash that was signed (for verification) + bytes proposal_hash = 3; +} + +// EthSignatureRequest contains the structured fields needed to compute +// the proposal hash: keccak256(abi.encode(txId, name, recipient, amount, blockHeight, asOf, nonce)) +message EthSignatureRequest { + bytes tx_id = 1; // 40-byte nockchain transaction ID (5 uint64 limbs) + bytes name = 2; // 80-byte note name (first + last Tip5 hashes) + bytes recipient = 3; // 20-byte Ethereum address + uint64 amount = 4; // Amount in nocks + uint64 block_height = 5; // Block height + bytes as_of = 6; // 32-byte as-of hash + uint64 nonce = 7; // Deposit nonce for event ordering +} + +message SignatureRequest { + reserved 1; + reserved "signer"; + // proposal_hash is keccak256(abi.encode(txId, name, recipient, amount, blockHeight, asOf)) + bytes proposal_hash = 2; + bytes signature = 3; + uint64 request_id = 4; + EthSignatureRequest eth_signature_request = 5; +} + +message SignatureResponse { + string event_id = 1; +} + +message HealthCheckRequest { + uint64 requester_node_id = 1; + string requester_address = 2; +} + +message HealthCheckResponse { + uint64 responder_node_id = 1; + uint64 uptime_millis = 2; + string status = 3; + uint64 timestamp_millis = 4; +} + +message SignatureBroadcast { + bytes deposit_id = 1; // DepositId serialized (120 bytes) + bytes proposal_hash = 2; // 32 bytes + bytes signature = 3; // 65 bytes (r, s, v) + bytes signer_address = 4; // 20 bytes + uint64 timestamp = 5; // Unix timestamp +} + +message SignatureBroadcastResponse { + bool accepted = 1; + string error = 2; +} + +message ProposalStatusRequest { + bytes deposit_id = 1; +} + +message ProposalStatusResponse { + string status = 1; + uint32 signature_count = 2; + repeated bytes signers = 3; + optional bytes tx_hash = 4; +} + +// Broadcast when a proposal has been confirmed on-chain +// Sent by the node that successfully posted to BASE +message ConfirmationBroadcast { + bytes deposit_id = 1; // DepositId serialized (120 bytes) + bytes proposal_hash = 2; // 32 bytes + bytes tx_hash = 3; // 32 bytes - the BASE transaction hash + uint64 block_number = 4; // BASE block number where confirmed + uint64 timestamp = 5; // Unix timestamp +} + +message ConfirmationBroadcastResponse { + bool accepted = 1; +} + +// Broadcast when the bridge should stop processing. +// The recipient should poke its kernel with `%stop last=[base-hash nock-hash]`. +message StopBroadcast { + uint64 sender_node_id = 1; + string reason = 2; + optional bytes last_base_hash = 3; // 40 bytes (5 uint64 limbs) + optional uint64 last_base_height = 4; + optional bytes last_nock_hash = 5; // 40 bytes (5 uint64 limbs) + optional uint64 last_nock_height = 6; + uint64 timestamp = 7; // Unix timestamp +} + +message StopBroadcastResponse { + bool accepted = 1; +} + +service BridgeIngress { + rpc HealthCheck(HealthCheckRequest) returns (HealthCheckResponse); + rpc BroadcastSignature(SignatureBroadcast) returns (SignatureBroadcastResponse); + rpc GetProposalStatus(ProposalStatusRequest) returns (ProposalStatusResponse); + rpc BroadcastConfirmation(ConfirmationBroadcast) returns (ConfirmationBroadcastResponse); + rpc BroadcastStop(StopBroadcast) returns (StopBroadcastResponse); +} diff --git a/crates/bridge/proto/bridge_status.proto b/crates/bridge/proto/bridge_status.proto new file mode 100644 index 000000000..e9c7b74cf --- /dev/null +++ b/crates/bridge/proto/bridge_status.proto @@ -0,0 +1,59 @@ +syntax = "proto3"; + +package bridge.status.v1; + +enum RunningState { + RUNNING_STATE_UNSPECIFIED = 0; + RUNNING = 1; + STOPPED = 2; +} + +message GetStatusRequest {} + +message Base58Hash { + string value = 1; +} + +message EthAddress { + string value = 1; +} + +message LastDeposit { + Base58Hash tx_id = 1; + Base58Hash name_first = 2; + Base58Hash name_last = 3; + EthAddress recipient = 4; + uint64 amount = 5; + uint64 block_height = 6; + Base58Hash as_of = 7; + uint64 nonce = 8; + string base_tx_hash = 9; + uint64 base_block_number = 10; +} + +message SuccessfulDeposit { + Base58Hash tx_id = 1; + Base58Hash name_first = 2; + Base58Hash name_last = 3; + EthAddress recipient = 4; + uint64 amount = 5; + uint64 block_height = 6; + Base58Hash as_of = 7; + uint64 nonce = 8; +} + +message GetStatusResponse { + RunningState running_state = 1; + bool nock_hold = 2; + bool base_hold = 3; + optional uint64 nock_hold_height = 4; + optional uint64 base_hold_height = 5; + optional uint64 nock_height = 6; + optional uint64 base_height = 7; + optional LastDeposit last_submitted_deposit = 8; + optional SuccessfulDeposit last_successful_deposit = 9; +} + +service BridgeStatus { + rpc GetStatus(GetStatusRequest) returns (GetStatusResponse); +} diff --git a/crates/bridge/proto/bridge_tui.proto b/crates/bridge/proto/bridge_tui.proto new file mode 100644 index 000000000..31c5e1327 --- /dev/null +++ b/crates/bridge/proto/bridge_tui.proto @@ -0,0 +1,293 @@ +syntax = "proto3"; + +package bridge.tui.v1; + +enum RunningState { + RUNNING_STATE_UNSPECIFIED = 0; + RUNNING = 1; + STOPPED = 2; +} + +message GetSnapshotRequest { + DepositLogView deposit_log_view = 1; + AlertView alert_view = 2; +} + +message Base58Hash { + string value = 1; +} + +message EthAddress { + string value = 1; +} + +message LastDeposit { + Base58Hash tx_id = 1; + Base58Hash name_first = 2; + Base58Hash name_last = 3; + EthAddress recipient = 4; + uint64 amount = 5; + uint64 block_height = 6; + Base58Hash as_of = 7; + uint64 nonce = 8; + string base_tx_hash = 9; + uint64 base_block_number = 10; +} + +message SuccessfulDeposit { + Base58Hash tx_id = 1; + Base58Hash name_first = 2; + Base58Hash name_last = 3; + EthAddress recipient = 4; + uint64 amount = 5; + uint64 block_height = 6; + Base58Hash as_of = 7; + uint64 nonce = 8; +} + +message DepositLogView { + uint64 offset = 1; + uint64 limit = 2; +} + +message DepositLogRow { + uint64 nonce = 1; + uint64 block_height = 2; + string tx_id_base58 = 3; + string recipient_hex = 4; + uint64 amount = 5; +} + +message DepositLogSnapshot { + uint64 total_count = 1; + uint64 first_epoch_nonce = 2; + repeated DepositLogRow rows = 3; +} + +message AlertView { + uint32 limit = 1; +} + +enum AlertSeverity { + ALERT_SEVERITY_UNSPECIFIED = 0; + INFO = 1; + WARNING = 2; + ERROR = 3; + CRITICAL = 4; +} + +message Alert { + uint64 id = 1; + AlertSeverity severity = 2; + string title = 3; + string message = 4; + string source = 5; + uint64 created_at_ms = 6; +} + +message AlertsSnapshot { + repeated Alert alerts = 1; +} + +message ChainState { + uint64 height = 1; + string tip_hash = 2; + uint64 confirmations = 3; + bool is_syncing = 4; + optional uint64 last_updated_ms = 5; +} + +message NockchainApiStatus { + enum State { + STATE_UNSPECIFIED = 0; + CONNECTED = 1; + CONNECTING = 2; + DISCONNECTED = 3; + } + + State state = 1; + optional uint64 since_ms = 2; + optional uint32 attempt = 3; + optional string last_error = 4; +} + +message BatchStatus { + oneof status { + BatchIdle idle = 1; + BatchProcessing processing = 2; + BatchAwaitingSignatures awaiting_signatures = 3; + BatchSubmitting submitting = 4; + } +} + +message BatchIdle {} + +message BatchProcessing { + uint64 batch_id = 1; + uint32 progress_pct = 2; +} + +message BatchAwaitingSignatures { + uint64 batch_id = 1; + uint32 collected = 2; + uint32 required = 3; +} + +message BatchSubmitting { + uint64 batch_id = 1; +} + +message NetworkState { + ChainState base = 1; + ChainState nockchain = 2; + uint64 pending_deposits = 3; + uint64 pending_withdrawals = 4; + uint64 unsettled_deposit_count = 5; + uint64 unsettled_withdrawal_count = 6; + BatchStatus batch_status = 7; + optional bool is_mainnet = 8; + NockchainApiStatus nockchain_api_status = 9; + optional uint64 base_next_height = 10; + optional uint64 nock_next_height = 11; + optional string degradation_warning = 12; +} + +enum ProposalStatus { + PROPOSAL_STATUS_UNSPECIFIED = 0; + PENDING = 1; + READY = 2; + SUBMITTED = 3; + EXECUTED = 4; + EXPIRED = 5; + FAILED = 6; +} + +message Proposal { + string id = 1; + string proposal_type = 2; + string description = 3; + uint32 signatures_collected = 4; + uint32 signatures_required = 5; + repeated uint64 signers = 6; + optional uint64 created_at_ms = 7; + ProposalStatus status = 8; + string data_hash = 9; + optional uint64 submitted_at_block = 10; + optional uint64 submitted_at_ms = 11; + optional string tx_hash = 12; + optional uint64 time_to_submit_ms = 13; + optional uint64 executed_at_block = 14; + optional uint64 source_block = 15; + // NOCK amount as a decimal string (1 NOCK = 65,536 nicks). + optional string amount = 16; + optional string recipient = 17; + optional uint64 nonce = 18; + optional string source_tx_id = 19; + optional uint64 current_proposer = 20; + bool is_my_turn = 21; + optional uint64 time_until_takeover_ms = 22; + optional string failure_reason = 23; +} + +message ProposalState { + Proposal last_submitted = 1; + repeated Proposal pending_inbound = 2; + repeated Proposal history = 3; +} + +enum TxDirection { + TX_DIRECTION_UNSPECIFIED = 0; + DEPOSIT = 1; + WITHDRAWAL = 2; +} + +message TxStatus { + oneof status { + TxStatusPending pending = 1; + TxStatusConfirming confirming = 2; + TxStatusProcessing processing = 3; + TxStatusCompleted completed = 4; + TxStatusFailed failed = 5; + } +} + +message TxStatusPending {} + +message TxStatusConfirming { + uint64 confirmations = 1; + uint64 required = 2; +} + +message TxStatusProcessing {} + +message TxStatusCompleted {} + +message TxStatusFailed { + string reason = 1; +} + +message BridgeTx { + string tx_hash = 1; + TxDirection direction = 2; + string from = 3; + string to = 4; + string amount = 5; + TxStatus status = 6; + uint64 timestamp_ms = 7; + optional uint64 base_block = 8; + optional uint64 nock_height = 9; +} + +message TransactionState { + repeated BridgeTx transactions = 1; + uint64 max_transactions = 2; +} + +message MetricsState { + string total_deposited = 1; + string total_withdrawn = 2; + repeated uint64 hourly_tx_counts = 3; + double avg_latency_secs = 4; + double success_rate = 5; + string total_fees = 6; + uint64 tx_count = 7; + uint64 latency_sum_ms = 8; + uint64 latency_count = 9; +} + +enum PeerHealthStatus { + PEER_HEALTH_STATUS_UNSPECIFIED = 0; + HEALTHY = 1; + UNREACHABLE = 2; +} + +message PeerStatus { + uint64 node_id = 1; + string address = 2; + PeerHealthStatus status = 3; + optional string error = 4; + optional uint64 latency_ms = 5; + optional uint64 peer_uptime_ms = 6; + optional uint64 last_updated_ms = 7; +} + +message GetSnapshotResponse { + RunningState running_state = 1; + bool nock_hold = 2; + bool base_hold = 3; + optional uint64 nock_hold_height = 4; + optional uint64 base_hold_height = 5; + NetworkState network_state = 6; + DepositLogSnapshot deposit_log = 7; + ProposalState proposals = 8; + repeated PeerStatus peer_statuses = 9; + LastDeposit last_submitted_deposit = 10; + SuccessfulDeposit last_successful_deposit = 11; + AlertsSnapshot alerts = 12; + MetricsState metrics = 13; + TransactionState transactions = 14; +} + +service BridgeTui { + rpc GetSnapshot(GetSnapshotRequest) returns (GetSnapshotResponse); +} diff --git a/crates/bridge/src/bin/nockchain-bridge-tui.rs b/crates/bridge/src/bin/nockchain-bridge-tui.rs new file mode 100644 index 000000000..0cad0a3ef --- /dev/null +++ b/crates/bridge/src/bin/nockchain-bridge-tui.rs @@ -0,0 +1,94 @@ +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use bridge::bridge_status::BridgeStatus; +use bridge::errors::BridgeError; +use bridge::health::{initialize_health_state, normalize_endpoint}; +use bridge::stop::StopController; +use bridge::tui; +use bridge::tui::state::{new_log_buffer, TuiStatus}; +use bridge::tui_client::BridgeTuiClient; +use clap::Parser; +use tokio::signal; +use tokio::time::timeout; +use tracing::{error, info}; +use tracing_subscriber::EnvFilter; + +#[derive(Parser, Debug, Clone)] +#[command(author, version, about = "Bridge TUI client", long_about = None)] +struct TuiCli { + #[arg( + long, + help = "Bridge ingress gRPC server address (default: 127.0.0.1:8001)" + )] + server: Option, +} + +const DEFAULT_SERVER_ADDR: &str = "127.0.0.1:8001"; +const INITIAL_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); + +fn init_tui_logging() -> Result<(), BridgeError> { + let filter = EnvFilter::new(std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string())); + tracing_subscriber::fmt() + .with_env_filter(filter) + .with_writer(std::io::stderr) + .try_init() + .map_err(|e| BridgeError::Runtime(format!("failed to init tracing: {e}")))?; + Ok(()) +} + +#[tokio::main] +async fn main() -> Result<(), BridgeError> { + init_tui_logging()?; + let cli = TuiCli::parse(); + + let server_raw = cli + .server + .clone() + .unwrap_or_else(|| DEFAULT_SERVER_ADDR.to_string()); + let server_uri = normalize_endpoint(&server_raw); + + info!("Attempting to connect to {server_uri}"); + let client = match timeout( + INITIAL_CONNECT_TIMEOUT, + BridgeTuiClient::new(server_uri.clone()), + ) + .await + { + Ok(client) => client, + Err(_) => { + let timeout_secs = INITIAL_CONNECT_TIMEOUT.as_secs(); + error!("Timed out after {timeout_secs}s connecting to {server_uri}"); + return Err(BridgeError::Runtime(format!( + "Timed out after {timeout_secs}s connecting to {server_uri}" + ))); + } + }; + + let peers = Vec::new(); + let health_state = initialize_health_state(&peers); + let bridge_status = BridgeStatus::new(health_state); + let tui_status = TuiStatus::new(bridge_status, new_log_buffer()); + + let shutdown = Arc::new(AtomicBool::new(false)); + let shutdown_signal = shutdown.clone(); + tokio::spawn(async move { + if signal::ctrl_c().await.is_ok() { + shutdown_signal.store(true, Ordering::Relaxed); + } + }); + + let client_shutdown = shutdown.clone(); + let client_status = tui_status.clone(); + let client_handle = + tokio::spawn(async move { client.run(client_status, client_shutdown).await }); + + let (_stop_controller, stop_handle) = StopController::new(); + tui::run_tui(tui_status, shutdown.clone(), stop_handle).await?; + + shutdown.store(true, Ordering::Relaxed); + let _ = client_handle.await; + + Ok(()) +} diff --git a/crates/bridge/src/bridge_status.rs b/crates/bridge/src/bridge_status.rs new file mode 100644 index 000000000..69e9aa5b7 --- /dev/null +++ b/crates/bridge/src/bridge_status.rs @@ -0,0 +1,955 @@ +//! Shared bridge status state. +//! +//! This module provides the central state container that aggregates +//! all data sources for the TUI panels and status endpoint. + +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; +use std::time::Duration; + +use alloy::primitives::Address; + +use crate::health::{NodeHealthSnapshot, SharedHealthState}; +use crate::proposal_cache::{ProposalState as CacheProposalState, SIGNATURE_THRESHOLD}; +use crate::tui::types::{AlertState, MetricsState, NetworkState, ProposalState, TransactionState}; + +/// Capacity for transaction history. +pub const TX_CAPACITY: usize = 100; + +/// Capacity for proposal history. +pub const PROPOSAL_HISTORY_CAPACITY: usize = 50; + +/// Capacity for alert history. +pub const ALERT_HISTORY_CAPACITY: usize = 100; + +/// Shared bridge status that can be updated from multiple sources. +/// +/// This is the central data store for the TUI panels and status endpoint. Each field +/// is wrapped in Arc> to allow concurrent updates from +/// background tasks while the TUI renders. +#[derive(Clone, Debug)] +pub struct BridgeStatus { + /// Peer health state (existing). + pub health: SharedHealthState, + /// Network/chain state. + pub network: Arc>, + /// Transaction activity. + pub transactions: Arc>, + /// Proposal management. + pub proposals: Arc>, + /// Metrics and analytics. + pub metrics: Arc>, + /// Alerts and notifications. + pub alerts: Arc>, + /// Last confirmed deposit nonce from chain. + last_deposit_nonce: Arc>>, +} + +impl BridgeStatus { + /// Create a new shared bridge status with the given health state. + pub fn new(health: SharedHealthState) -> Self { + Self { + health, + network: Arc::new(RwLock::new(NetworkState::default())), + transactions: Arc::new(RwLock::new(TransactionState::new(TX_CAPACITY))), + proposals: Arc::new(RwLock::new(ProposalState::new(PROPOSAL_HISTORY_CAPACITY))), + metrics: Arc::new(RwLock::new(MetricsState::default())), + alerts: Arc::new(RwLock::new(AlertState::new(ALERT_HISTORY_CAPACITY))), + last_deposit_nonce: Arc::new(RwLock::new(None)), + } + } + + // NOTE: All read methods return defaults on lock poisoning for graceful TUI degradation. + // A poisoned lock indicates a panic in a background thread - the TUI should continue + // rendering (with empty data) rather than crashing. The underlying panic will be + // logged elsewhere. + + /// Get health snapshots. + pub fn health_snapshots(&self) -> Vec { + self.health + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + /// Get network state. + pub fn network(&self) -> NetworkState { + self.network + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + /// Get transaction state. + pub fn transactions(&self) -> TransactionState { + self.transactions + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + /// Get proposal state. + pub fn proposals(&self) -> ProposalState { + self.proposals + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + /// Get metrics state. + pub fn metrics(&self) -> MetricsState { + self.metrics + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + /// Get alert state. + pub fn alerts(&self) -> AlertState { + self.alerts + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + pub fn update_proposal(&self, proposal: crate::tui::types::Proposal) { + if let Ok(mut guard) = self.proposals.write() { + guard.update_or_insert(proposal); + } + } + + pub fn update_proposal_signature(&self, proposal_id: &str, node_id: u64) -> bool { + if let Ok(mut guard) = self.proposals.write() { + guard.add_signature(proposal_id, node_id) + } else { + false + } + } + + /// Sync proposal signature counts and signers from the cache into the TUI. + /// + /// This keeps the UI accurate when signatures arrive before a proposal exists + /// and are later applied from the pending signature queue. + pub fn sync_proposal_signatures_from_cache( + &self, + proposal_id: &str, + cache_state: &CacheProposalState, + address_to_node_id: &HashMap, + self_node_id: u64, + ) { + let total_signatures = cache_state.peer_signatures.len() + + if cache_state.my_signature.is_some() { + 1 + } else { + 0 + }; + + if let Some(mut proposal) = self.find_proposal(proposal_id) { + let mut signers = Vec::new(); + + if cache_state.my_signature.is_some() { + signers.push(self_node_id); + } + + for addr in cache_state.peer_signatures.keys() { + if let Some(&node_id) = address_to_node_id.get(addr) { + signers.push(node_id); + } + } + + signers.sort_unstable(); + signers.dedup(); + + proposal.signers = signers; + proposal.signatures_collected = total_signatures as u8; + if proposal.signatures_required == 0 { + proposal.signatures_required = SIGNATURE_THRESHOLD as u8; + } + + if total_signatures >= proposal.signatures_required as usize + && proposal.status == crate::tui::types::ProposalStatus::Pending + { + proposal.status = crate::tui::types::ProposalStatus::Ready; + } + + self.update_proposal(proposal); + } + + self.update_signature_count( + cache_state.proposal.nonce, total_signatures as u8, SIGNATURE_THRESHOLD as u8, + ); + } + + /// Find a proposal by ID and return a clone if found. + pub fn find_proposal(&self, id: &str) -> Option { + self.proposals + .read() + .ok() + .and_then(|guard| guard.find_by_id(id).cloned()) + } + + pub fn push_transaction(&self, tx: crate::tui::types::BridgeTx) { + if let Ok(mut guard) = self.transactions.write() { + guard.push(tx); + } + } + + pub fn update_network(&self, network: NetworkState) { + if let Ok(mut guard) = self.network.write() { + *guard = network; + } + } + + pub fn update_base_tip_hash(&self, tip_hash: String) { + if tip_hash.is_empty() { + return; + } + if let Ok(mut guard) = self.network.write() { + guard.base.tip_hash = tip_hash; + } + } + + pub fn update_nockchain_tip_hash(&self, tip_hash: String) { + if tip_hash.is_empty() { + return; + } + if let Ok(mut guard) = self.network.write() { + guard.nockchain.tip_hash = tip_hash; + } + } + + /// Update the nockchain API connection status. + /// + /// This updates only the `nockchain_api_status` field, preserving other network state. + pub fn update_nockchain_api_status(&self, status: crate::tui::types::NockchainApiStatus) { + if let Ok(mut guard) = self.network.write() { + guard.nockchain_api_status = status; + } + } + + pub fn push_alert( + &self, + severity: crate::tui::types::AlertSeverity, + title: String, + message: String, + source: String, + ) { + if let Ok(mut guard) = self.alerts.write() { + guard.push(severity, title, message, source); + } + } + + // --- Metrics update methods --- + + /// Record a transaction completion for metrics tracking. + /// + /// Updates running averages for latency and success rate. + /// Uses saturating arithmetic to prevent overflow. + pub fn record_tx_completion( + &self, + direction: crate::tui::types::TxDirection, + amount: u128, + latency_ms: u64, + success: bool, + ) { + if let Ok(mut guard) = self.metrics.write() { + // Update transaction count + guard.tx_count = guard.tx_count.saturating_add(1); + + // Update latency tracking + guard.latency_sum_ms = guard.latency_sum_ms.saturating_add(latency_ms); + guard.latency_count = guard.latency_count.saturating_add(1); + + // Compute average latency in seconds (avoid division by zero) + if guard.latency_count > 0 { + let avg_ms = guard.latency_sum_ms / guard.latency_count; + guard.avg_latency_secs = avg_ms as f64 / 1000.0; + } + + // Update success rate (avoid division by zero) + if guard.tx_count > 0 { + // Track successes by maintaining running count + // success_rate = successful_count / total_count + // We can derive successful_count from: successful = success_rate * (tx_count - 1) + let prev_successful = if guard.tx_count > 1 { + (guard.success_rate * (guard.tx_count - 1) as f64).round() as u64 + } else { + 0 + }; + let new_successful = if success { + prev_successful + 1 + } else { + prev_successful + }; + guard.success_rate = new_successful as f64 / guard.tx_count as f64; + } + + // Update direction-specific totals with saturating add + match direction { + crate::tui::types::TxDirection::Deposit => { + guard.total_deposited = guard.total_deposited.saturating_add(amount); + } + crate::tui::types::TxDirection::Withdrawal => { + guard.total_withdrawn = guard.total_withdrawn.saturating_add(amount); + } + } + } + } + + /// Update metrics totals directly. + /// + /// This is useful for bulk updates from external sources. + /// Uses saturating arithmetic to prevent overflow. + pub fn update_metrics_totals(&self, deposited: u128, withdrawn: u128, fees: u128) { + if let Ok(mut guard) = self.metrics.write() { + guard.total_deposited = guard.total_deposited.saturating_add(deposited); + guard.total_withdrawn = guard.total_withdrawn.saturating_add(withdrawn); + guard.total_fees = guard.total_fees.saturating_add(fees); + } + } + + /// Push an hourly transaction count to the metrics sparkline. + /// + /// This delegates to MetricsState::push_hourly_count which enforces + /// the 24-hour capacity limit. + pub fn push_hourly_count(&self, count: u64) { + if let Ok(mut guard) = self.metrics.write() { + guard.push_hourly_count(count); + } + } + + /// Rotate hourly transaction counts (shift left, add 0). + /// + /// Called every hour by the background rotation task to maintain + /// a rolling 24-hour window. Drops the oldest hour and adds a new + /// empty count (0) for the current hour. + pub fn rotate_hourly_counts(&self) { + if let Ok(mut guard) = self.metrics.write() { + guard.push_hourly_count(0); + } + } + + // --- Batch status update methods --- + + /// Update batch status with state transition validation. + /// + /// Validates that state transitions follow the expected flow: + /// Idle → Processing → AwaitingSignatures → Submitting → Idle + /// + /// Invalid transitions log a warning but do not crash. + pub fn update_batch_status(&self, status: crate::tui::types::BatchStatus) { + if let Ok(mut guard) = self.network.write() { + use crate::tui::types::BatchStatus; + + // Validate state transition + let valid_transition = match (&guard.batch_status, &status) { + // From Idle: can only go to Processing + (BatchStatus::Idle, BatchStatus::Processing { .. }) => true, + (BatchStatus::Idle, BatchStatus::Idle) => true, // Idempotent + + // From Processing: can go to AwaitingSignatures, Submitting (if no sigs needed), or back to Idle (on error) + (BatchStatus::Processing { .. }, BatchStatus::AwaitingSignatures { .. }) => true, + (BatchStatus::Processing { .. }, BatchStatus::Submitting { .. }) => true, + (BatchStatus::Processing { .. }, BatchStatus::Idle) => true, + ( + BatchStatus::Processing { batch_id: id1, .. }, + BatchStatus::Processing { batch_id: id2, .. }, + ) => { + id1 == id2 // Allow progress updates for same batch + } + + // From AwaitingSignatures: can go to Submitting or back to Idle (on error/timeout) + (BatchStatus::AwaitingSignatures { .. }, BatchStatus::Submitting { .. }) => true, + (BatchStatus::AwaitingSignatures { .. }, BatchStatus::Idle) => true, + ( + BatchStatus::AwaitingSignatures { batch_id: id1, .. }, + BatchStatus::AwaitingSignatures { batch_id: id2, .. }, + ) => { + id1 == id2 // Allow signature count updates for same batch + } + + // From Submitting: can only go to Idle (completion or error) + (BatchStatus::Submitting { .. }, BatchStatus::Idle) => true, + + // Invalid transitions + _ => false, + }; + + if valid_transition { + guard.batch_status = status; + } else { + // Log warning but don't crash - the TUI should continue rendering + tracing::warn!( + "Invalid batch status transition: {:?} -> {:?}", guard.batch_status, status + ); + } + } + } + + /// Update batch processing progress. + /// + /// This is a convenience method for updating progress percentage + /// within the Processing state. + pub fn update_batch_progress(&self, batch_id: u64, progress_pct: u8) { + use crate::tui::types::BatchStatus; + self.update_batch_status(BatchStatus::Processing { + batch_id, + progress_pct, + }); + } + + /// Update signature collection count. + /// + /// This is a convenience method for updating signature collection + /// within the AwaitingSignatures state. + pub fn update_signature_count(&self, batch_id: u64, collected: u8, required: u8) { + use crate::tui::types::BatchStatus; + self.update_batch_status(BatchStatus::AwaitingSignatures { + batch_id, + collected, + required, + }); + } + + /// Get last confirmed deposit nonce from chain. + pub fn last_deposit_nonce(&self) -> Option { + self.last_deposit_nonce.read().ok().and_then(|guard| *guard) + } + + /// Update last confirmed deposit nonce from chain. + pub fn update_last_deposit_nonce(&self, nonce: u64) { + if let Ok(mut guard) = self.last_deposit_nonce.write() { + *guard = Some(nonce); + } + } + + /// Replace last confirmed deposit nonce (clears when None). + pub fn set_last_deposit_nonce(&self, nonce: Option) { + if let Ok(mut guard) = self.last_deposit_nonce.write() { + *guard = nonce; + } + } +} + +/// Rotates hourly transaction counts every hour for the sparkline display. +pub async fn run_hourly_rotation(bridge_status: BridgeStatus) { + use tokio::time::{interval_at, Instant}; + + let now = std::time::SystemTime::now(); + let secs_since_epoch = now + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let secs_until_next_hour = 3600 - (secs_since_epoch % 3600); + + let start = Instant::now() + Duration::from_secs(secs_until_next_hour); + let mut timer = interval_at(start, Duration::from_secs(3600)); + + loop { + timer.tick().await; + bridge_status.rotate_hourly_counts(); + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, RwLock}; + + use super::*; + use crate::tui::types::{BatchStatus, NockchainApiStatus, TxDirection, HOURLY_TX_CAPACITY}; + + #[test] + fn test_record_tx_completion_updates_totals() { + let state = BridgeStatus::new(Arc::new(RwLock::new(Vec::new()))); + + // Record a deposit + state.record_tx_completion(TxDirection::Deposit, 1000, 500, true); + + let metrics = state.metrics(); + assert_eq!(metrics.total_deposited, 1000); + assert_eq!(metrics.total_withdrawn, 0); + + // Record a withdrawal + state.record_tx_completion(TxDirection::Withdrawal, 500, 300, true); + + let metrics = state.metrics(); + assert_eq!(metrics.total_deposited, 1000); + assert_eq!(metrics.total_withdrawn, 500); + } + + #[test] + fn test_success_rate_calculation() { + let state = BridgeStatus::new(Arc::new(RwLock::new(Vec::new()))); + + // All successful + state.record_tx_completion(TxDirection::Deposit, 100, 100, true); + state.record_tx_completion(TxDirection::Deposit, 100, 100, true); + state.record_tx_completion(TxDirection::Deposit, 100, 100, true); + + let metrics = state.metrics(); + assert_eq!(metrics.tx_count, 3); + assert_eq!(metrics.success_rate, 1.0); + + // One failure + state.record_tx_completion(TxDirection::Deposit, 100, 100, false); + + let metrics = state.metrics(); + assert_eq!(metrics.tx_count, 4); + assert_eq!(metrics.success_rate, 0.75); // 3/4 + + // Half and half + state.record_tx_completion(TxDirection::Deposit, 100, 100, false); + state.record_tx_completion(TxDirection::Deposit, 100, 100, false); + + let metrics = state.metrics(); + assert_eq!(metrics.tx_count, 6); + assert_eq!(metrics.success_rate, 0.5); // 3/6 + } + + #[test] + fn test_success_rate_with_zero_count() { + let state = BridgeStatus::new(Arc::new(RwLock::new(Vec::new()))); + let metrics = state.metrics(); + + // Should not panic with division by zero + assert_eq!(metrics.success_rate, 0.0); + assert_eq!(metrics.tx_count, 0); + } + + #[test] + fn test_latency_averaging() { + let state = BridgeStatus::new(Arc::new(RwLock::new(Vec::new()))); + + // Record transactions with different latencies + state.record_tx_completion(TxDirection::Deposit, 100, 1000, true); // 1s + state.record_tx_completion(TxDirection::Deposit, 100, 2000, true); // 2s + state.record_tx_completion(TxDirection::Deposit, 100, 3000, true); // 3s + + let metrics = state.metrics(); + assert_eq!(metrics.latency_count, 3); + assert_eq!(metrics.latency_sum_ms, 6000); + // Average should be 2000ms = 2.0s + assert_eq!(metrics.avg_latency_secs, 2.0); + } + + #[test] + fn test_latency_averaging_with_zero_count() { + let state = BridgeStatus::new(Arc::new(RwLock::new(Vec::new()))); + let metrics = state.metrics(); + + // Should not panic with division by zero + assert_eq!(metrics.avg_latency_secs, 0.0); + assert_eq!(metrics.latency_count, 0); + } + + #[test] + fn test_overflow_protection() { + let state = BridgeStatus::new(Arc::new(RwLock::new(Vec::new()))); + + // Set to near max + { + let mut guard = state.metrics.write().unwrap(); + guard.total_deposited = u128::MAX - 100; + } + + // This should saturate instead of overflow + state.record_tx_completion(TxDirection::Deposit, 200, 100, true); + + let metrics = state.metrics(); + assert_eq!(metrics.total_deposited, u128::MAX); + } + + #[test] + fn test_update_metrics_totals() { + let state = BridgeStatus::new(Arc::new(RwLock::new(Vec::new()))); + + state.update_metrics_totals(1000, 500, 25); + + let metrics = state.metrics(); + assert_eq!(metrics.total_deposited, 1000); + assert_eq!(metrics.total_withdrawn, 500); + assert_eq!(metrics.total_fees, 25); + + // Cumulative update + state.update_metrics_totals(500, 300, 10); + + let metrics = state.metrics(); + assert_eq!(metrics.total_deposited, 1500); + assert_eq!(metrics.total_withdrawn, 800); + assert_eq!(metrics.total_fees, 35); + } + + #[test] + fn test_push_hourly_count() { + let state = BridgeStatus::new(Arc::new(RwLock::new(Vec::new()))); + + // Push some counts + state.push_hourly_count(10); + state.push_hourly_count(20); + state.push_hourly_count(30); + + let metrics = state.metrics(); + assert_eq!(metrics.hourly_tx_counts.len(), 3); + assert_eq!(metrics.hourly_tx_counts[0], 10); + assert_eq!(metrics.hourly_tx_counts[1], 20); + assert_eq!(metrics.hourly_tx_counts[2], 30); + } + + #[test] + fn test_hourly_count_capacity_limit() { + let state = BridgeStatus::new(Arc::new(RwLock::new(Vec::new()))); + + // Push more than capacity + for i in 0..(HOURLY_TX_CAPACITY + 5) { + state.push_hourly_count(i as u64); + } + + let metrics = state.metrics(); + // Should be capped at HOURLY_TX_CAPACITY (24) + assert_eq!(metrics.hourly_tx_counts.len(), HOURLY_TX_CAPACITY); + // First element should be 5 (the 5 oldest were popped) + assert_eq!(metrics.hourly_tx_counts[0], 5); + } + + #[test] + fn test_incremental_latency_averaging() { + let state = BridgeStatus::new(Arc::new(RwLock::new(Vec::new()))); + + // First tx: 1000ms latency + state.record_tx_completion(TxDirection::Deposit, 100, 1000, true); + let metrics = state.metrics(); + assert_eq!(metrics.avg_latency_secs, 1.0); + + // Second tx: 3000ms latency (average should be 2000ms = 2.0s) + state.record_tx_completion(TxDirection::Deposit, 100, 3000, true); + let metrics = state.metrics(); + assert_eq!(metrics.avg_latency_secs, 2.0); + + // Third tx: 2000ms latency (average should be 2000ms = 2.0s) + state.record_tx_completion(TxDirection::Deposit, 100, 2000, true); + let metrics = state.metrics(); + assert_eq!(metrics.avg_latency_secs, 2.0); + } + + // --- Batch status tests --- + + #[test] + fn test_valid_batch_status_transitions() { + let state = BridgeStatus::new(Arc::new(RwLock::new(Vec::new()))); + + // Start: Idle -> Processing + state.update_batch_status(BatchStatus::Processing { + batch_id: 1, + progress_pct: 0, + }); + let network = state.network(); + assert!(matches!( + network.batch_status, + BatchStatus::Processing { batch_id: 1, .. } + )); + + // Processing -> AwaitingSignatures + state.update_batch_status(BatchStatus::AwaitingSignatures { + batch_id: 1, + collected: 2, + required: 4, + }); + let network = state.network(); + assert!(matches!( + network.batch_status, + BatchStatus::AwaitingSignatures { + batch_id: 1, + collected: 2, + required: 4 + } + )); + + // AwaitingSignatures -> Submitting + state.update_batch_status(BatchStatus::Submitting { batch_id: 1 }); + let network = state.network(); + assert!(matches!( + network.batch_status, + BatchStatus::Submitting { batch_id: 1 } + )); + + // Submitting -> Idle (completion) + state.update_batch_status(BatchStatus::Idle); + let network = state.network(); + assert!(matches!(network.batch_status, BatchStatus::Idle)); + } + + #[test] + fn test_invalid_batch_status_transitions() { + let state = BridgeStatus::new(Arc::new(RwLock::new(Vec::new()))); + + // Idle -> Submitting (invalid, should remain Idle) + state.update_batch_status(BatchStatus::Submitting { batch_id: 1 }); + let network = state.network(); + assert!(matches!(network.batch_status, BatchStatus::Idle)); + + // Idle -> AwaitingSignatures (invalid) + state.update_batch_status(BatchStatus::AwaitingSignatures { + batch_id: 1, + collected: 2, + required: 4, + }); + let network = state.network(); + assert!(matches!(network.batch_status, BatchStatus::Idle)); + } + + #[test] + fn test_batch_progress_updates() { + let state = BridgeStatus::new(Arc::new(RwLock::new(Vec::new()))); + + // Start processing + state.update_batch_progress(1, 0); + let network = state.network(); + assert!(matches!( + network.batch_status, + BatchStatus::Processing { + batch_id: 1, + progress_pct: 0 + } + )); + + // Update progress (same batch) + state.update_batch_progress(1, 50); + let network = state.network(); + assert!(matches!( + network.batch_status, + BatchStatus::Processing { + batch_id: 1, + progress_pct: 50 + } + )); + + // Update progress to 100% + state.update_batch_progress(1, 100); + let network = state.network(); + assert!(matches!( + network.batch_status, + BatchStatus::Processing { + batch_id: 1, + progress_pct: 100 + } + )); + } + + #[test] + fn test_signature_count_updates() { + let state = BridgeStatus::new(Arc::new(RwLock::new(Vec::new()))); + + // Start with processing, then move to awaiting signatures + state.update_batch_status(BatchStatus::Processing { + batch_id: 1, + progress_pct: 100, + }); + state.update_signature_count(1, 0, 4); + + let network = state.network(); + assert!(matches!( + network.batch_status, + BatchStatus::AwaitingSignatures { + batch_id: 1, + collected: 0, + required: 4 + } + )); + + // Update signature count (same batch) + state.update_signature_count(1, 2, 4); + let network = state.network(); + assert!(matches!( + network.batch_status, + BatchStatus::AwaitingSignatures { + batch_id: 1, + collected: 2, + required: 4 + } + )); + + // All signatures collected + state.update_signature_count(1, 4, 4); + let network = state.network(); + assert!(matches!( + network.batch_status, + BatchStatus::AwaitingSignatures { + batch_id: 1, + collected: 4, + required: 4 + } + )); + } + + #[test] + fn test_batch_error_recovery() { + let state = BridgeStatus::new(Arc::new(RwLock::new(Vec::new()))); + + // Processing -> Idle (error during processing) + state.update_batch_status(BatchStatus::Processing { + batch_id: 1, + progress_pct: 50, + }); + state.update_batch_status(BatchStatus::Idle); + let network = state.network(); + assert!(matches!(network.batch_status, BatchStatus::Idle)); + + // AwaitingSignatures -> Idle (timeout) + state.update_batch_status(BatchStatus::Processing { + batch_id: 2, + progress_pct: 100, + }); + state.update_batch_status(BatchStatus::AwaitingSignatures { + batch_id: 2, + collected: 1, + required: 4, + }); + state.update_batch_status(BatchStatus::Idle); + let network = state.network(); + assert!(matches!(network.batch_status, BatchStatus::Idle)); + } + + #[test] + fn test_batch_id_mismatch_in_updates() { + let state = BridgeStatus::new(Arc::new(RwLock::new(Vec::new()))); + + // Start processing batch 1 + state.update_batch_progress(1, 0); + + // Try to update batch 2's progress (different batch - should be rejected) + state.update_batch_progress(2, 50); + let network = state.network(); + // Should still be batch 1 at 0% + assert!(matches!( + network.batch_status, + BatchStatus::Processing { + batch_id: 1, + progress_pct: 0 + } + )); + } + + #[test] + fn test_processing_to_submitting_shortcut() { + let state = BridgeStatus::new(Arc::new(RwLock::new(Vec::new()))); + + // Processing -> Submitting (valid for batches that don't need signatures) + state.update_batch_status(BatchStatus::Processing { + batch_id: 1, + progress_pct: 100, + }); + state.update_batch_status(BatchStatus::Submitting { batch_id: 1 }); + + let network = state.network(); + assert!(matches!( + network.batch_status, + BatchStatus::Submitting { batch_id: 1 } + )); + } + + // --- Hourly rotation tests --- + + #[test] + fn test_rotate_hourly_counts() { + let state = BridgeStatus::new(Arc::new(RwLock::new(Vec::new()))); + + // Fill with initial data (10 hours of counts) + for i in 0..10 { + state.push_hourly_count(i * 10); + } + + let metrics = state.metrics(); + assert_eq!(metrics.hourly_tx_counts.len(), 10); + assert_eq!(metrics.hourly_tx_counts[0], 0); + assert_eq!(metrics.hourly_tx_counts[9], 90); + + // Rotate (shift left, add 0 at end) + state.rotate_hourly_counts(); + + let metrics = state.metrics(); + assert_eq!(metrics.hourly_tx_counts.len(), 11); + // Should have added 0 at end: [0, 10, 20, ..., 90, 0] + assert_eq!(metrics.hourly_tx_counts[10], 0); + } + + #[test] + fn test_rotate_hourly_counts_at_capacity() { + let state = BridgeStatus::new(Arc::new(RwLock::new(Vec::new()))); + + // Fill to capacity (24 hours) + for i in 0..HOURLY_TX_CAPACITY { + state.push_hourly_count(i as u64); + } + + let metrics = state.metrics(); + assert_eq!(metrics.hourly_tx_counts.len(), HOURLY_TX_CAPACITY); + + // Rotate (should drop oldest, add 0) + state.rotate_hourly_counts(); + + let metrics = state.metrics(); + assert_eq!(metrics.hourly_tx_counts.len(), HOURLY_TX_CAPACITY); + // Oldest (0) should be dropped, newest should be 0 + assert_eq!(metrics.hourly_tx_counts[0], 1); + assert_eq!(metrics.hourly_tx_counts[HOURLY_TX_CAPACITY - 1], 0); + } + + #[test] + fn test_rotate_hourly_counts_empty() { + let state = BridgeStatus::new(Arc::new(RwLock::new(Vec::new()))); + + // Rotate on empty state + state.rotate_hourly_counts(); + + let metrics = state.metrics(); + assert_eq!(metrics.hourly_tx_counts.len(), 1); + assert_eq!(metrics.hourly_tx_counts[0], 0); + } + + #[test] + fn test_update_nockchain_api_status() { + let state = BridgeStatus::new(Arc::new(RwLock::new(Vec::new()))); + + // Default is Connecting + let network = state.network(); + assert!(matches!( + network.nockchain_api_status, + NockchainApiStatus::Connecting { attempt: 0, .. } + )); + + // Update to Connected + state.update_nockchain_api_status(NockchainApiStatus::connected()); + let network = state.network(); + assert!(matches!( + network.nockchain_api_status, + NockchainApiStatus::Connected { .. } + )); + + // Update to Disconnected + state.update_nockchain_api_status(NockchainApiStatus::disconnected("error".to_string())); + let network = state.network(); + assert!(matches!( + network.nockchain_api_status, + NockchainApiStatus::Disconnected { .. } + )); + assert_eq!(network.nockchain_api_status.last_error(), Some("error")); + + // Update to Connecting with attempt + state.update_nockchain_api_status(NockchainApiStatus::connecting( + 5, + Some("retry".to_string()), + )); + let network = state.network(); + match &network.nockchain_api_status { + NockchainApiStatus::Connecting { + attempt, + last_error, + .. + } => { + assert_eq!(*attempt, 5); + assert_eq!(last_error.as_deref(), Some("retry")); + } + _ => panic!("expected Connecting"), + } + } +} diff --git a/crates/bridge/src/config.rs b/crates/bridge/src/config.rs new file mode 100644 index 000000000..efdd04a8c --- /dev/null +++ b/crates/bridge/src/config.rs @@ -0,0 +1,462 @@ +use std::collections::HashSet; +use std::convert::TryInto; +use std::fs; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +use alloy::primitives::Address; +use nockchain_math::belt::{Belt, PRIME}; +use nockchain_types::tx_engine::common::Hash as NockPkh; +use num_bigint::BigUint; +use serde::{Deserialize, Serialize}; + +use crate::errors::BridgeError; +use crate::types::{AtomBytes, BridgeConstants, NodeConfig, NodeInfo, SchnorrSecretKey, Tip5Hash}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BridgeConfigToml { + pub node_id: u64, + pub base_ws_url: String, + #[serde(default)] + pub inbox_contract_address: Option, + #[serde(default)] + pub nock_contract_address: Option, + pub my_eth_key: String, + pub my_nock_key: String, + pub grpc_address: String, + /// Number of confirmations required on Base before sending a batch to the kernel. + pub base_confirmation_depth: u64, + /// Number of confirmations required on nockchain before sending a block to the kernel. + pub nockchain_confirmation_depth: u64, + /// Base contract lastDepositNonce at the time the runtime nonce epoch is activated. + /// + /// When non-zero, this must equal the nonce of the anchor deposit identified by + /// nonce_epoch_start_height + nonce_epoch_start_tx_id_base58. + /// When zero, the start height/tx-id may be omitted to start from the first deposit + /// at/after the default start height. + #[serde(default)] + pub nonce_epoch_base: Option, + /// Nockchain height at which the runtime nonce epoch starts. + /// + /// Deposits with `block_height < nonce_epoch_start_height` will not be signed under the + /// epoch scheme, and are expected to have been handled prior to activation. + /// + /// Required when nonce_epoch_base is non-zero. + #[serde(default)] + pub nonce_epoch_start_height: Option, + /// Nockchain tx-id (base58) for the first deposit in the nonce epoch. + /// + /// This tx-id is included as the first entry in the deposit log and its nonce is + /// `nonce_epoch_base`. Deposits in the same block with smaller tx-ids are ignored. + /// + /// Required when nonce_epoch_base is non-zero. + #[serde(default)] + pub nonce_epoch_start_tx_id_base58: Option, + #[serde(default)] + pub ingress_listen_address: Option, + pub nodes: Vec, + /// Optional bridge constants (defaults applied if omitted) + #[serde(default)] + pub constants: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NodeInfoToml { + pub ip: String, + pub eth_pubkey: String, // TODO: this should be eth_address + /// Nockchain public key hash (PKH) - base58 encoded ~52 chars + pub nock_pkh: String, +} + +#[derive(Debug, Clone)] +pub struct NonceEpochConfig { + pub base: u64, + pub start_height: u64, + pub start_tx_id: Option, +} + +/// Optional bridge constants configuration. +/// All fields are optional - defaults match Hoon type defaults. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BridgeConstantsToml { + /// Minimum signatures required (default: 3) + #[serde(default = "default_min_signers")] + pub min_signers: u64, + + /// Total number of bridge nodes (default: 5) + #[serde(default = "default_total_signers")] + pub total_signers: u64, + + /// Minimum nocks for a bridge event (default: 1_000_000) + #[serde(default = "default_minimum_event_nocks")] + pub minimum_event_nocks: u64, + + /// Fee per nock in nicks (default: 195) + #[serde(default = "default_nicks_fee_per_nock")] + pub nicks_fee_per_nock: u64, + + /// Base blocks per chunk (default: 100) + #[serde(default = "default_base_blocks_chunk")] + pub base_blocks_chunk: u64, + + /// Base chain start height (default: 33_387_036) + #[serde(default = "default_base_start_height")] + pub base_start_height: u64, + + /// Nockchain start height (default: 25) + #[serde(default = "default_nockchain_start_height")] + pub nockchain_start_height: u64, +} + +// Default functions for serde +fn default_min_signers() -> u64 { + 3 +} +fn default_total_signers() -> u64 { + 5 +} +fn default_minimum_event_nocks() -> u64 { + 1_000_000 +} +fn default_nicks_fee_per_nock() -> u64 { + 195 +} +fn default_base_blocks_chunk() -> u64 { + 100 +} +fn default_base_start_height() -> u64 { + 33_387_036 +} +fn default_nockchain_start_height() -> u64 { + 25 +} + +impl Default for BridgeConstantsToml { + fn default() -> Self { + Self { + min_signers: default_min_signers(), + total_signers: default_total_signers(), + minimum_event_nocks: default_minimum_event_nocks(), + nicks_fee_per_nock: default_nicks_fee_per_nock(), + base_blocks_chunk: default_base_blocks_chunk(), + base_start_height: default_base_start_height(), + nockchain_start_height: default_nockchain_start_height(), + } + } +} + +impl BridgeConstantsToml { + /// Convert to BridgeConstants with validation. + pub fn to_bridge_constants(&self) -> Result { + // Validation + if self.min_signers > self.total_signers { + return Err(BridgeError::Config(format!( + "min_signers ({}) cannot exceed total_signers ({})", + self.min_signers, self.total_signers + ))); + } + if self.min_signers == 0 { + return Err(BridgeError::Config("min_signers must be at least 1".into())); + } + if self.minimum_event_nocks == 0 { + return Err(BridgeError::Config( + "minimum_event_nocks must be greater than 0".into(), + )); + } + if self.base_blocks_chunk == 0 { + return Err(BridgeError::Config( + "base_blocks_chunk must be greater than 0".into(), + )); + } + + // Warn if base_start_height is not aligned to batch boundaries + // This is allowed but unusual - the driver now handles misalignment correctly + let offset = self.base_start_height % self.base_blocks_chunk; + if offset != 0 && offset != 1 { + tracing::warn!( + base_start_height = self.base_start_height, + base_blocks_chunk = self.base_blocks_chunk, + offset = offset, + "base_start_height is not aligned to batch boundary (this is supported but unusual)" + ); + } + + Ok(BridgeConstants { + version: 0, + min_signers: self.min_signers, + total_signers: self.total_signers, + minimum_event_nocks: self.minimum_event_nocks, + nicks_fee_per_nock: self.nicks_fee_per_nock, + base_blocks_chunk: self.base_blocks_chunk, + base_start_height: self.base_start_height, + nockchain_start_height: self.nockchain_start_height, + }) + } +} + +#[derive(Debug, Deserialize)] +struct DeploymentsAddresses { + #[serde(rename = "messageInboxProxy")] + message_inbox_proxy: Option, + #[serde(rename = "nock")] + nock: Option, +} + +impl BridgeConfigToml { + pub fn from_file>(path: P) -> Result { + let contents = fs::read_to_string(path.as_ref()).map_err(|e| { + BridgeError::Config(format!( + "Failed to read config file at {}: {}", + path.as_ref().display(), + e + )) + })?; + + toml::from_str(&contents).map_err(|e| { + BridgeError::Config(format!( + "Failed to parse TOML config at {}: {}", + path.as_ref().display(), + e + )) + }) + } + + pub fn to_node_config(&self) -> Result { + let my_eth_key = parse_hex_key(&self.my_eth_key, "my_eth_key")?; + let my_nock_key_limbs = base58_to_belts::<8>(&self.my_nock_key, "my_nock_key")?; + + let nodes = self + .nodes + .iter() + .map(|n| n.to_node_info()) + .collect::, _>>()?; + + if nodes.len() != 5 { + return Err(BridgeError::Config(format!( + "expected exactly 5 nodes, found {}", + nodes.len() + ))); + } + + let mut seen_ips = HashSet::new(); + let mut seen_eth = HashSet::new(); + let mut seen_nock = HashSet::new(); + for node in &nodes { + if !seen_ips.insert(node.ip.clone()) { + return Err(BridgeError::Config(format!( + "duplicate node ip detected: {}", + node.ip + ))); + } + if !seen_eth.insert(node.eth_pubkey.as_slice().to_vec()) { + return Err(BridgeError::Config( + "duplicate ethereum pubkey detected".into(), + )); + } + if !seen_nock.insert(node.nock_pkh.clone()) { + return Err(BridgeError::Config( + "duplicate nockchain pkh detected".into(), + )); + } + } + + Ok(NodeConfig { + node_id: self.node_id, + nodes, + my_eth_key: AtomBytes::from(my_eth_key), + my_nock_key: SchnorrSecretKey::from(my_nock_key_limbs), + }) + } + + pub fn inbox_contract_address(&self) -> Result { + if let Some(address) = self.inbox_contract_address.as_ref().and_then(|value| { + if value.trim().is_empty() { + None + } else { + Some(value) + } + }) { + return Address::from_str(address).map_err(|e| { + BridgeError::Config(format!("Invalid inbox_contract_address: {}", e)) + }); + } + if let Some(deployments) = load_deployments_addresses()? { + if let Some(address) = deployments.message_inbox_proxy { + return Address::from_str(&address).map_err(|e| { + BridgeError::Config(format!( + "Invalid messageInboxProxy in deployments.json: {}", + e + )) + }); + } + } + Err(BridgeError::Config( + "Missing MessageInbox contract address. Set inbox_contract_address in bridge-conf.toml or ensure deployments.json provides messageInboxProxy." + .into(), + )) + } + + pub fn nock_contract_address(&self) -> Result { + if let Some(address) = self.nock_contract_address.as_ref().and_then(|value| { + if value.trim().is_empty() { + None + } else { + Some(value) + } + }) { + return Address::from_str(address) + .map_err(|e| BridgeError::Config(format!("Invalid nock_contract_address: {}", e))); + } + if let Some(deployments) = load_deployments_addresses()? { + if let Some(address) = deployments.nock { + return Address::from_str(&address).map_err(|e| { + BridgeError::Config(format!("Invalid nock address in deployments.json: {}", e)) + }); + } + } + Err(BridgeError::Config( + "Missing Nock token contract address. Set nock_contract_address in bridge-conf.toml or ensure deployments.json provides nock." + .into(), + )) + } + + pub fn base_ws_url(&self) -> &str { + &self.base_ws_url + } + + pub fn grpc_address(&self) -> &str { + &self.grpc_address + } + + pub fn my_eth_key_hex(&self) -> &str { + &self.my_eth_key + } + + pub fn ingress_listen_address(&self) -> Option<&str> { + self.ingress_listen_address.as_deref() + } + + /// Get bridge constants, using defaults if not configured. + pub fn bridge_constants(&self) -> Result { + match &self.constants { + Some(c) => c.to_bridge_constants(), + None => Ok(BridgeConstants::default()), + } + } + + pub fn nonce_epoch_start_tx_id(&self) -> Result, BridgeError> { + let Some(value) = self.nonce_epoch_start_tx_id_base58.as_deref() else { + return Ok(None); + }; + let belts = base58_to_belts::<5>(value, "nonce_epoch_start_tx_id_base58")?; + Ok(Some(Tip5Hash(belts))) + } +} + +impl NonceEpochConfig { + pub fn first_epoch_nonce(&self) -> u64 { + if self.start_tx_id.is_some() { + self.base + } else { + self.base.saturating_add(1) + } + } + + pub fn is_before_start_key(&self, block_height: u64, tx_id: &Tip5Hash) -> bool { + if block_height < self.start_height { + return true; + } + if block_height > self.start_height { + return false; + } + let Some(start_tx_id) = self.start_tx_id.as_ref() else { + return false; + }; + tx_id.to_be_limb_bytes() < start_tx_id.to_be_limb_bytes() + } +} + +impl NodeInfoToml { + pub fn to_node_info(&self) -> Result { + let eth_pubkey = parse_hex_key(&self.eth_pubkey, "eth_pubkey")?; + let nock_pkh = NockPkh::from_base58(&self.nock_pkh) + .map_err(|err| BridgeError::Config(format!("invalid pkh for nock_pkh: {}", err)))?; + + Ok(NodeInfo { + ip: self.ip.clone(), + eth_pubkey: AtomBytes::from(eth_pubkey), + nock_pkh, + }) + } +} + +fn parse_hex_key(hex_str: &str, field_name: &str) -> Result, BridgeError> { + let hex_str = hex_str.strip_prefix("0x").unwrap_or(hex_str); + let bytes = hex::decode(hex_str).map_err(|e| { + BridgeError::Config(format!("Invalid hex encoding for {}: {}", field_name, e)) + })?; + + if bytes.is_empty() { + return Err(BridgeError::Config(format!( + "{} cannot be empty", + field_name + ))); + } + + Ok(bytes) +} + +fn base58_to_belts(value: &str, field: &str) -> Result<[Belt; N], BridgeError> { + let bytes = bs58::decode(value).into_vec().map_err(|e| { + BridgeError::Config(format!("Invalid base58 encoding for {}: {}", field, e)) + })?; + if bytes.is_empty() { + return Err(BridgeError::Config(format!("{} cannot be empty", field))); + } + + let mut big = BigUint::from_bytes_be(&bytes); + let prime = BigUint::from(PRIME); + let mut belts = [Belt(0); N]; + for belt in belts.iter_mut() { + let rem = (&big % &prime) + .try_into() + .map_err(|_| BridgeError::Config(format!("{} limb did not fit in field", field)))?; + *belt = Belt(rem); + big /= ′ + } + + if big > BigUint::from(0u8) { + return Err(BridgeError::Config(format!( + "{} exceeds {} Belt limbs", + field, N + ))); + } + + Ok(belts) +} + +fn load_deployments_addresses() -> Result, BridgeError> { + let path = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("contracts") + .join("deployments.json"); + if !path.exists() { + return Ok(None); + } + let contents = fs::read_to_string(&path).map_err(|e| { + BridgeError::Config(format!( + "Failed to read deployments.json at {}: {}", + path.display(), + e + )) + })?; + if contents.trim().is_empty() { + return Ok(None); + } + let addresses: DeploymentsAddresses = serde_json::from_str(&contents)?; + Ok(Some(addresses)) +} + +pub fn default_config_path() -> Result { + let bridge_data_dir = nockapp::system_data_dir().join("bridge"); + Ok(bridge_data_dir.join("bridge-conf.toml")) +} diff --git a/crates/bridge/src/deposit_log.rs b/crates/bridge/src/deposit_log.rs new file mode 100644 index 000000000..1045c4597 --- /dev/null +++ b/crates/bridge/src/deposit_log.rs @@ -0,0 +1,1532 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use deadpool_diesel::sqlite::{Manager, Pool}; +use deadpool_diesel::Runtime; +use diesel::connection::SimpleConnection; +use diesel::prelude::*; +use diesel::sqlite::SqliteConnection; +use diesel::OptionalExtension; +use nockchain_types::v1::Name; +use tracing::{info, warn}; + +use crate::bridge_status::BridgeStatus; +use crate::config::NonceEpochConfig; +use crate::errors::BridgeError; +use crate::ethereum::BaseBridge; +use crate::metrics; +use crate::schema::deposit_log; +use crate::stop::StopHandle; +use crate::tui::state::TuiStatus; +use crate::tui::types::{DepositLogSnapshot, DepositLogView, DEPOSIT_LOG_PAGE_SIZE}; +use crate::types::{DepositId, EthAddress, Tip5Hash}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DepositLogEntry { + pub block_height: u64, + pub tx_id: Tip5Hash, + pub as_of: Tip5Hash, + pub name: Name, + pub recipient: EthAddress, + pub amount_to_mint: u64, +} + +#[derive(Insertable)] +#[diesel(table_name = deposit_log)] +struct NewDepositLogRow { + tx_id: Vec, + block_height: i64, + as_of: Vec, + name_first: Vec, + name_last: Vec, + recipient: Vec, + amount_to_mint: i64, +} + +/// Insert outcomes for idempotent inserts. Conflicting rows return an error. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DepositLogInsertOutcome { + Inserted, + ExistingMatch, + ExistingMismatch, +} + +#[derive(Queryable)] +struct DepositLogRow { + tx_id: Vec, + block_height: i64, + as_of: Vec, + name_first: Vec, + name_last: Vec, + recipient: Vec, + amount_to_mint: i64, +} + +pub struct DepositLog { + pool: Pool, + read_only_pool: Pool, +} + +impl DepositLog { + /// Open a deposit log at the given path, creating schema/indexes if missing. + /// This does not load any rows into memory and only validates that the DB + /// is reachable and compatible with the expected schema. + pub async fn open(path: PathBuf) -> Result { + let pool = sqlite_pool(&path, SqlitePoolMode::ReadWrite)?; + let read_only_pool = sqlite_pool(&path, SqlitePoolMode::ReadOnly)?; + let log = Self { + pool, + read_only_pool, + }; + log.ensure_schema().await?; + Ok(log) + } + + /// Open the default per-node deposit log path under the system data directory. + /// This is the standard location used by the bridge runtime. + pub async fn open_default() -> Result { + Self::open(default_deposit_log_path()?).await + } + + async fn with_conn(&self, f: F) -> Result + where + F: FnOnce(&mut SqliteConnection) -> Result + Send + 'static, + T: Send + 'static, + { + let conn = self + .pool + .get() + .await + .map_err(|err| BridgeError::Runtime(format!("deposit log pool failed: {err}")))?; + let result = conn + .interact(move |conn| { + conn.batch_execute(&format!( + "PRAGMA busy_timeout = {};", + SQLITE_BUSY_TIMEOUT_MS + )) + .map_err(|err| BridgeError::Runtime(format!("deposit log pragma failed: {err}")))?; + f(conn) + }) + .await + .map_err(|err| BridgeError::Runtime(format!("deposit log interact failed: {err}")))?; + result + } + + /// Insert a deposit log entry if the tx_id is not already present. + /// Returns whether the insert happened, or if an existing row matches. + /// Conflicting tx_id rows are treated as fatal errors. + /// This is the only write path used by CDC and backfill. + pub async fn insert_entry( + &self, + entry: &DepositLogEntry, + ) -> Result { + let row = NewDepositLogRow::try_from_entry(entry)?; + let tx_id_hex = hex::encode(&row.tx_id); + let inserted = self + .with_conn(move |conn| { + diesel::insert_into(deposit_log::table) + .values(&row) + .on_conflict_do_nothing() + .execute(conn) + .map_err(|err| { + BridgeError::Runtime(format!("deposit log insert failed: {err}")) + }) + }) + .await?; + if inserted > 0 { + return Ok(DepositLogInsertOutcome::Inserted); + } + + let existing = self.fetch_entry(&entry.tx_id).await?; + let Some(existing) = existing else { + return Err(BridgeError::Runtime( + "deposit log insert conflict without existing row".into(), + )); + }; + + if &existing == entry { + warn!( + tx_id_bytes = %tx_id_hex, + block_height = entry.block_height, + "deposit log already has tx_id, skipping duplicate" + ); + return Ok(DepositLogInsertOutcome::ExistingMatch); + } + + Err(BridgeError::InvalidDepositLogEntry(format!( + "deposit log tx_id conflict for {tx_id_hex}: existing={existing:?} incoming={entry:?}" + ))) + } + + pub async fn compute_nonce( + &self, + entry: &DepositLogEntry, + epoch: &NonceEpochConfig, + ) -> Result, BridgeError> { + // Only compute nonces for entries at/after the epoch start key. + if epoch.is_before_start_key(entry.block_height, &entry.tx_id) { + return Ok(None); + } + // Count how many deposits in the epoch sort order come before this entry. + let height_i64 = i64::try_from(entry.block_height).map_err(|err| { + BridgeError::ValueConversion(format!("block_height too large for sqlite: {err}")) + })?; + let epoch_start = i64::try_from(epoch.start_height).map_err(|err| { + BridgeError::ValueConversion(format!( + "nonce_epoch_start_height too large for sqlite: {err}" + )) + })?; + let tx_id_bytes = entry.tx_id.to_be_limb_bytes().to_vec(); + let start_tx_id_bytes = epoch + .start_tx_id + .as_ref() + .map(Tip5Hash::to_be_limb_bytes) + .map(Vec::from); + let count: i64 = self + .with_conn(move |conn| { + use crate::schema::deposit_log::dsl::{ + block_height, deposit_log as deposit_log_table, tx_id, + }; + // Apply epoch start key filtering, then count rows strictly before this entry. + let mut query = deposit_log_table.into_boxed(); + query = if let Some(start_tx_id_bytes) = start_tx_id_bytes { + // Inclusive start key in lexicographic (block_height, tx_id) order: + // (block_height, tx_id) >= (epoch_start, start_tx_id). + query.filter( + block_height.gt(epoch_start).or(block_height + .eq(epoch_start) + .and(tx_id.ge(start_tx_id_bytes))), + ) + } else { + query.filter(block_height.ge(epoch_start)) + }; + query + .filter( + block_height + .lt(height_i64) + .or(block_height.eq(height_i64).and(tx_id.lt(tx_id_bytes))), + ) + .count() + .get_result(conn) + .map_err(|err| BridgeError::Runtime(format!("deposit log count failed: {err}"))) + }) + .await?; + let index = u64::try_from(count).map_err(|err| { + BridgeError::ValueConversion(format!("deposit log count overflow: {err}")) + })?; + // Nonce is epoch base + index (or base+1 if no start tx-id is defined). + Ok(Some(epoch.first_epoch_nonce().saturating_add(index))) + } + + /// Count the number of deposits at/after the epoch start key. + /// This is used to validate local history against the chain nonce prefix. + pub async fn number_of_deposits_in_epoch( + &self, + epoch: &NonceEpochConfig, + ) -> Result { + let epoch_start = i64::try_from(epoch.start_height).map_err(|err| { + BridgeError::ValueConversion(format!( + "nonce_epoch_start_height too large for sqlite: {err}" + )) + })?; + let start_tx_id_bytes = epoch + .start_tx_id + .as_ref() + .map(Tip5Hash::to_be_limb_bytes) + .map(Vec::from); + let count: i64 = self + .with_conn(move |conn| { + use crate::schema::deposit_log::dsl::{ + block_height, deposit_log as deposit_log_table, + }; + let mut query = deposit_log_table.into_boxed(); + if let Some(start_tx_id_bytes) = start_tx_id_bytes { + use crate::schema::deposit_log::dsl::tx_id; + query = query.filter( + block_height.gt(epoch_start).or(block_height + .eq(epoch_start) + .and(tx_id.ge(start_tx_id_bytes))), + ); + } else { + query = query.filter(block_height.ge(epoch_start)); + } + query + .count() + .get_result(conn) + .map_err(|err| BridgeError::Runtime(format!("deposit log count failed: {err}"))) + }) + .await?; + u64::try_from(count).map_err(|err| { + BridgeError::ValueConversion(format!("deposit log count overflow: {err}")) + }) + } + + /// Fetch a single entry by nonce, if it exists in the epoch window. + /// Returns None if the nonce is before the epoch or beyond the log length. + pub async fn get_by_nonce( + &self, + nonce: u64, + epoch: &NonceEpochConfig, + ) -> Result, BridgeError> { + let mut rows = self.records_from_nonce(nonce, 1, epoch).await?; + Ok(rows.pop().map(|(_, entry)| entry)) + } + + /// Fetch a contiguous range of entries starting at `start_nonce`, in nonce order. + /// The output includes the nonce alongside each entry for display and signing. + pub async fn records_from_nonce( + &self, + start_nonce: u64, + limit: usize, + epoch: &NonceEpochConfig, + ) -> Result, BridgeError> { + if limit == 0 { + return Ok(Vec::new()); + } + let first_epoch_nonce = epoch.first_epoch_nonce(); + if start_nonce < first_epoch_nonce { + return Ok(Vec::new()); + } + let start_index = start_nonce - first_epoch_nonce; + let Ok(start_index) = i64::try_from(start_index) else { + return Ok(Vec::new()); + }; + + // Query rows in canonical order (block_height, tx_id), then offset into the epoch. + let epoch_start = i64::try_from(epoch.start_height).map_err(|err| { + BridgeError::ValueConversion(format!( + "nonce_epoch_start_height too large for sqlite: {err}" + )) + })?; + + let limit_i64 = i64::try_from(limit).unwrap_or(i64::MAX); + let start_tx_id_bytes = epoch + .start_tx_id + .as_ref() + .map(Tip5Hash::to_be_limb_bytes) + .map(Vec::from); + let rows: Vec = self + .with_conn(move |conn| { + use crate::schema::deposit_log::dsl::{ + block_height, deposit_log as deposit_log_table, tx_id, + }; + let mut query = deposit_log_table.into_boxed(); + query = if let Some(start_tx_id_bytes) = start_tx_id_bytes { + query.filter( + block_height.gt(epoch_start).or(block_height + .eq(epoch_start) + .and(tx_id.ge(start_tx_id_bytes))), + ) + } else { + query.filter(block_height.ge(epoch_start)) + }; + query + .order((block_height.asc(), tx_id.asc())) + .offset(start_index) + .limit(limit_i64) + .load(conn) + .map_err(|err| BridgeError::Runtime(format!("deposit log range failed: {err}"))) + }) + .await?; + + let mut out = Vec::with_capacity(rows.len()); + for (offset, row) in rows.into_iter().enumerate() { + let nonce = start_nonce.saturating_add(offset as u64); + out.push((nonce, DepositLogEntry::try_from_row(row)?)); + } + Ok(out) + } + + pub async fn snapshot( + &self, + nonce_epoch: &NonceEpochConfig, + view: DepositLogView, + ) -> Result { + let conn = self + .read_only_pool + .get() + .await + .map_err(|err| BridgeError::Runtime(format!("deposit log pool failed: {err}")))?; + let nonce_epoch = nonce_epoch.clone(); + conn.interact(move |conn| { + conn.batch_execute(&format!( + "PRAGMA busy_timeout = {};", + SQLITE_BUSY_TIMEOUT_MS + )) + .map_err(|err| { + BridgeError::Runtime(format!( + "failed to configure read-only deposit log connection: {err}" + )) + })?; + fetch_deposit_log_snapshot(conn, &nonce_epoch, view) + }) + .await + .map_err(|err| BridgeError::Runtime(format!("deposit log interact failed: {err}")))? + } + + /// Return the maximum block height present at/after the epoch start key. + /// Used for incremental backfill scans. + pub async fn max_block_height( + &self, + epoch: &NonceEpochConfig, + ) -> Result, BridgeError> { + let epoch_start = i64::try_from(epoch.start_height).map_err(|err| { + BridgeError::ValueConversion(format!( + "nonce_epoch_start_height too large for sqlite: {err}" + )) + })?; + let start_tx_id_bytes = epoch + .start_tx_id + .as_ref() + .map(Tip5Hash::to_be_limb_bytes) + .map(Vec::from); + let max: Option = self + .with_conn(move |conn| { + use crate::schema::deposit_log::dsl::{ + block_height, deposit_log as deposit_log_table, + }; + let mut query = deposit_log_table.into_boxed(); + if let Some(start_tx_id_bytes) = start_tx_id_bytes { + use crate::schema::deposit_log::dsl::tx_id; + query = query.filter( + block_height.gt(epoch_start).or(block_height + .eq(epoch_start) + .and(tx_id.ge(start_tx_id_bytes))), + ); + } else { + query = query.filter(block_height.ge(epoch_start)); + } + query + .select(block_height) + .order(block_height.desc()) + .first(conn) + .optional() + .map_err(|err| BridgeError::Runtime(format!("deposit log max failed: {err}"))) + }) + .await?; + let Some(max) = max else { + return Ok(None); + }; + let max_u64 = u64::try_from(max).map_err(|err| { + BridgeError::ValueConversion(format!("deposit log max overflow: {err}")) + })?; + Ok(Some(max_u64)) + } + + /// Fetch a single entry by tx_id, if present. + /// This is used to resolve insert conflicts and to probe for the epoch start tx-id. + async fn fetch_entry(&self, tx_id: &Tip5Hash) -> Result, BridgeError> { + let tx_id_bytes = tx_id.to_be_limb_bytes().to_vec(); + let row = self + .with_conn(move |conn| { + use crate::schema::deposit_log::dsl::{ + deposit_log as deposit_log_table, tx_id as tx_id_col, + }; + deposit_log_table + .filter(tx_id_col.eq(tx_id_bytes)) + .first::(conn) + .optional() + .map_err(|err| BridgeError::Runtime(format!("deposit log fetch failed: {err}"))) + }) + .await?; + row.map(DepositLogEntry::try_from_row).transpose() + } + + /// Fetch a single entry by deposit_id (as_of + name), if present. + async fn fetch_entry_by_deposit_id( + &self, + deposit_id: &DepositId, + ) -> Result, BridgeError> { + let as_of_bytes = deposit_id.as_of.to_be_limb_bytes().to_vec(); + let name_first_bytes = deposit_id.name.first.to_be_limb_bytes().to_vec(); + let name_last_bytes = deposit_id.name.last.to_be_limb_bytes().to_vec(); + let row = self + .with_conn(move |conn| { + use crate::schema::deposit_log::dsl::{ + as_of, deposit_log as deposit_log_table, name_first, name_last, + }; + deposit_log_table + .filter(as_of.eq(as_of_bytes)) + .filter(name_first.eq(name_first_bytes)) + .filter(name_last.eq(name_last_bytes)) + .first::(conn) + .optional() + .map_err(|err| { + BridgeError::Runtime(format!( + "deposit log fetch by deposit_id failed: {err}" + )) + }) + }) + .await?; + row.map(DepositLogEntry::try_from_row).transpose() + } + + /// Check whether the log contains a given tx_id. + /// This is a lightweight wrapper used by backfill logic. + pub async fn contains_tx_id(&self, tx_id: &Tip5Hash) -> Result { + Ok(self.fetch_entry(tx_id).await?.is_some()) + } + + /// Check whether the log contains a given deposit_id. + pub async fn contains_deposit_id(&self, deposit_id: &DepositId) -> Result { + Ok(self.fetch_entry_by_deposit_id(deposit_id).await?.is_some()) + } + + /// Ensure the SQLite schema exists and is configured with WAL + index. + /// This runs on open and is idempotent. + async fn ensure_schema(&self) -> Result<(), BridgeError> { + self.with_conn(|conn| { + conn.batch_execute( + r#" + PRAGMA journal_mode=WAL; + PRAGMA synchronous=FULL; + + CREATE TABLE IF NOT EXISTS deposit_log ( + tx_id BLOB PRIMARY KEY NOT NULL CHECK(length(tx_id) = 40), + block_height INTEGER NOT NULL, + as_of BLOB NOT NULL CHECK(length(as_of) = 40), + name_first BLOB NOT NULL CHECK(length(name_first) = 40), + name_last BLOB NOT NULL CHECK(length(name_last) = 40), + recipient BLOB NOT NULL CHECK(length(recipient) = 20), + amount_to_mint INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS deposit_log_by_height ON deposit_log(block_height, tx_id); + "#, + ) + .map_err(|err| BridgeError::Runtime(format!("deposit log schema failed: {err}")))?; + Ok(()) + }) + .await + } +} + +impl DepositLogEntry { + /// Convert a DB row into a strongly-typed deposit log entry. + /// Validates byte lengths and integer bounds. + fn try_from_row(row: DepositLogRow) -> Result { + let recipient: [u8; 20] = row + .recipient + .as_slice() + .try_into() + .map_err(|_| BridgeError::Runtime("invalid recipient length".into()))?; + let block_height = u64::try_from(row.block_height).map_err(|err| { + BridgeError::ValueConversion(format!("deposit log block_height overflow: {err}")) + })?; + let amount_to_mint = u64::try_from(row.amount_to_mint).map_err(|err| { + BridgeError::ValueConversion(format!("deposit log amount overflow: {err}")) + })?; + Ok(Self { + block_height, + tx_id: Tip5Hash::from_be_limb_bytes(&row.tx_id) + .map_err(|e| BridgeError::Runtime(format!("invalid tx_id bytes: {e}")))?, + as_of: Tip5Hash::from_be_limb_bytes(&row.as_of) + .map_err(|e| BridgeError::Runtime(format!("invalid as_of bytes: {e}")))?, + name: Name::new( + Tip5Hash::from_be_limb_bytes(&row.name_first) + .map_err(|e| BridgeError::Runtime(format!("invalid name_first bytes: {e}")))?, + Tip5Hash::from_be_limb_bytes(&row.name_last) + .map_err(|e| BridgeError::Runtime(format!("invalid name_last bytes: {e}")))?, + ), + recipient: EthAddress(recipient), + amount_to_mint, + }) + } +} + +impl NewDepositLogRow { + /// Convert an in-memory entry into an insertable DB row. + /// Performs integer range checks for SQLite. + fn try_from_entry(entry: &DepositLogEntry) -> Result { + let block_height = i64::try_from(entry.block_height).map_err(|err| { + BridgeError::ValueConversion(format!("block_height too large for sqlite: {err}")) + })?; + let amount_to_mint = i64::try_from(entry.amount_to_mint).map_err(|err| { + BridgeError::ValueConversion(format!("amount_to_mint too large for sqlite: {err}")) + })?; + Ok(Self { + tx_id: entry.tx_id.to_be_limb_bytes().to_vec(), + block_height, + as_of: entry.as_of.to_be_limb_bytes().to_vec(), + name_first: entry.name.first.to_be_limb_bytes().to_vec(), + name_last: entry.name.last.to_be_limb_bytes().to_vec(), + recipient: entry.recipient.0.to_vec(), + amount_to_mint, + }) + } +} + +/// Compute the default on-disk location of the per-node deposit log database. +fn default_deposit_log_path() -> Result { + Ok(nockapp::system_data_dir() + .join("bridge") + .join("deposit-queue.sqlite")) +} + +const SQLITE_BUSY_TIMEOUT_MS: u64 = 2_000; + +#[derive(Clone, Copy, Debug)] +enum SqlitePoolMode { + ReadOnly, + ReadWrite, +} + +fn sqlite_pool(path: &Path, mode: SqlitePoolMode) -> Result { + let path_str = path.to_string_lossy(); + let url = match mode { + SqlitePoolMode::ReadOnly => format!("file:{path_str}?mode=ro"), + SqlitePoolMode::ReadWrite => path_str.to_string(), + }; + let manager = Manager::new(url, Runtime::Tokio1); + Pool::builder(manager) + .build() + .map_err(|err| BridgeError::Runtime(format!("deposit log pool build failed: {err}"))) +} + +pub async fn sync_deposit_log_from_hashchain( + runtime: Arc, + deposit_log: Arc, + nonce_epoch: &NonceEpochConfig, + initial_delay: Duration, +) -> Result { + // Allow the kernel a moment to boot before peeking (used during startup). + if !initial_delay.is_zero() { + tokio::time::sleep(initial_delay).await; + } + + let max_height = deposit_log.max_block_height(nonce_epoch).await?; + let start_tx_present = if let Some(start_tx_id) = nonce_epoch.start_tx_id.as_ref() { + deposit_log.contains_tx_id(start_tx_id).await? + } else { + true + }; + + // Determine the latest nockchain height that contained a deposit. + // This peek is expected to always return a value (0 when no deposits exist yet). + // If it returns None, treat it as a runtime contract violation. + let last_deposit_height = runtime + .peek_nock_last_deposit_height() + .await? + .ok_or_else(|| BridgeError::Runtime("Failed to peek last deposit height".to_string()))? + .max(nonce_epoch.start_height); + + // Determine the first block height to scan next (incremental sync). + // - If the log is empty, scan from the epoch start height (bootstrap case). + // - If the log has advanced past the epoch start but the start tx-id is missing, + // the log is inconsistent and we hard-stop. + // - Otherwise, scan from the smaller of the chain tip and the current log max. + let scan_start_height = if let Some(max_height) = max_height { + if max_height >= nonce_epoch.start_height && !start_tx_present { + return Err(BridgeError::InvalidDepositLogBase( + "Epoch base starting tx-id not present in deposit log, even though deposit log has advanced past epoch start height".to_string(), + )); + } else { + std::cmp::min(last_deposit_height, max_height) + } + } else { + nonce_epoch.start_height + }; + + let all = runtime + .peek_nock_hashchain_deposits_since_height(scan_start_height) + .await?; + + let records: Vec = all + .into_iter() + .filter(|r| !nonce_epoch.is_before_start_key(r.block_height, &r.tx_id)) + .map(|req| DepositLogEntry { + block_height: req.block_height, + tx_id: req.tx_id, + name: req.name, + recipient: req.recipient, + amount_to_mint: req.amount, + as_of: req.as_of, + }) + .collect(); + + let mut inserted = 0u64; + for record in &records { + if matches!( + deposit_log.insert_entry(record).await?, + DepositLogInsertOutcome::Inserted + ) { + inserted += 1; + } + } + + info!( + target: "bridge.deposit_log", + inserted, + scan_start_height, + last_deposit_height, + nonce_epoch_start_height = nonce_epoch.start_height, + "deposit log sync from nock hashchain complete" + ); + + Ok(inserted) +} + +pub async fn validate_deposit_log_against_chain_nonce_prefix( + base_bridge: Arc, + deposit_log: Arc, + nonce_epoch: NonceEpochConfig, +) -> Result<(), BridgeError> { + let last_chain_nonce = base_bridge.get_last_deposit_nonce().await?; + check_deposit_log_against_chain_nonce_prefix_with_last_nonce( + last_chain_nonce, deposit_log, nonce_epoch, + ) + .await +} + +// This should be called after syncing/backfilling the deposit log so the local +// history is complete before comparing against on-chain nonce state. +async fn check_deposit_log_against_chain_nonce_prefix_with_last_nonce( + last_chain_nonce: u64, + deposit_log: Arc, + nonce_epoch: NonceEpochConfig, +) -> Result<(), BridgeError> { + let first_epoch_nonce = nonce_epoch.first_epoch_nonce(); + + let nonces_spent_this_epoch = compute_spent_epoch_nonces(last_chain_nonce, first_epoch_nonce); + let next_nonce = last_chain_nonce + 1; + + // Compute log_len for progress logging. Do not fail when the log is behind. + let snapshot = build_log_validation_snapshot(&deposit_log, &nonce_epoch, next_nonce).await?; + + // Log behind is expected when the kernel lags; the log will catch up. + if snapshot.log_len < nonces_spent_this_epoch { + warn!( + target: "bridge.deposit_log", + log_len = snapshot.log_len, + nonces_spent_this_epoch, + "deposit log behind chain prefix, waiting for log to catch up" + ); + } + + if let Some(next_rec) = snapshot.next_entry { + warn!( + target: "bridge.deposit_log", + next_nonce, + tx_id=%hex::encode(next_rec.tx_id.to_be_limb_bytes()), + block_height=next_rec.block_height, + "next epoch nonce already exists in local deposit log (waiting for signatures)" + ); + } else { + info!( + target: "bridge.deposit_log", + next_nonce, + nonces_spent_this_epoch, + log_len = snapshot.log_len, + "no deposit yet for next epoch nonce in local log" + ); + } + + info!( + target: "bridge.deposit_log", + nonce_epoch_base = nonce_epoch.base, + last_chain_nonce, + nonces_spent_this_epoch, + log_len = snapshot.log_len, + "deposit log nonce validation complete" + ); + + Ok(()) +} + +fn compute_spent_epoch_nonces(last_chain_nonce: u64, first_epoch_nonce: u64) -> u64 { + if last_chain_nonce < first_epoch_nonce { + 0 + } else { + last_chain_nonce - first_epoch_nonce + 1 + } +} + +// Immutable view of the log state needed for validation without holding the mutex. +#[derive(Debug, Clone)] +struct LogValidationSnapshot { + log_len: u64, + next_entry: Option, +} + +async fn build_log_validation_snapshot( + log: &DepositLog, + nonce_epoch: &NonceEpochConfig, + next_nonce: u64, +) -> Result { + let log_len = log.number_of_deposits_in_epoch(nonce_epoch).await?; + let next_entry = log.get_by_nonce(next_nonce, nonce_epoch).await?; + + Ok(LogValidationSnapshot { + log_len, + next_entry, + }) +} + +pub async fn persist_commit_nock_deposits_requests( + mut requests: Vec, + deposit_log: &DepositLog, + nonce_epoch: &NonceEpochConfig, +) -> Result { + requests.retain(|req| { + if nonce_epoch.is_before_start_key(req.block_height, &req.tx_id) { + tracing::warn!( + target: "bridge.propose", + block_height=req.block_height, + nonce_epoch_start_height = nonce_epoch.start_height, + "deposit block is before nonce epoch start key, skipping CDC insert" + ); + return false; + } + true + }); + requests.sort_by(|a, b| { + let height_cmp = a.block_height.cmp(&b.block_height); + if height_cmp != std::cmp::Ordering::Equal { + return height_cmp; + } + a.tx_id.to_be_limb_bytes().cmp(&b.tx_id.to_be_limb_bytes()) + }); + + let mut inserted = 0u64; + for req in requests { + let entry = DepositLogEntry { + block_height: req.block_height, + tx_id: req.tx_id, + name: req.name, + recipient: req.recipient, + amount_to_mint: req.amount, + as_of: req.as_of, + }; + if matches!( + deposit_log.insert_entry(&entry).await?, + DepositLogInsertOutcome::Inserted + ) { + inserted += 1; + } + } + + Ok(inserted) +} + +/// CDC driver for commit-nock-deposits effects. +/// Persists effect payloads to the deposit log, no signing or gossip. +pub fn create_commit_nock_deposits_driver( + runtime: Arc, + stop_controller: crate::stop::StopController, + bridge_status: BridgeStatus, + tui_status: Option, + stop: crate::stop::StopHandle, + deposit_log: Arc, + nonce_epoch: NonceEpochConfig, +) -> nockapp::driver::IODriverFn { + use nockapp::driver::{make_driver, NockAppHandle}; + use noun_serde::NounDecode; + use tracing::{error, info, warn}; + + use crate::stop::trigger_local_stop; + use crate::types::{BridgeEffect, BridgeEffectVariant}; + + make_driver(move |handle: NockAppHandle| { + let runtime = runtime.clone(); + let stop_controller = stop_controller.clone(); + let bridge_status = bridge_status.clone(); + let tui_status = tui_status.clone(); + let stop = stop.clone(); + let deposit_log = deposit_log.clone(); + let nonce_epoch = nonce_epoch; + + async move { + loop { + if stop.is_stopped() { + tokio::time::sleep(Duration::from_secs(1)).await; + continue; + } + let effect = match handle.next_effect().await { + Ok(effect) => effect, + Err(_) => continue, + }; + + let root = unsafe { effect.root() }; + let bridge_effect = match BridgeEffect::from_noun(root) { + Ok(effect) => effect, + Err(err) => { + warn!("Failed to decode effect: {}", err); + continue; + } + }; + + if let BridgeEffectVariant::CommitNockDeposits(requests) = bridge_effect.variant { + info!( + target: "bridge.propose", + request_count=requests.len(), + "processing commit-nock-deposits effect (CDC mode)" + ); + let inserted = match persist_commit_nock_deposits_requests( + requests, + deposit_log.as_ref(), + &nonce_epoch, + ) + .await + { + Ok(inserted) => inserted, + Err(err) => { + error!( + target: "bridge.propose", + error=%err, + "deposit log persistence failed, triggering local stop" + ); + let reason = format!( + "deposit log conflict while persisting commit-nock-deposits: {err}" + ); + trigger_local_stop( + runtime.clone(), + stop_controller.clone(), + bridge_status.clone(), + reason, + ) + .await; + continue; + } + }; + info!( + target: "bridge.propose", + inserted, + "persisted commit-nock-deposits entries to deposit log" + ); + if inserted > 0 { + if let Some(ref status) = tui_status { + status.notify_deposit_log_refresh(); + } + } + } + } + } + }) +} + +pub async fn run_deposit_log_tui_poller( + deposit_log_path: PathBuf, + nonce_epoch: NonceEpochConfig, + bridge_status: TuiStatus, + stop: StopHandle, +) { + use tokio::time::{interval, MissedTickBehavior}; + use tracing::warn; + + const POLL_INTERVAL: Duration = Duration::from_secs(30); + const REFRESH_INTERVAL: Duration = Duration::from_secs(10); + + let pool = match sqlite_pool(&deposit_log_path, SqlitePoolMode::ReadOnly) { + Ok(pool) => pool, + Err(err) => { + warn!( + target: "bridge.tui.deposit_log", + error=%err, + "failed to create read-only deposit log pool" + ); + return; + } + }; + + let mut ticker = interval(POLL_INTERVAL); + ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); + let notifier = bridge_status.deposit_log_notifier(); + let mut last_refresh = std::time::Instant::now() + .checked_sub(REFRESH_INTERVAL) + .unwrap_or_else(std::time::Instant::now); + let mut last_view = DepositLogView::default(); + + loop { + let mut force_refresh = false; + tokio::select! { + _ = ticker.tick() => {} + _ = notifier.notified() => { + force_refresh = true; + } + } + if stop.is_stopped() { + continue; + } + if !bridge_status.deposit_log_active() { + continue; + } + + let view = bridge_status.deposit_log_view(); + if view.limit == 0 { + continue; + } + let view_changed = view != last_view; + if !force_refresh && !view_changed && last_refresh.elapsed() < REFRESH_INTERVAL { + continue; + } + + let conn = match pool.get().await { + Ok(conn) => conn, + Err(err) => { + warn!( + target: "bridge.tui.deposit_log", + error=%err, + "failed to fetch deposit log connection from pool" + ); + continue; + } + }; + + let nonce_epoch = nonce_epoch.clone(); + let snapshot = match conn + .interact(move |conn| { + conn.batch_execute(&format!( + "PRAGMA query_only = ON; PRAGMA busy_timeout = {};", + SQLITE_BUSY_TIMEOUT_MS + )) + .map_err(|err| { + BridgeError::Runtime(format!( + "failed to configure read-only deposit log connection: {err}" + )) + })?; + fetch_deposit_log_snapshot(conn, &nonce_epoch, view) + }) + .await + { + Ok(Ok(snapshot)) => snapshot, + Ok(Err(err)) => { + warn!( + target: "bridge.tui.deposit_log", + error=%err, + "failed to load deposit log snapshot" + ); + continue; + } + Err(err) => { + warn!( + target: "bridge.tui.deposit_log", + error=%err, + "failed to run deposit log snapshot query" + ); + continue; + } + }; + + bridge_status.update_deposit_log_snapshot(snapshot); + last_refresh = std::time::Instant::now(); + last_view = view; + } +} + +pub(crate) fn fetch_deposit_log_snapshot( + conn: &mut SqliteConnection, + nonce_epoch: &NonceEpochConfig, + view: DepositLogView, +) -> Result { + let started = Instant::now(); + let metrics = metrics::init_metrics(); + metrics + .tui_deposit_log_limit_requested + .swap(view.limit as f64); + metrics + .tui_deposit_log_offset_requested + .swap(view.offset as f64); + if view.limit > DEPOSIT_LOG_PAGE_SIZE { + metrics.tui_deposit_log_limit_over_cache.increment(); + } + if view.limit > 10_000 { + metrics.tui_deposit_log_limit_over_10000.increment(); + } + use crate::schema::deposit_log::dsl::{ + amount_to_mint, block_height, deposit_log as deposit_log_table, recipient, tx_id, + }; + + let epoch_start = i64::try_from(nonce_epoch.start_height).map_err(|err| { + BridgeError::ValueConversion(format!( + "nonce_epoch_start_height too large for sqlite: {err}" + )) + })?; + + let start_tx_id_bytes = nonce_epoch + .start_tx_id + .as_ref() + .map(Tip5Hash::to_be_limb_bytes) + .map(Vec::from); + + let mut count_query = deposit_log_table.into_boxed(); + if let Some(start_tx_id_bytes) = start_tx_id_bytes.as_ref() { + let start_tx_id_hex = hex::encode(start_tx_id_bytes); + let start_filter = diesel::dsl::sql::(&format!( + "(block_height, tx_id) >= ({epoch_start}, X'{start_tx_id_hex}')" + )); + count_query = count_query.filter(start_filter); + } else { + count_query = count_query.filter(block_height.ge(epoch_start)); + } + + let count_started = Instant::now(); + let total_count: i64 = count_query.count().get_result(conn).map_err(|err| { + metrics + .deposit_log_count_time + .add_timing(&count_started.elapsed()); + BridgeError::Runtime(format!("deposit log count failed: {err}")) + })?; + metrics + .deposit_log_count_time + .add_timing(&count_started.elapsed()); + let total_count_u64 = u64::try_from(total_count).map_err(|err| { + BridgeError::ValueConversion(format!("deposit log count overflow: {err}")) + })?; + let first_epoch_nonce = nonce_epoch.first_epoch_nonce(); + + let result = if total_count_u64 == 0 { + metrics.tui_deposit_log_rows_returned.swap(0.0); + Ok(DepositLogSnapshot { + total_count: 0, + first_epoch_nonce, + rows: Vec::new(), + }) + } else { + let mut rows_query = deposit_log_table.into_boxed(); + if let Some(start_tx_id_bytes) = start_tx_id_bytes.as_ref() { + let start_tx_id_hex = hex::encode(start_tx_id_bytes); + let start_filter = diesel::dsl::sql::(&format!( + "(block_height, tx_id) >= ({epoch_start}, X'{start_tx_id_hex}')" + )); + rows_query = rows_query.filter(start_filter); + } else { + rows_query = rows_query.filter(block_height.ge(epoch_start)); + } + + let limit_i64 = i64::try_from(view.limit).unwrap_or(i64::MAX); + let offset_i64 = i64::try_from(view.offset).unwrap_or(i64::MAX); + let page_started = Instant::now(); + let rows: Vec<(Vec, i64, Vec, i64)> = rows_query + .select((tx_id, block_height, recipient, amount_to_mint)) + .order((block_height.desc(), tx_id.desc())) + .offset(offset_i64) + .limit(limit_i64) + .load(conn) + .map_err(|err| { + metrics + .deposit_log_page_time + .add_timing(&page_started.elapsed()); + BridgeError::Runtime(format!("deposit log query failed: {err}")) + })?; + metrics + .deposit_log_page_time + .add_timing(&page_started.elapsed()); + metrics + .tui_deposit_log_rows_returned + .swap(rows.len() as f64); + + let mut out = Vec::with_capacity(rows.len()); + for (idx, (tx_id_bytes, height, recipient_bytes, amount)) in rows.into_iter().enumerate() { + let block_height_u64 = u64::try_from(height).map_err(|err| { + BridgeError::ValueConversion(format!("deposit log block_height overflow: {err}")) + })?; + let amount_u64 = u64::try_from(amount).map_err(|err| { + BridgeError::ValueConversion(format!("deposit log amount overflow: {err}")) + })?; + let offset_u64 = u64::try_from(view.offset).unwrap_or(u64::MAX); + let descending_index = offset_u64.saturating_add(idx as u64); + let ascending_index = total_count_u64 + .saturating_sub(1) + .saturating_sub(descending_index); + let nonce = first_epoch_nonce.saturating_add(ascending_index); + + let tx_hash = Tip5Hash::from_be_limb_bytes(&tx_id_bytes) + .map_err(|err| BridgeError::Runtime(format!("invalid tx_id bytes: {err}")))?; + out.push(crate::tui::types::DepositLogRow { + nonce, + block_height: block_height_u64, + tx_id_base58: tx_hash.to_base58(), + recipient_hex: format!("0x{}", hex::encode(recipient_bytes)), + amount: amount_u64, + }); + } + + Ok(DepositLogSnapshot { + total_count: total_count_u64, + first_epoch_nonce, + rows: out, + }) + }; + + metrics + .deposit_log_snapshot_time + .add_timing(&started.elapsed()); + + result +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use nockchain_math::belt::Belt; + use tempfile::TempDir; + + use super::*; + + fn tip5(a: u64, b: u64, c: u64, d: u64, e: u64) -> Tip5Hash { + // Helper for creating deterministic Tip5 hashes in tests. + Tip5Hash([Belt(a), Belt(b), Belt(c), Belt(d), Belt(e)]) + } + + fn addr(byte: u8) -> EthAddress { + // Helper for creating deterministic 20-byte addresses in tests. + EthAddress([byte; 20]) + } + + #[tokio::test] + async fn insert_is_idempotent_by_tx_id() { + // Ensure duplicate inserts on tx_id are treated as no-ops. + let dir = TempDir::new().unwrap(); + let path = dir.path().join("deposit-log.sqlite"); + let log = DepositLog::open(path).await.unwrap(); + let epoch = NonceEpochConfig { + base: 100, + start_height: 10, + start_tx_id: None, + }; + let entry = DepositLogEntry { + block_height: 10, + tx_id: tip5(1, 2, 3, 4, 5), + as_of: tip5(9, 9, 9, 9, 9), + name: Name::new(tip5(10, 0, 0, 0, 0), tip5(11, 0, 0, 0, 0)), + recipient: addr(0x11), + amount_to_mint: 123, + }; + + let out1 = log.insert_entry(&entry).await.unwrap(); + let out2 = log.insert_entry(&entry).await.unwrap(); + assert_eq!(out1, DepositLogInsertOutcome::Inserted); + assert_eq!(out2, DepositLogInsertOutcome::ExistingMatch); + assert_eq!(log.compute_nonce(&entry, &epoch).await.unwrap(), Some(101)); + } + + #[tokio::test] + async fn insert_mismatch_is_fatal() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("deposit-log.sqlite"); + let log = DepositLog::open(path).await.unwrap(); + let entry = DepositLogEntry { + block_height: 10, + tx_id: tip5(1, 2, 3, 4, 5), + as_of: tip5(9, 9, 9, 9, 9), + name: Name::new(tip5(10, 0, 0, 0, 0), tip5(11, 0, 0, 0, 0)), + recipient: addr(0x11), + amount_to_mint: 123, + }; + + log.insert_entry(&entry).await.unwrap(); + + let mut conflicting = entry.clone(); + conflicting.amount_to_mint = 999; + + let err = log.insert_entry(&conflicting).await.unwrap_err(); + assert!(matches!(err, BridgeError::InvalidDepositLogEntry(_))); + } + + #[tokio::test] + async fn compute_nonce_orders_by_height_then_tx_id() { + // Verify nonce ordering matches (block_height, tx_id) ascending. + let dir = TempDir::new().unwrap(); + let path = dir.path().join("deposit-log.sqlite"); + let log = DepositLog::open(path).await.unwrap(); + let epoch = NonceEpochConfig { + base: 100, + start_height: 10, + start_tx_id: None, + }; + + let entry_a = DepositLogEntry { + block_height: 10, + tx_id: tip5(1, 0, 0, 0, 0), + as_of: tip5(9, 9, 9, 9, 9), + name: Name::new(tip5(10, 0, 0, 0, 0), tip5(11, 0, 0, 0, 0)), + recipient: addr(0x11), + amount_to_mint: 1, + }; + let entry_b = DepositLogEntry { + block_height: 11, + tx_id: tip5(1, 0, 0, 0, 1), + as_of: tip5(8, 8, 8, 8, 8), + name: Name::new(tip5(12, 0, 0, 0, 0), tip5(13, 0, 0, 0, 0)), + recipient: addr(0x22), + amount_to_mint: 2, + }; + let entry_c = DepositLogEntry { + block_height: 11, + tx_id: tip5(2, 0, 0, 0, 0), + as_of: tip5(7, 7, 7, 7, 7), + name: Name::new(tip5(14, 0, 0, 0, 0), tip5(15, 0, 0, 0, 0)), + recipient: addr(0x33), + amount_to_mint: 3, + }; + + log.insert_entry(&entry_a).await.unwrap(); + log.insert_entry(&entry_b).await.unwrap(); + log.insert_entry(&entry_c).await.unwrap(); + + assert_eq!( + log.compute_nonce(&entry_a, &epoch).await.unwrap(), + Some(101) + ); + assert_eq!( + log.compute_nonce(&entry_b, &epoch).await.unwrap(), + Some(102) + ); + assert_eq!( + log.compute_nonce(&entry_c, &epoch).await.unwrap(), + Some(103) + ); + } + + #[tokio::test] + async fn anchor_nonce_mapping_and_records() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("deposit-log.sqlite"); + let log = DepositLog::open(path).await.unwrap(); + + let anchor_tx_id = tip5(2, 0, 0, 0, 0); + let entry_anchor = DepositLogEntry { + block_height: 10, + tx_id: anchor_tx_id.clone(), + as_of: tip5(9, 9, 9, 9, 9), + name: Name::new(tip5(10, 0, 0, 0, 0), tip5(11, 0, 0, 0, 0)), + recipient: addr(0x11), + amount_to_mint: 1, + }; + let entry_next = DepositLogEntry { + block_height: 10, + tx_id: tip5(3, 0, 0, 0, 0), + as_of: tip5(8, 8, 8, 8, 8), + name: Name::new(tip5(12, 0, 0, 0, 0), tip5(13, 0, 0, 0, 0)), + recipient: addr(0x22), + amount_to_mint: 2, + }; + + log.insert_entry(&entry_anchor).await.unwrap(); + log.insert_entry(&entry_next).await.unwrap(); + + let epoch = NonceEpochConfig { + base: 50, + start_height: 10, + start_tx_id: Some(anchor_tx_id), + }; + + assert_eq!( + log.compute_nonce(&entry_anchor, &epoch).await.unwrap(), + Some(50) + ); + assert_eq!( + log.compute_nonce(&entry_next, &epoch).await.unwrap(), + Some(51) + ); + + let by_base = log + .get_by_nonce(50, &epoch) + .await + .unwrap() + .expect("anchor exists"); + let by_next = log + .get_by_nonce(51, &epoch) + .await + .unwrap() + .expect("next exists"); + assert_eq!(by_base.tx_id, entry_anchor.tx_id); + assert_eq!(by_next.tx_id, entry_next.tx_id); + + let rows = log.records_from_nonce(50, 2, &epoch).await.unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].0, 50); + assert_eq!(rows[0].1.tx_id, entry_anchor.tx_id); + assert_eq!(rows[1].0, 51); + assert_eq!(rows[1].1.tx_id, entry_next.tx_id); + } + + #[tokio::test] + async fn start_key_filters_pre_epoch_entries() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("deposit-log.sqlite"); + let log = DepositLog::open(path).await.unwrap(); + + let pre_epoch = DepositLogEntry { + block_height: 10, + tx_id: tip5(1, 0, 0, 0, 0), + as_of: tip5(7, 7, 7, 7, 7), + name: Name::new(tip5(10, 0, 0, 0, 0), tip5(11, 0, 0, 0, 0)), + recipient: addr(0x10), + amount_to_mint: 1, + }; + let anchor_tx_id = tip5(2, 0, 0, 0, 0); + let anchor = DepositLogEntry { + block_height: 10, + tx_id: anchor_tx_id.clone(), + as_of: tip5(8, 8, 8, 8, 8), + name: Name::new(tip5(12, 0, 0, 0, 0), tip5(13, 0, 0, 0, 0)), + recipient: addr(0x11), + amount_to_mint: 2, + }; + let next = DepositLogEntry { + block_height: 11, + tx_id: tip5(3, 0, 0, 0, 0), + as_of: tip5(9, 9, 9, 9, 9), + name: Name::new(tip5(14, 0, 0, 0, 0), tip5(15, 0, 0, 0, 0)), + recipient: addr(0x12), + amount_to_mint: 3, + }; + + log.insert_entry(&pre_epoch).await.unwrap(); + log.insert_entry(&anchor).await.unwrap(); + log.insert_entry(&next).await.unwrap(); + + let epoch = NonceEpochConfig { + base: 50, + start_height: 10, + start_tx_id: Some(anchor_tx_id), + }; + + assert_eq!(log.number_of_deposits_in_epoch(&epoch).await.unwrap(), 2); + assert_eq!(log.max_block_height(&epoch).await.unwrap(), Some(11)); + + let rows = log.records_from_nonce(50, 10, &epoch).await.unwrap(); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].1.tx_id, anchor.tx_id); + assert_eq!(rows[1].1.tx_id, next.tx_id); + } + + #[tokio::test] + async fn records_from_nonce_below_epoch_returns_empty() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("deposit-log.sqlite"); + let log = DepositLog::open(path).await.unwrap(); + + let anchor_tx_id = tip5(2, 0, 0, 0, 0); + let anchor = DepositLogEntry { + block_height: 10, + tx_id: anchor_tx_id.clone(), + as_of: tip5(8, 8, 8, 8, 8), + name: Name::new(tip5(12, 0, 0, 0, 0), tip5(13, 0, 0, 0, 0)), + recipient: addr(0x11), + amount_to_mint: 2, + }; + log.insert_entry(&anchor).await.unwrap(); + + let epoch = NonceEpochConfig { + base: 50, + start_height: 10, + start_tx_id: Some(anchor_tx_id), + }; + + let rows = log.records_from_nonce(49, 2, &epoch).await.unwrap(); + assert!(rows.is_empty()); + } + + #[tokio::test] + async fn snapshot_applies_offset_and_limit() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("deposit-log.sqlite"); + let log = DepositLog::open(path).await.unwrap(); + let epoch = NonceEpochConfig { + base: 100, + start_height: 10, + start_tx_id: None, + }; + + let entry_a = DepositLogEntry { + block_height: 10, + tx_id: tip5(1, 0, 0, 0, 0), + as_of: tip5(9, 9, 9, 9, 9), + name: Name::new(tip5(10, 0, 0, 0, 0), tip5(11, 0, 0, 0, 0)), + recipient: addr(0x11), + amount_to_mint: 1, + }; + let entry_b = DepositLogEntry { + block_height: 11, + tx_id: tip5(2, 0, 0, 0, 0), + as_of: tip5(8, 8, 8, 8, 8), + name: Name::new(tip5(12, 0, 0, 0, 0), tip5(13, 0, 0, 0, 0)), + recipient: addr(0x22), + amount_to_mint: 2, + }; + let entry_c = DepositLogEntry { + block_height: 12, + tx_id: tip5(3, 0, 0, 0, 0), + as_of: tip5(7, 7, 7, 7, 7), + name: Name::new(tip5(14, 0, 0, 0, 0), tip5(15, 0, 0, 0, 0)), + recipient: addr(0x33), + amount_to_mint: 3, + }; + + log.insert_entry(&entry_a).await.unwrap(); + log.insert_entry(&entry_b).await.unwrap(); + log.insert_entry(&entry_c).await.unwrap(); + + let snapshot = log + .snapshot( + &epoch, + DepositLogView { + offset: 0, + limit: 2, + }, + ) + .await + .unwrap(); + assert_eq!(snapshot.total_count, 3); + assert_eq!(snapshot.first_epoch_nonce, 101); + assert_eq!(snapshot.rows.len(), 2); + assert_eq!(snapshot.rows[0].nonce, 103); + assert_eq!(snapshot.rows[0].block_height, 12); + assert_eq!(snapshot.rows[0].tx_id_base58, entry_c.tx_id.to_base58()); + assert_eq!(snapshot.rows[1].nonce, 102); + assert_eq!(snapshot.rows[1].block_height, 11); + assert_eq!(snapshot.rows[1].tx_id_base58, entry_b.tx_id.to_base58()); + + let snapshot_offset = log + .snapshot( + &epoch, + DepositLogView { + offset: 1, + limit: 1, + }, + ) + .await + .unwrap(); + assert_eq!(snapshot_offset.total_count, 3); + assert_eq!(snapshot_offset.rows.len(), 1); + assert_eq!(snapshot_offset.rows[0].nonce, 102); + assert_eq!(snapshot_offset.rows[0].block_height, 11); + assert_eq!( + snapshot_offset.rows[0].tx_id_base58, + entry_b.tx_id.to_base58() + ); + } + + #[tokio::test] + async fn validation_succeeds_when_log_is_behind_chain_nonce() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("deposit-log.sqlite"); + let log = DepositLog::open(path).await.unwrap(); + + let entry = DepositLogEntry { + block_height: 10, + tx_id: tip5(1, 0, 0, 0, 0), + as_of: tip5(9, 9, 9, 9, 9), + name: Name::new(tip5(10, 0, 0, 0, 0), tip5(11, 0, 0, 0, 0)), + recipient: addr(0x11), + amount_to_mint: 1, + }; + log.insert_entry(&entry).await.unwrap(); + + let epoch = NonceEpochConfig { + base: 0, + start_height: 10, + start_tx_id: None, + }; + + let deposit_log = Arc::new(log); + let result = + check_deposit_log_against_chain_nonce_prefix_with_last_nonce(5, deposit_log, epoch) + .await; + assert!(result.is_ok()); + } +} diff --git a/crates/bridge/src/errors.rs b/crates/bridge/src/errors.rs new file mode 100644 index 000000000..f9c0f8e5d --- /dev/null +++ b/crates/bridge/src/errors.rs @@ -0,0 +1,113 @@ +use nockapp::CrownError; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum BridgeError { + #[error("Configuration error: {0}")] + Config(String), + + #[error("Base bridge initialization failed: {0}")] + BaseBridgeInit(String), + + #[error("Base bridge submission failed: {0}")] + BaseBridgeSubmission(String), + + #[error("Base bridge monitoring failed: {0}")] + BaseBridgeMonitoring(String), + + #[error("Base bridge query failed: {0}")] + BaseBridgeQuery(String), + + #[error("Nockapp task failed: {0}")] + NockappTask(String), + + #[error("Ack task failed: {0}")] + AckTask(String), + + #[error("Invalid contract address: {0}")] + InvalidContractAddress(String), + + #[error("Invalid private key: {0}")] + InvalidPrivateKey(String), + + #[error("WebSocket connection failed: {0}")] + WebSocketConnection(String), + + #[error("Contract interaction failed: {0}")] + ContractInteraction(String), + + #[error("Event monitoring failed: {0}")] + EventMonitoring(String), + + #[error("Value conversion failed: {0}")] + ValueConversion(String), + + #[error("Bridge runtime error: {0}")] + Runtime(String), + + #[error("Signature generation failed: {0}")] + SignatureGeneration(String), + + #[error("Invalid signature format: {0}")] + InvalidSignatureFormat(String), + + #[error("Invalid deposit log base: {0}")] + InvalidDepositLogBase(String), + + #[error("Invalid deposit log entry: {0}")] + InvalidDepositLogEntry(String), +} + +impl From for BridgeError { + fn from(err: anyhow::Error) -> Self { + BridgeError::Config(err.to_string()) + } +} + +impl From for BridgeError { + fn from(err: std::env::VarError) -> Self { + BridgeError::Config(format!("Environment variable error: {}", err)) + } +} + +impl From for BridgeError { + fn from(err: std::num::ParseIntError) -> Self { + BridgeError::Config(format!("Parse error: {}", err)) + } +} + +impl From for CrownError { + fn from(err: BridgeError) -> Self { + CrownError::Unknown(err.to_string()) + } +} + +impl From for BridgeError { + fn from(err: CrownError) -> Self { + BridgeError::NockappTask(err.to_string()) + } +} + +impl From for BridgeError { + fn from(err: serde_json::Error) -> Self { + BridgeError::Config(format!("JSON parsing error: {}", err)) + } +} + +impl From for BridgeError { + fn from(err: nockapp_grpc::NockAppGrpcError) -> Self { + BridgeError::NockappTask(err.to_string()) + } +} + +impl From for BridgeError { + fn from(err: nockapp::NockAppError) -> Self { + BridgeError::NockappTask(err.to_string()) + } +} + +impl From for BridgeError { + fn from(err: alloy::signers::local::LocalSignerError) -> Self { + BridgeError::InvalidPrivateKey(err.to_string()) + } +} diff --git a/crates/bridge/src/ethereum.rs b/crates/bridge/src/ethereum.rs new file mode 100644 index 000000000..366794389 --- /dev/null +++ b/crates/bridge/src/ethereum.rs @@ -0,0 +1,1631 @@ +#![allow(clippy::too_many_arguments)] // For macro-generated code + +use std::collections::HashMap; +use std::convert::TryInto; +use std::str::FromStr; +use std::sync::Arc; + +use alloy::network::{EthereumWallet, NetworkWallet}; +use alloy::primitives::{keccak256, Address, Bytes, B256, U256}; +use alloy::providers::{DynProvider, Provider, ProviderBuilder}; +use alloy::rpc::client::BatchRequest; +use alloy::rpc::types::eth::{BlockNumberOrTag, Filter, RawLog}; +use alloy::signers::local::PrivateKeySigner; +use alloy::sol_types::SolEvent; +use alloy::transports::ws::WsConnect; +use alloy::transports::TransportError; +use backon::{ExponentialBuilder, Retryable}; +use hex::encode as hex_encode; +use op_alloy::network::Optimism; +use tokio::time::{sleep, Duration}; +use tracing::{debug, error, info, trace, warn}; + +use crate::bridge_status::BridgeStatus; +use crate::errors::BridgeError; +use crate::runtime::{BaseBlockBatch, BridgeEvent, BridgeRuntimeHandle, ChainEvent}; +use crate::stop::StopHandle; +use crate::tui::types::{BridgeTx, TxDirection, TxStatus}; + +fn is_rate_limit_error(e: &E) -> bool { + let s = e.to_string().to_lowercase(); + s.contains("rate limit") || s.contains("-32005") +} + +/// Result of a successful deposit submission to Base. +#[derive(Clone, Debug)] +pub struct DepositSubmissionResult { + /// Transaction hash on Base. + pub tx_hash: String, + /// Block number where the transaction was included. + pub block_number: u64, +} + +use nockchain_types::v1::Name; + +use crate::types::{ + zero_tip5_hash, AtomBytes, BaseBlockRef, BaseDepositSettlementEntry, BaseEvent, + BaseEventContent, BaseWithdrawalEntry, DepositSettlement, DepositSettlementData, + DepositSubmission, EthAddress, NullTag, Tip5Hash, Withdrawal, +}; + +/// Default Base confirmation depth used by the driver if not specified in config. +/// +/// The bridge kernel assumes blocks it receives are final; this is enforced by the Rust driver. +pub const DEFAULT_BASE_CONFIRMATION_DEPTH: u64 = 300; + +// In Bazel builds, contract JSON paths are provided via rustc_env. +// In Cargo builds, they're relative to CARGO_MANIFEST_DIR. +#[cfg(feature = "bazel_build")] +alloy::sol!( + #[sol(rpc)] + MessageInbox, + env!("MESSAGE_INBOX_JSON") +); + +#[cfg(not(feature = "bazel_build"))] +alloy::sol!( + #[sol(rpc)] + MessageInbox, + concat!( + env!("CARGO_MANIFEST_DIR"), + "/contracts/out/MessageInbox.sol/MessageInbox.json" + ) +); + +#[cfg(feature = "bazel_build")] +alloy::sol!( + #[sol(rpc)] + Nock, + env!("NOCK_JSON") +); + +#[cfg(not(feature = "bazel_build"))] +alloy::sol!( + #[sol(rpc)] + Nock, + concat!( + env!("CARGO_MANIFEST_DIR"), + "/contracts/out/Nock.sol/Nock.json" + ) +); + +/// Base unit for Nock token (10^16) - Nock.sol uses 16 decimals, not 18 +const NOCK_BASE_UNIT: u128 = 10_000_000_000_000_000; + +/// Nicks per NOCK on Nockchain (2^16) +const NICKS_PER_NOCK: u128 = 65_536; + +/// Conversion factor: NOCK base units per nick +/// 1 nick = 10^16 / 65,536 = 152,587,890,625 NOCK base units +const NOCK_BASE_PER_NICK: u128 = NOCK_BASE_UNIT / NICKS_PER_NOCK; + +/// Test helper: calculate confirmed batch using old global-boundary logic. +/// Only used for testing the confirmation depth behavior. +#[cfg(test)] +fn confirmed_batch(chain_tip: u64, batch_size: u64, confirmation_depth: u64) -> Option<(u64, u64)> { + let confirmed_height = chain_tip.saturating_sub(confirmation_depth); + if confirmed_height < batch_size { + return None; + } + let batch_end = (confirmed_height / batch_size) * batch_size; + let batch_start = batch_end - batch_size + 1; + Some((batch_start, batch_end)) +} + +/// Calculate the next batch window to fetch, aligned to kernel's requested height. +/// +/// Returns `Some((start, end))` if a full batch is confirmed, `None` otherwise. +/// The batch is always exactly `batch_size` blocks, starting at `next_needed_height`. +fn next_confirmed_window( + next_needed_height: u64, + confirmed_height: u64, + batch_size: u64, +) -> Option<(u64, u64)> { + let batch_start = next_needed_height; + let batch_end = next_needed_height + batch_size - 1; + // Only return if the FULL batch is confirmed + if batch_end > confirmed_height { + return None; + } + Some((batch_start, batch_end)) +} + +#[allow(dead_code)] +pub struct BaseBridge { + provider: DynProvider, + wallet: EthereumWallet, + inbox_contract_address: Address, + nock_contract_address: Address, + runtime_handle: Arc, + /// Batch size for fetching base blocks (must match Hoon kernel's base-blocks-chunk) + batch_size: u64, + /// Number of confirmations required before emitting a batch to the kernel. + confirmation_depth: u64, + stop: StopHandle, +} + +impl BaseBridge { + pub async fn new( + ws_url: String, + inbox_contract_address: Address, + nock_contract_address: Address, + private_key: String, + runtime_handle: Arc, + batch_size: u64, + confirmation_depth: u64, + stop: StopHandle, + ) -> Result { + let signer = { + let key = private_key.strip_prefix("0x").unwrap_or(&private_key); + PrivateKeySigner::from_str(key)? + }; + let wallet = EthereumWallet::from(signer); + + let connect_backoff = || { + ExponentialBuilder::default() + .with_min_delay(Duration::from_secs(1)) + .with_max_delay(Duration::from_secs(30)) + .with_jitter() + .with_max_times(10) + }; + + let provider = loop { + if stop.is_stopped() { + return Err(BridgeError::Runtime( + "bridge stopped while connecting to base websocket".into(), + )); + } + + let ws_url = ws_url.clone(); + let connect = || async { + let ws = WsConnect::new(ws_url.clone()); + // Build provider with recommended fillers for gas estimation, nonce management, and chain ID + // Note: We use filler() to add each filler explicitly since RecommendedFillers + // doesn't work directly with Optimism network + use alloy::providers::fillers::{ + CachedNonceManager, ChainIdFiller, GasFiller, NonceFiller, WalletFiller, + }; + ProviderBuilder::<_, _, Optimism>::default() + .filler(GasFiller) + .filler(NonceFiller::::default()) + .filler(ChainIdFiller::default()) + .filler(WalletFiller::new(wallet.clone())) + .connect_ws(ws) + .await + }; + + match connect + .retry(connect_backoff()) + .notify(|err, dur| { + warn!( + target: "bridge.base.connect", + error=%err, + backoff_secs = dur.as_secs(), + "failed to connect to base websocket, will retry" + ); + }) + .await + { + Ok(provider) => break provider.erased(), + Err(err) => { + warn!( + target: "bridge.base.connect", + error=%err, + "failed to connect to base websocket after retries, retrying" + ); + sleep(Duration::from_secs(2)).await; + } + } + }; + + Ok(Self { + provider, + wallet, + inbox_contract_address, + nock_contract_address, + runtime_handle, + batch_size, + confirmation_depth, + stop, + }) + } + + /// Submit a deposit to Base, converting nicks to NOCK base units (ERC-20). + /// 1 NOCK = 10^16 base units, 1 NOCK = 65,536 nicks, so 1 nick = NOCK_BASE_PER_NICK. + /// Conversion: amount_base = nicks * NOCK_BASE_PER_NICK. + pub async fn submit_deposit( + &self, + submission: DepositSubmission, + ) -> Result { + let recipient = Address::from(submission.recipient); + // Convert nicks (Nockchain internal units) to NOCK base units (ERC-20) + let amount = U256::from(submission.amount) * U256::from(NOCK_BASE_PER_NICK); + let block_height = U256::from(submission.block_height); + + info!( + recipient = %recipient, + amount = %amount, + block_height = %block_height, + "Submitting deposit to Base", + ); + + let eth_sigs = submission + .signatures + .eth_signatures + .into_iter() + .map(|sig| Bytes::from(sig.into_vec())) + .collect::>(); + + let inbox = MessageInbox::new(self.inbox_contract_address, self.provider.clone()); + + let tx_id_sol = MessageInbox::Tip5Hash { + limbs: submission.tx_id.to_array(), + }; + let name_first_sol = MessageInbox::Tip5Hash { + limbs: submission.name_first.to_array(), + }; + let name_last_sol = MessageInbox::Tip5Hash { + limbs: submission.name_last.to_array(), + }; + let as_of_sol = MessageInbox::Tip5Hash { + limbs: submission.as_of.to_array(), + }; + + let deposit_nonce = U256::from(submission.nonce); + // GasFiller, NonceFiller, and ChainIdFiller will automatically populate + // gas_limit, nonce, max_fee_per_gas, max_priority_fee_per_gas, and chain_id + let pending_tx = inbox + .submitDeposit( + tx_id_sol, name_first_sol, name_last_sol, recipient, amount, block_height, + as_of_sol, deposit_nonce, eth_sigs, + ) + .from(NetworkWallet::::default_signer_address( + &self.wallet, + )) + .send() + .await + .map_err(|e| BridgeError::BaseBridgeSubmission(format!("Transaction failed: {}", e)))?; + + let receipt = pending_tx + .get_receipt() + .await + .map_err(|e| BridgeError::BaseBridgeSubmission(format!("Receipt failed: {}", e)))?; + + let tx_hash = format!("{:?}", receipt.inner.transaction_hash); + let block_number = receipt.inner.block_number.unwrap_or(0); + let status_ok = receipt + .inner + .inner + .receipt + .as_receipt() + .status + .coerce_status(); + + if !status_ok { + return Err(BridgeError::BaseBridgeSubmission(format!( + "Transaction reverted (status=0) tx_hash={}", + tx_hash + ))); + } + + info!( + tx_hash = %tx_hash, + block_number = %block_number, + "Deposit submitted successfully!" + ); + + Ok(DepositSubmissionResult { + tx_hash, + block_number, + }) + } + + /// Query the last deposit nonce from the MessageInbox contract. + /// + /// This is the source of truth for which nonce to submit next. + /// The next valid deposit must have nonce == lastDepositNonce + 1. + pub async fn get_last_deposit_nonce(&self) -> Result { + let inbox = MessageInbox::new(self.inbox_contract_address, self.provider.clone()); + + let nonce = inbox.lastDepositNonce().call().await.map_err(|e| { + BridgeError::BaseBridgeQuery(format!("Failed to query lastDepositNonce: {}", e)) + })?; + + Ok(nonce.to::()) + } + + /// Query whether a nockchain txId has already been processed on-chain. + /// + /// This is the source of truth for replay protection (`processedDeposits` mapping). + pub async fn is_deposit_processed(&self, tx_id: &Tip5Hash) -> Result { + use alloy::primitives::B256; + use tiny_keccak::{Hasher, Keccak}; + + let inbox = MessageInbox::new(self.inbox_contract_address, self.provider.clone()); + + let be40 = tx_id.to_be_limb_bytes(); + let mut hasher = Keccak::v256(); + hasher.update(&be40); + let mut out = [0u8; 32]; + hasher.finalize(&mut out); + + let key = B256::from_slice(&out); + let processed = inbox.processedDeposits(key).call().await.map_err(|e| { + BridgeError::BaseBridgeQuery(format!("Failed to query processedDeposits: {}", e)) + })?; + + Ok(processed) + } + + /// Query the nonce for a processed deposit by tx_id. + /// + /// Uses the DepositProcessed event topic filter to locate the on-chain nonce. + pub async fn get_deposit_processed_nonce_for_tx_id( + &self, + tx_id: &Tip5Hash, + from_block: u64, + ) -> Result, BridgeError> { + let tx_hash = keccak256(tx_id.to_be_limb_bytes()); + let filter = Filter::new() + .address(self.inbox_contract_address) + .event_signature(MessageInbox::DepositProcessed::SIGNATURE_HASH) + .topic1(tx_hash) + .from_block(from_block) + .to_block(BlockNumberOrTag::Latest); + + let logs = self.provider.get_logs(&filter).await.map_err(|e| { + BridgeError::BaseBridgeQuery(format!("Failed to query DepositProcessed logs: {e}")) + })?; + + if logs.is_empty() { + return Ok(None); + } + + let mut best: Option<(u64, u64, u64)> = None; + for log in logs { + let raw = RawLog { + address: log.address(), + topics: log.topics().to_vec(), + data: log.data().data.clone(), + }; + let event = MessageInbox::DepositProcessed::decode_raw_log( + raw.topics.iter().cloned(), + raw.data.as_ref(), + ) + .map_err(|e| { + BridgeError::BaseBridgeQuery(format!("Failed to decode DepositProcessed log: {e}")) + })?; + + let event_tx_id = Tip5Hash::from_limbs(&event.txIdFull.limbs); + if &event_tx_id != tx_id { + return Err(BridgeError::BaseBridgeQuery( + "DepositProcessed tx_id mismatch for tx_id filter".into(), + )); + } + + if event.nonce > U256::from(u64::MAX) { + return Err(BridgeError::ValueConversion( + "DepositProcessed nonce exceeds u64 range".into(), + )); + } + let nonce = event.nonce.to::(); + let block_number = log.block_number.unwrap_or(0); + let log_index = log.log_index.unwrap_or(0); + + match best { + Some((best_block, best_index, _)) => { + if block_number > best_block + || (block_number == best_block && log_index > best_index) + { + best = Some((block_number, log_index, nonce)); + } + } + None => best = Some((block_number, log_index, nonce)), + } + } + + Ok(best.map(|(_, _, nonce)| nonce)) + } + + pub async fn watch_base_acks(&self) -> Result<(), BridgeError> { + self.stream_base_events(None).await + } + + pub async fn stream_base_events( + &self, + bridge_status: Option, + ) -> Result<(), BridgeError> { + info!( + "starting base bridge event stream (confirmation_depth={}, batch_size={})", + self.confirmation_depth, self.batch_size + ); + // Base blocks are ~2s, but we need 300 confirmations (~10 min), so 30s polling is fine. + const BASE_POLL_INTERVAL: Duration = Duration::from_secs(30); + + let rpc_backoff = || { + ExponentialBuilder::default() + .with_min_delay(Duration::from_secs(5)) + .with_max_delay(Duration::from_secs(120)) + .with_jitter() + .with_max_times(10) + }; + + loop { + if self.stop.is_stopped() { + sleep(BASE_POLL_INTERVAL).await; + continue; + } + sleep(BASE_POLL_INTERVAL).await; + + let chain_tip: u64 = match (|| async { self.provider.get_block_number().await }) + .retry(rpc_backoff()) + .when(is_rate_limit_error) + .notify(|err, dur| { + warn!( + target: "bridge.base.observer", + error=%err, + backoff_secs = dur.as_secs(), + "failed to get block number, will retry" + ); + }) + .await + { + Ok(tip) => tip, + Err(e) => { + error!( + target: "bridge.base.observer", + error=%e, + "failed to get block number after retries" + ); + continue; + } + }; + + let confirmed_height = chain_tip.saturating_sub(self.confirmation_depth); + if confirmed_height < self.batch_size { + debug!( + target: "bridge.base.observer", + chain_tip, + confirmed_height, + "no confirmed batch yet (bootstrap)" + ); + continue; + } + + let next_needed_height = match self.runtime_handle.peek_base_next_height().await { + Ok(Some(height)) => height, + Ok(None) => { + debug!( + target: "bridge.base.observer", + chain_tip, + "kernel has no pending base batch" + ); + continue; + } + Err(err) => { + warn!( + target: "bridge.base.observer", + error=%err, + "failed to peek base next height" + ); + continue; + } + }; + + debug!( + target: "bridge.base.observer", + next_needed_height, + "kernel reports base-hashchain-next-height" + ); + + let Some((batch_start, batch_end)) = + next_confirmed_window(next_needed_height, confirmed_height, self.batch_size) + else { + // Calculate what we're waiting for to help debugging + let needed_confirmed = next_needed_height + self.batch_size - 1; + let blocks_until_ready = needed_confirmed.saturating_sub(confirmed_height); + debug!( + target: "bridge.base.observer", + chain_tip, + confirmed_height, + next_needed_height, + batch_size = self.batch_size, + needed_confirmed_height = needed_confirmed, + blocks_until_ready, + "batch not yet confirmed for kernel need" + ); + continue; + }; + + debug!( + target: "bridge.base.observer", + chain_tip, + batch_start, + batch_end, + batch_blocks = batch_end - batch_start + 1, + "fetching confirmed batch" + ); + + let tui = bridge_status.clone(); + match (|| async { self.fetch_batch(batch_start, batch_end, tui.clone()).await }) + .retry(rpc_backoff()) + .when(is_rate_limit_error) + .notify(|err, dur| { + warn!( + target: "bridge.base.observer", + batch_start, + batch_end, + error=%err, + backoff_secs = dur.as_secs(), + "failed to fetch batch, will retry" + ); + }) + .await + { + Ok(batch) => { + self.runtime_handle + .send_event(BridgeEvent::Chain(Box::new(ChainEvent::Base(batch)))) + .await?; + info!( + target: "bridge.base.observer", + batch_start, + batch_end, + "emitted base batch" + ); + } + Err(err) => { + error!( + target: "bridge.base.observer", + batch_start, + batch_end, + error=%err, + "failed to fetch batch after retries" + ); + } + } + } + } + + async fn fetch_batch( + &self, + batch_start: u64, + batch_end: u64, + bridge_status: Option, + ) -> Result { + // Filter by specific event signatures to avoid fetching irrelevant logs + // (e.g., ERC-20 Transfer events from the Nock token contract) + let event_signatures = vec![ + Nock::BurnForWithdrawal::SIGNATURE_HASH, + MessageInbox::DepositProcessed::SIGNATURE_HASH, + MessageInbox::BridgeNodeUpdated::SIGNATURE_HASH, + ]; + let filter = Filter::new() + .address(vec![ + self.inbox_contract_address, self.nock_contract_address, + ]) + .event_signature(event_signatures) + .from_block(batch_start) + .to_block(batch_end); + + let logs = self + .provider + .get_logs(&filter) + .await + .map_err(|e: TransportError| BridgeError::BaseBridgeMonitoring(e.to_string()))?; + + debug!( + target: "bridge.base.observer", + batch_start, + batch_end, + log_count = logs.len(), + "fetched logs for batch" + ); + + // Use batch RPC to fetch all block headers in chunks of 20 + // This reduces 100 individual RPC calls to ~5 batched requests + let mut block_info: HashMap = HashMap::new(); + let heights: Vec = (batch_start..=batch_end).collect(); + + for chunk in heights.chunks(20) { + let mut batch = BatchRequest::new(self.provider.client()); + let mut futures = Vec::new(); + + for &height in chunk { + // eth_getBlockByNumber with false = don't include transactions + let fut = batch + .add_call::<_, Option>( + "eth_getBlockByNumber", + &(BlockNumberOrTag::Number(height), false), + ) + .map_err(|e| { + BridgeError::BaseBridgeMonitoring(format!( + "failed to add batch call for block {}: {}", + height, e + )) + })?; + futures.push((height, fut)); + } + + // Send the batch + batch.send().await.map_err(|e| { + BridgeError::BaseBridgeMonitoring(format!("batch RPC failed: {}", e)) + })?; + + // Collect results + for (height, fut) in futures { + let block_opt: Option = fut.await.map_err(|e| { + BridgeError::BaseBridgeMonitoring(format!( + "failed to get block {}: {}", + height, e + )) + })?; + let block = block_opt.ok_or_else(|| { + BridgeError::BaseBridgeMonitoring(format!( + "block {} unavailable during batch fetch", + height + )) + })?; + block_info.insert(height, (block.header.hash, block.header.inner.parent_hash)); + } + } + + let mut blocks = Vec::new(); + let mut prev_hash: Option = None; + for height in batch_start..=batch_end { + let (block_hash, parent_hash) = block_info.get(&height).ok_or_else(|| { + BridgeError::BaseBridgeMonitoring(format!("missing block info for {}", height)) + })?; + + if let Some(expected_parent) = prev_hash { + if *parent_hash != expected_parent { + return Err(BridgeError::BaseBridgeMonitoring(format!( + "base reorg detected at height {} (expected parent {:?}, got {:?})", + height, expected_parent, parent_hash + ))); + } + } + prev_hash = Some(*block_hash); + + blocks.push(BaseBlockRef { + height, + block_id: atom_bytes_from_b256(*block_hash), + parent_block_id: atom_bytes_from_b256(*parent_hash), + }); + } + + if let Some(last_block) = blocks.last() { + let tip_hash = format!("0x{}", hex_encode(last_block.block_id.as_slice())); + self.runtime_handle.set_base_tip_hash(tip_hash); + } + + let mut withdrawals = Vec::new(); + let mut deposit_settlements = Vec::new(); + let mut block_events: HashMap> = HashMap::new(); + for height in batch_start..=batch_end { + block_events.insert(height, Vec::new()); + } + + for log in logs { + let block_number = log.block_number.ok_or_else(|| { + BridgeError::BaseBridgeMonitoring("log missing block number".into()) + })?; + let tx_hash = log.transaction_hash.ok_or_else(|| { + BridgeError::BaseBridgeMonitoring("log missing transaction hash".into()) + })?; + let log_index = log.log_index; + + let raw = RawLog { + address: log.address(), + topics: log.topics().to_vec(), + data: log.data().data.clone(), + }; + + if log.address() == self.inbox_contract_address { + if let Some((event, settlement)) = + self.process_inbox_log(&raw, &tx_hash, log_index)? + { + block_events + .get_mut(&block_number) + .expect("block initialized") + .push(event.clone()); + if let Some(s) = settlement { + deposit_settlements.push(s); + } + // Push transaction to TUI state if available + if let Some(ref state) = bridge_status { + if let BaseEventContent::DepositProcessed { + recipient, + amount, + block_height, + .. + } = &event.content + { + let bridge_tx = BridgeTx { + tx_hash: format!("0x{}", hex_encode(tx_hash.as_slice())), + direction: TxDirection::Deposit, + from: "Base".to_string(), + to: format!("0x{}", hex_encode(recipient.0)), + amount: *amount as u128, + status: TxStatus::Completed, + timestamp: std::time::SystemTime::now(), + base_block: Some(*block_height), + nock_height: None, + }; + state.push_transaction(bridge_tx); + + // Record metrics for deposit completion + // Note: We don't have true latency tracking here since we're processing + // historical events in batches. Setting latency to 0 for now. + state.record_tx_completion( + TxDirection::Deposit, + *amount as u128, + 0, // latency_ms (not tracked for historical events) + true, // success (we only process successful deposits) + ); + } + } + } + } else if log.address() == self.nock_contract_address { + if let Some((event, withdrawal)) = + self.process_nock_log(&raw, &tx_hash, log_index)? + { + block_events + .get_mut(&block_number) + .expect("block initialized") + .push(event.clone()); + if let Some(w) = withdrawal { + withdrawals.push(w); + } + // Push transaction to TUI state if available + if let Some(ref state) = bridge_status { + if let BaseEventContent::BurnForWithdrawal { burner, amount, .. } = + &event.content + { + let bridge_tx = BridgeTx { + tx_hash: format!("0x{}", hex_encode(tx_hash.as_slice())), + direction: TxDirection::Withdrawal, + from: format!("0x{}", hex_encode(burner.0)), + to: "Nockchain".to_string(), + amount: *amount as u128, + status: TxStatus::Completed, + timestamp: std::time::SystemTime::now(), + base_block: Some(block_number), + nock_height: None, + }; + state.push_transaction(bridge_tx); + + // Record metrics for withdrawal completion + // Note: We don't have true latency tracking here since we're processing + // historical events in batches. Setting latency to 0 for now. + state.record_tx_completion( + TxDirection::Withdrawal, + *amount as u128, + 0, // latency_ms (not tracked for historical events) + true, // success (we only process successful withdrawals) + ); + } + } + } + } + } + + Ok(BaseBlockBatch { + version: 0, + first_height: batch_start, + last_height: batch_end, + blocks, + withdrawals, + deposit_settlements, + block_events, + prev: zero_tip5_hash(), + }) + } + + /// Decode Base deposit logs and convert NOCK base units to nicks. + /// Requires exact divisibility by NOCK_BASE_PER_NICK to avoid rounding. + fn process_inbox_log( + &self, + raw: &RawLog, + tx_hash: &B256, + log_index: Option, + ) -> Result)>, BridgeError> { + if let Ok(event) = MessageInbox::DepositProcessed::decode_raw_log( + raw.topics.iter().cloned(), + raw.data.as_ref(), + ) { + // Convert NOCK base units back to nicks + // 1 nick = NOCK_BASE_PER_NICK NOCK base units + let nock_per_nick = U256::from(NOCK_BASE_PER_NICK); + let amount_raw: U256 = event.amount; + if amount_raw % nock_per_nick != U256::ZERO { + warn!( + target: "bridge.base.observer", + amount=%amount_raw, + "deposit amount not divisible by NOCK_BASE_PER_NICK, skipping" + ); + return Ok(None); + } + let nicks = amount_raw / nock_per_nick; + if nicks > U256::from(u64::MAX) { + return Err(BridgeError::ValueConversion(format!( + "deposit amount exceeds representable range (value: {} nicks)", + nicks + ))); + } + let amount = nicks.to::(); + + let base_tx_id = AtomBytes(tx_hash.as_slice().to_vec()); + let base_event_id = compute_base_event_id(tx_hash, log_index); + let nock_tx_id = tip5_from_limbs(&event.txIdFull.limbs); + let note_name_first = tip5_from_limbs(&event.nameFirst.limbs); + let note_name_last = tip5_from_limbs(&event.nameLast.limbs); + let as_of = tip5_from_limbs(&event.asOf.limbs); + + let data = DepositSettlementData { + counterpart: nock_tx_id.clone(), + as_of: as_of.clone(), + dest: AtomBytes(event.recipient.as_slice().to_vec()), + settled_amount: amount, + fees: Vec::new(), + bridge_fee: 0, + }; + let settlement = DepositSettlement { + base_tx_id: base_tx_id.clone(), + data, + }; + + let block_height_raw: U256 = event.blockHeight; + info!( + tx_id = %event.txId, + name_first_hash = %event.nameFirstHash, + recipient = %event.recipient, + amount = %amount_raw, + block_height = %block_height_raw, + "Deposit processed on MessageInbox", + ); + + return Ok(Some(( + BaseEvent { + base_event_id, + content: BaseEventContent::DepositProcessed { + nock_tx_id, + note_name: Name::new(note_name_first, note_name_last), + recipient: eth_address_from_alloy(event.recipient), + amount, + block_height: block_height_raw.to::(), + as_of, + nonce: event.nonce.to::(), + }, + }, + Some(BaseDepositSettlementEntry { + base_tx_id, + settlement, + }), + ))); + } + + if let Ok(event) = MessageInbox::BridgeNodeUpdated::decode_raw_log( + raw.topics.iter().cloned(), + raw.data.as_ref(), + ) { + let base_event_id = compute_base_event_id(tx_hash, log_index); + let index: U256 = event.index; + info!( + index = %index, + old_node = %event.oldNode, + new_node = %event.newNode, + "Bridge node updated on MessageInbox", + ); + return Ok(Some(( + BaseEvent { + base_event_id, + content: BaseEventContent::BridgeNodeUpdated(NullTag), + }, + None, + ))); + } + + Ok(None) + } + + /// Decode Base withdrawal logs and convert NOCK base units to nicks. + /// Requires exact divisibility by NOCK_BASE_PER_NICK to avoid rounding. + fn process_nock_log( + &self, + raw: &RawLog, + tx_hash: &B256, + log_index: Option, + ) -> Result)>, BridgeError> { + match Nock::BurnForWithdrawal::decode_raw_log(raw.topics.iter().cloned(), raw.data.as_ref()) + { + Ok(event) => { + // Convert NOCK base units back to nicks + // 1 nick = NOCK_BASE_PER_NICK NOCK base units + let nock_per_nick = U256::from(NOCK_BASE_PER_NICK); + let amount_raw: U256 = event.amount; + if amount_raw % nock_per_nick != U256::ZERO { + warn!( + target: "bridge.base.observer", + amount=%amount_raw, + "withdrawal amount not divisible by NOCK_BASE_PER_NICK, skipping" + ); + return Ok(None); + } + let nicks = amount_raw / nock_per_nick; + if nicks > U256::from(u64::MAX) { + warn!( + target: "bridge.base.observer", + nicks=%nicks, + "withdrawal amount exceeds representable range, skipping" + ); + return Ok(None); + } + let amount = nicks.to::(); + + let base_tx_id = AtomBytes(tx_hash.as_slice().to_vec()); + let withdrawal = Withdrawal { + base_tx_id: base_tx_id.clone(), + dest: None, + raw_amount: amount, + }; + + debug!( + target: "bridge.base.observer", + base_tx_id_hex=%hex_encode(&base_tx_id.0), + base_tx_id_len=%base_tx_id.0.len(), + dest_is_none=true, + raw_amount=%amount, + "created Withdrawal struct" + ); + + let entry = BaseWithdrawalEntry { + base_tx_id: base_tx_id.clone(), + withdrawal, + }; + + debug!( + target: "bridge.base.observer", + entry_base_tx_id_hex=%hex_encode(&entry.base_tx_id.0), + entry_withdrawal_raw_amount=%entry.withdrawal.raw_amount, + "created BaseWithdrawalEntry" + ); + + let base_event_id = compute_base_event_id(tx_hash, log_index); + + info!( + burner = %event.burner, + amount = %amount_raw, + lock_root = %event.lockRoot, + "Withdrawal detected on Nock contract" + ); + + return Ok(Some(( + BaseEvent { + base_event_id, + content: BaseEventContent::BurnForWithdrawal { + burner: eth_address_from_alloy(event.burner), + amount, + lock_root: Tip5Hash::from_be_bytes(&b256_to_array(event.lockRoot)), + }, + }, + Some(entry), + ))); + } + Err(err) => { + // With topic filtering, this should rarely happen - only if ABI changes + trace!("Skipping non-BurnForWithdrawal log: {}", err); + } + } + + Ok(None) + } +} + +fn atom_bytes_from_b256(value: B256) -> AtomBytes { + AtomBytes(value.as_slice().to_vec()) +} + +fn eth_address_from_alloy(addr: Address) -> EthAddress { + EthAddress::from(addr) +} + +fn tip5_from_limbs(limbs: &[u64; 5]) -> Tip5Hash { + Tip5Hash::from_limbs(limbs) +} + +fn compute_base_event_id(tx_hash: &B256, log_index: Option) -> AtomBytes { + let log_index = U256::from(log_index.unwrap_or(0u64)); + let mut hash_input = Vec::new(); + hash_input.extend_from_slice(tx_hash.as_slice()); + let log_index_bytes = log_index.to_be_bytes::<32>(); + hash_input.extend_from_slice(&log_index_bytes); + AtomBytes(keccak256(&hash_input).as_slice().to_vec()) +} + +fn b256_to_array(value: B256) -> [u8; 32] { + value.as_slice().try_into().expect("B256 is 32 bytes") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn b256_from_u64(value: u64) -> B256 { + let mut bytes = [0u8; 32]; + bytes[24..].copy_from_slice(&value.to_be_bytes()); + B256::from(bytes) + } + + fn address_from_u64(value: u64) -> Address { + let mut bytes = [0u8; 20]; + bytes[12..].copy_from_slice(&value.to_be_bytes()); + Address::from(bytes) + } + + fn address_topic(addr: Address) -> B256 { + let mut topic = [0u8; 32]; + topic[12..].copy_from_slice(addr.as_slice()); + B256::from(topic) + } + + #[test] + fn decodes_burn_for_withdrawal_event() { + let burner = address_from_u64(0xdeadbeef); + let lock_root = b256_from_u64(0x1234); + let amount = U256::from(42u64); + + let topics = + vec![Nock::BurnForWithdrawal::SIGNATURE_HASH, address_topic(burner), lock_root]; + let mut amount_bytes = [0u8; 32]; + amount_bytes.copy_from_slice(&amount.to_be_bytes::<32>()); + let log = RawLog { + address: Address::ZERO, + topics, + data: Bytes::from(amount_bytes.to_vec()), + }; + + let event = + Nock::BurnForWithdrawal::decode_raw_log(log.topics.iter().cloned(), log.data.as_ref()) + .expect("decode burn for withdrawal"); + assert_eq!(event.burner, burner); + assert_eq!(event.lockRoot, lock_root); + assert_eq!(U256::from(event.amount), amount); + } + + #[test] + fn compute_base_event_id_matches_keccak() { + let tx_hash = b256_from_u64(0xfeed); + let id = compute_base_event_id(&tx_hash, Some(2)); + let expected = { + let mut buf = Vec::new(); + buf.extend_from_slice(tx_hash.as_slice()); + let idx = U256::from(2u64); + buf.extend_from_slice(&idx.to_be_bytes::<32>()); + keccak256(&buf) + }; + assert_eq!(id.0, expected.as_slice()); + } + + const TEST_BATCH_SIZE: u64 = 1000; + + #[test] + fn confirmed_batch_returns_none_during_bootstrap() { + assert!(confirmed_batch(500, TEST_BATCH_SIZE, DEFAULT_BASE_CONFIRMATION_DEPTH).is_none()); + assert!(confirmed_batch( + TEST_BATCH_SIZE - 1, + TEST_BATCH_SIZE, + DEFAULT_BASE_CONFIRMATION_DEPTH + ) + .is_none()); + } + + #[test] + fn confirmed_batch_returns_batch_when_ready() { + let tip = DEFAULT_BASE_CONFIRMATION_DEPTH + TEST_BATCH_SIZE; + let batch = confirmed_batch(tip, TEST_BATCH_SIZE, DEFAULT_BASE_CONFIRMATION_DEPTH); + assert!(batch.is_some()); + let (start, end) = + batch.expect("batch should be Some when tip >= confirmation_depth + batch_size"); + assert_eq!(end - start + 1, TEST_BATCH_SIZE); + assert!(end <= tip - DEFAULT_BASE_CONFIRMATION_DEPTH); + } + + #[test] + fn next_confirmed_window_returns_exact_batch() { + // With batch_size=1000, need confirmed_height >= 1001 + 1000 - 1 = 2000 + let confirmed_height = 2500; + let window = next_confirmed_window(1001, confirmed_height, 1000).expect("window"); + // Should return exact batch size, not capped to confirmed_height + assert_eq!(window, (1001, 2000)); + } + + #[test] + fn next_confirmed_window_none_when_batch_not_fully_confirmed() { + // With batch_size=1000, need confirmed_height >= 2001 + 1000 - 1 = 3000 + let confirmed_height = 2500; // Not enough for full batch + let window = next_confirmed_window(2001, confirmed_height, 1000); + assert!(window.is_none()); + } + + #[test] + fn next_confirmed_window_works_with_misaligned_start() { + // Start at 33,387,036 (offset 36 from 1000-boundary), batch_size=100 + // Need confirmed_height >= 33,387,036 + 100 - 1 = 33,387,135 + let window = next_confirmed_window(33_387_036, 33_387_200, 100).expect("window"); + assert_eq!(window, (33_387_036, 33_387_135)); + } + + #[test] + fn next_confirmed_window_none_when_not_confirmed() { + let confirmed_height = 1500; + let window = next_confirmed_window(2001, confirmed_height, 1000); + assert!(window.is_none()); + } + + // Helper to encode a Tip5Hash (5 u64 limbs) as ABI-encoded data + fn encode_tip5_limbs(limbs: &[u64; 5]) -> Vec { + let mut data = Vec::new(); + for &limb in limbs { + // ABI encodes uint64 as 32 bytes (left-padded with zeros) + let mut padded = [0u8; 32]; + padded[24..].copy_from_slice(&limb.to_be_bytes()); + data.extend_from_slice(&padded); + } + data + } + + // Helper to create a bytes32 topic from first limb of Tip5Hash (for indexed param) + fn tip5_to_indexed_bytes32(limbs: &[u64; 5]) -> B256 { + // The indexed bytes32 is keccak256 of the full Tip5Hash limbs + // For testing, we'll use a simple hash of the first limb + let mut bytes = [0u8; 32]; + bytes[24..].copy_from_slice(&limbs[0].to_be_bytes()); + B256::from(bytes) + } + + #[test] + fn decodes_deposit_processed_event_all_fields() { + // Define known test values + let tx_id_limbs: [u64; 5] = [ + 0x1111111111111111, 0x2222222222222222, 0x3333333333333333, 0x4444444444444444, + 0x5555555555555555, + ]; + let name_first_limbs: [u64; 5] = [ + 0xaaaaaaaaaaaaaaaa, 0xbbbbbbbbbbbbbbbb, 0xcccccccccccccccc, 0xdddddddddddddddd, + 0xeeeeeeeeeeeeeeee, + ]; + let name_last_limbs: [u64; 5] = [ + 0x1111222233334444, 0x5555666677778888, 0x9999aaaabbbbcccc, 0xddddeeeeffff0000, + 0x1234567890abcdef, + ]; + let as_of_limbs: [u64; 5] = [ + 0x1234567890abcdef, 0xfedcba0987654321, 0x0011223344556677, 0x8899aabbccddeeff, + 0xdeadbeefcafebabe, + ]; + let recipient = address_from_u64(0xdeadbeef); + let amount = U256::from(10_000_000_000_000_000u128); // 1 NOCK (10^16 base units) + let block_height = U256::from(12345u64); + let nonce = U256::from(42u64); + + // Build topics: [signature, txId, nameFirstHash, recipient] + let topics = vec![ + MessageInbox::DepositProcessed::SIGNATURE_HASH, + tip5_to_indexed_bytes32(&tx_id_limbs), + tip5_to_indexed_bytes32(&name_first_limbs), + address_topic(recipient), + ]; + + // Build data: txIdFull, nameFirst, nameLast, amount, blockHeight, asOf, nonce + let mut data = Vec::new(); + data.extend(encode_tip5_limbs(&tx_id_limbs)); + data.extend(encode_tip5_limbs(&name_first_limbs)); + data.extend(encode_tip5_limbs(&name_last_limbs)); + data.extend_from_slice(&amount.to_be_bytes::<32>()); + data.extend_from_slice(&block_height.to_be_bytes::<32>()); + data.extend(encode_tip5_limbs(&as_of_limbs)); + data.extend_from_slice(&nonce.to_be_bytes::<32>()); + + let log = RawLog { + address: Address::ZERO, + topics, + data: Bytes::from(data), + }; + + // Decode the event + let event = MessageInbox::DepositProcessed::decode_raw_log( + log.topics.iter().cloned(), + log.data.as_ref(), + ) + .expect("decode deposit processed"); + + // Verify all fields + assert_eq!( + event.txIdFull.limbs, tx_id_limbs, + "txIdFull limbs should match" + ); + assert_eq!( + event.nameFirst.limbs, name_first_limbs, + "nameFirst limbs should match" + ); + assert_eq!( + event.nameLast.limbs, name_last_limbs, + "nameLast limbs should match" + ); + assert_eq!(event.recipient, recipient, "recipient should match"); + assert_eq!(event.amount, amount, "amount should match"); + assert_eq!(event.blockHeight, block_height, "blockHeight should match"); + assert_eq!(event.asOf.limbs, as_of_limbs, "asOf limbs should match"); + assert_eq!(event.nonce, nonce, "nonce should match"); + + // Verify Tip5Hash extraction through the conversion function + let nock_tx_id = tip5_from_limbs(&event.txIdFull.limbs); + let note_name_first = tip5_from_limbs(&event.nameFirst.limbs); + let note_name_last = tip5_from_limbs(&event.nameLast.limbs); + let as_of = tip5_from_limbs(&event.asOf.limbs); + + // Verify each limb in the Tip5Hash + assert_eq!(nock_tx_id.0[0].0, tx_id_limbs[0]); + assert_eq!(nock_tx_id.0[1].0, tx_id_limbs[1]); + assert_eq!(nock_tx_id.0[2].0, tx_id_limbs[2]); + assert_eq!(nock_tx_id.0[3].0, tx_id_limbs[3]); + assert_eq!(nock_tx_id.0[4].0, tx_id_limbs[4]); + + assert_eq!(note_name_first.0[0].0, name_first_limbs[0]); + assert_eq!(note_name_last.0[0].0, name_last_limbs[0]); + assert_eq!(as_of.0[0].0, as_of_limbs[0]); + } + + #[test] + fn nock_to_nicks_conversion() { + // Test amount conversion: NOCK base units → nicks + // Formula: nicks = nock_base / NOCK_BASE_PER_NICK + // 1 nick = 152,587,890,625 NOCK base units + + let nock_per_nick = U256::from(NOCK_BASE_PER_NICK); + + // 1 NOCK = 10^16 NOCK base units → 65,536 nicks + let one_nock_base = U256::from(NOCK_BASE_UNIT); + let nicks = one_nock_base / nock_per_nick; + assert_eq!(nicks, U256::from(65_536u64), "1 NOCK should be 65536 nicks"); + + // 1000 NOCK → 65,536,000 nicks + let thousand_nock_base = U256::from(1000u64) * U256::from(NOCK_BASE_UNIT); + let nicks = thousand_nock_base / nock_per_nick; + assert_eq!( + nicks, + U256::from(65_536_000u64), + "1000 NOCK should be 65,536,000 nicks" + ); + + // 0 → 0 nicks + let zero = U256::ZERO; + let nicks = zero / nock_per_nick; + assert_eq!(nicks, U256::ZERO, "0 NOCK should be 0 nicks"); + + // 1 nick worth of NOCK → 1 nick + let one_nick_nock = U256::from(NOCK_BASE_PER_NICK); + let nicks = one_nick_nock / nock_per_nick; + assert_eq!( + nicks, + U256::from(1u64), + "NOCK_BASE_PER_NICK should be 1 nick" + ); + + // Test non-divisible amounts should be flagged + let not_divisible = U256::from(NOCK_BASE_PER_NICK) + U256::from(1u64); + let remainder = not_divisible % nock_per_nick; + assert!( + remainder != U256::ZERO, + "NOCK_BASE_PER_NICK + 1 should have non-zero remainder" + ); + } + + #[test] + fn nicks_to_nock_conversion() { + // Test the submission-side conversion: nicks → NOCK base units + // 1 NOCK = 65,536 nicks = 10^16 NOCK base units + // So 1 nick = 10^16 / 65,536 = 152,587,890,625 NOCK base units + + // Verify the constant is correct + assert_eq!( + NOCK_BASE_PER_NICK, + NOCK_BASE_UNIT / NICKS_PER_NOCK, + "NOCK_BASE_PER_NICK should equal NOCK_BASE_UNIT / NICKS_PER_NOCK" + ); + assert_eq!(NOCK_BASE_PER_NICK, 152_587_890_625); + + // 1 NOCK worth of nicks → 10^16 NOCK base units + let one_nock_nicks: u128 = 65_536; + let nock_base = U256::from(one_nock_nicks) * U256::from(NOCK_BASE_PER_NICK); + assert_eq!(nock_base, U256::from(NOCK_BASE_UNIT)); + + // Fractional nock: 1 nick → 152,587,890,625 NOCK base units + let one_nick: u128 = 1; + let nock_base = U256::from(one_nick) * U256::from(NOCK_BASE_PER_NICK); + assert_eq!(nock_base, U256::from(152_587_890_625u128)); + + // The actual failing amount from the bug report: 3,988,097,980 nicks + let bug_amount: u128 = 3_988_097_980; + let nock_base = U256::from(bug_amount) * U256::from(NOCK_BASE_PER_NICK); + // This should be divisible by NOCK_BASE_PER_NICK (for round-trip back to nicks) + assert_eq!( + nock_base % U256::from(NOCK_BASE_PER_NICK), + U256::ZERO, + "converted amount should be divisible by NOCK_BASE_PER_NICK" + ); + // And we can recover the original nicks + assert_eq!( + nock_base / U256::from(NOCK_BASE_PER_NICK), + U256::from(bug_amount), + "should recover original nicks" + ); + + // Round-trip: nicks → NOCK base → nicks + let original_nicks: u128 = 123_456_789; + let nock_base = U256::from(original_nicks) * U256::from(NOCK_BASE_PER_NICK); + let back_nicks = nock_base / U256::from(NOCK_BASE_PER_NICK); + assert_eq!(back_nicks, U256::from(original_nicks)); + } + + #[test] + fn deposit_processed_nonce_extraction() { + // Test that nonce is correctly extracted as u64 + let nonce_value = 12345u64; + let nonce_u256 = U256::from(nonce_value); + + // Event would store it as U256, we convert to u64 + let extracted = nonce_u256.to::(); + assert_eq!(extracted, nonce_value, "nonce should extract correctly"); + + // Test edge case: max u64 + let max_nonce = U256::from(u64::MAX); + let extracted = max_nonce.to::(); + assert_eq!(extracted, u64::MAX, "max u64 nonce should work"); + + // Test zero nonce + let zero_nonce = U256::ZERO; + let extracted = zero_nonce.to::(); + assert_eq!(extracted, 0, "zero nonce should work"); + } + + #[test] + fn deposit_processed_block_height_extraction() { + // Test block height extraction + let height_value = 9999999u64; + let height_u256 = U256::from(height_value); + let extracted = height_u256.to::(); + assert_eq!( + extracted, height_value, + "block height should extract correctly" + ); + } + + #[test] + fn tip5_from_limbs_roundtrip() { + let limbs: [u64; 5] = [ + 0x1234567890abcdef, 0xfedcba0987654321, 0x0011223344556677, 0x8899aabbccddeeff, + 0xdeadbeefcafebabe, + ]; + + let tip5 = Tip5Hash::from_limbs(&limbs); + let back = tip5.to_array(); + + assert_eq!(limbs, back, "limbs should roundtrip through Tip5Hash"); + } + + #[test] + fn tip5_from_limbs_all_zeros() { + let limbs: [u64; 5] = [0, 0, 0, 0, 0]; + let tip5 = tip5_from_limbs(&limbs); + for belt in tip5.0.iter() { + assert_eq!(belt.0, 0, "all limbs should be zero"); + } + } + + #[test] + fn tip5_from_limbs_max_values() { + let limbs: [u64; 5] = [u64::MAX, u64::MAX, u64::MAX, u64::MAX, u64::MAX]; + let tip5 = tip5_from_limbs(&limbs); + for (i, belt) in tip5.0.iter().enumerate() { + assert_eq!(belt.0, u64::MAX, "limb {} should be max u64", i); + } + } + + #[test] + fn recipient_address_extraction() { + let addr_bytes: [u8; 20] = [ + 0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe, 0x12, 0x34, 0x56, 0x78, 0x90, 0xab, + 0xcd, 0xef, 0x11, 0x22, 0x33, 0x44, + ]; + let addr = Address::from(addr_bytes); + let eth_addr = eth_address_from_alloy(addr); + + assert_eq!(eth_addr.0, addr_bytes, "address bytes should match"); + } + + #[test] + fn amount_overflow_protection() { + // Test that amounts > u64::MAX / 65536 are caught + let max_safe = u64::MAX / 65_536; + + // Just at the limit - should work + let safe_nocks = U256::from(max_safe); + assert!( + safe_nocks <= U256::from(u64::MAX / 65_536u64), + "safe value should be within limit" + ); + let amount = safe_nocks.to::().checked_mul(65_536); + assert!(amount.is_some(), "safe multiplication should succeed"); + + // Over the limit - would fail + let unsafe_nocks = U256::from(max_safe + 1); + let amount = unsafe_nocks.to::().checked_mul(65_536); + assert!(amount.is_none(), "overflow should be detected"); + } + + #[test] + fn burn_for_withdrawal_lock_root_to_tip5hash() { + // Test lock_root bytes32 → Tip5Hash conversion via Tip5Hash::from_be_bytes + // This is critical for matching withdrawals to Nockchain lock roots + + // Test with known bytes32 value + let lock_root_bytes: [u8; 32] = [ + 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, + 0x77, 0x88, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff, 0x00, 0x11, 0xde, 0xad, 0xbe, 0xef, + 0xca, 0xfe, 0xba, 0xbe, + ]; + let lock_root = B256::from(lock_root_bytes); + + // Convert using the production code path + let tip5 = Tip5Hash::from_be_bytes(&b256_to_array(lock_root)); + + // The Tip5Hash should have 5 Belt values derived from the BE bytes + // Verify the structure is valid (non-panic) + assert_eq!(tip5.0.len(), 5, "Tip5Hash should have 5 limbs"); + + // Test all-zeros + let zero_root = B256::ZERO; + let tip5_zero = Tip5Hash::from_be_bytes(&b256_to_array(zero_root)); + // All-zero input should produce all-zero Tip5Hash + for belt in tip5_zero.0.iter() { + assert_eq!(belt.0, 0, "zero input should produce zero Tip5Hash"); + } + + // Test all-0xFF (max bytes) + let max_bytes: [u8; 32] = [0xFF; 32]; + let max_root = B256::from(max_bytes); + let tip5_max = Tip5Hash::from_be_bytes(&b256_to_array(max_root)); + // Should produce valid Tip5Hash without panic + assert_eq!( + tip5_max.0.len(), + 5, + "max bytes should produce valid Tip5Hash" + ); + } + + #[test] + fn burn_for_withdrawal_full_extraction() { + // End-to-end test: create BurnForWithdrawal event, extract and convert lock_root + let burner = address_from_u64(0xcafebabe); + let lock_root_bytes: [u8; 32] = [ + 0xde, 0xad, 0xbe, 0xef, 0xca, 0xfe, 0xba, 0xbe, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, + 0xde, 0xf0, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0xaa, 0xbb, 0xcc, 0xdd, + 0xee, 0xff, 0x00, 0x11, + ]; + let lock_root = B256::from(lock_root_bytes); + let amount = U256::from(50_000_000_000_000_000u128); // 5 NOCK (5 * 10^16 base units) + + let topics = + vec![Nock::BurnForWithdrawal::SIGNATURE_HASH, address_topic(burner), lock_root]; + let mut amount_bytes = [0u8; 32]; + amount_bytes.copy_from_slice(&amount.to_be_bytes::<32>()); + let log = RawLog { + address: Address::ZERO, + topics, + data: Bytes::from(amount_bytes.to_vec()), + }; + + let event = + Nock::BurnForWithdrawal::decode_raw_log(log.topics.iter().cloned(), log.data.as_ref()) + .expect("decode burn for withdrawal"); + + // Extract and convert lock_root + let tip5_lock_root = Tip5Hash::from_be_bytes(&b256_to_array(event.lockRoot)); + + // Verify the conversion happened + assert_eq!( + tip5_lock_root.0.len(), + 5, + "lock_root should convert to 5-limb Tip5Hash" + ); + + // Verify other fields + assert_eq!(event.burner, burner, "burner should match"); + assert_eq!(event.lockRoot, lock_root, "lockRoot bytes should match"); + assert_eq!(event.amount, amount, "amount should match"); + + // Verify amount conversion (NOCK base units → nicks) + let nock_per_nick = U256::from(NOCK_BASE_PER_NICK); + let nicks = amount / nock_per_nick; + assert_eq!( + nicks, + U256::from(5u64 * 65_536), + "5 NOCK should be 5 * 65536 nicks" + ); + } + + #[test] + fn base_event_id_computation() { + // Test base_event_id computation (keccak256 of tx_hash + log_index) + let tx_hash = b256_from_u64(0xdeadbeef); + + // With log index 0 + let id0 = compute_base_event_id(&tx_hash, Some(0)); + assert_eq!(id0.0.len(), 32, "base_event_id should be 32 bytes"); + + // With log index 1 - should be different + let id1 = compute_base_event_id(&tx_hash, Some(1)); + assert_ne!( + id0.0, id1.0, + "different log indices should produce different IDs" + ); + + // With None log index (defaults to 0) + let id_none = compute_base_event_id(&tx_hash, None); + assert_eq!(id0.0, id_none.0, "None log index should equal 0"); + + // Different tx_hash should produce different ID + let tx_hash2 = b256_from_u64(0xcafebabe); + let id2 = compute_base_event_id(&tx_hash2, Some(0)); + assert_ne!( + id0.0, id2.0, + "different tx hashes should produce different IDs" + ); + } + + #[test] + fn records_deposit_metrics_on_event_processing() { + // This test verifies that when we process a DepositProcessed event, + // we correctly call record_tx_completion() to update metrics. + // The actual metrics recording is tested in state.rs tests. + // This is a characterization test to document the integration point. + use std::sync::RwLock; + + use crate::bridge_status::BridgeStatus; + + let state = BridgeStatus::new(Arc::new(RwLock::new(Vec::new()))); + + // Simulate what happens when a deposit event is processed + state.record_tx_completion(TxDirection::Deposit, 1000, 0, true); + + let metrics = state.metrics(); + assert_eq!( + metrics.total_deposited, 1000, + "should record deposit amount" + ); + assert_eq!(metrics.tx_count, 1, "should increment tx count"); + } + + #[test] + fn records_withdrawal_metrics_on_event_processing() { + // This test verifies that when we process a BurnForWithdrawal event, + // we correctly call record_tx_completion() to update metrics. + // The actual metrics recording is tested in state.rs tests. + // This is a characterization test to document the integration point. + use std::sync::RwLock; + + use crate::bridge_status::BridgeStatus; + + let state = BridgeStatus::new(Arc::new(RwLock::new(Vec::new()))); + + // Simulate what happens when a withdrawal event is processed + state.record_tx_completion(TxDirection::Withdrawal, 2000, 0, true); + + let metrics = state.metrics(); + assert_eq!( + metrics.total_withdrawn, 2000, + "should record withdrawal amount" + ); + assert_eq!(metrics.tx_count, 1, "should increment tx count"); + } +} diff --git a/crates/bridge/src/grpc.rs b/crates/bridge/src/grpc.rs new file mode 100644 index 000000000..6ff86379a --- /dev/null +++ b/crates/bridge/src/grpc.rs @@ -0,0 +1 @@ +pub const FILE_DESCRIPTOR_SET: &[u8] = tonic::include_file_descriptor_set!("bridge_descriptor"); diff --git a/crates/bridge/src/health.rs b/crates/bridge/src/health.rs new file mode 100644 index 000000000..066fefe38 --- /dev/null +++ b/crates/bridge/src/health.rs @@ -0,0 +1,439 @@ +use std::collections::HashMap; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant, SystemTime}; + +use tracing::{debug, warn}; + +use crate::bridge_status::BridgeStatus; +use crate::errors::BridgeError; +use crate::ingress::proto::bridge_ingress_client::BridgeIngressClient; +use crate::ingress::proto::HealthCheckRequest; +use crate::tui::types::AlertSeverity; +use crate::types::NodeConfig; + +#[derive(Clone, Debug)] +pub struct PeerEndpoint { + pub node_id: u64, + pub address: String, +} + +#[derive(Clone, Debug)] +pub enum NodeHealthStatus { + Healthy, + Unreachable { error: String }, +} + +#[derive(Clone, Debug)] +pub struct NodeHealthSnapshot { + pub node_id: u64, + pub address: String, + pub status: NodeHealthStatus, + pub latency_ms: Option, + pub peer_uptime_ms: Option, + pub last_updated: SystemTime, +} + +pub type SharedHealthState = Arc>>; + +#[derive(Clone)] +pub struct HealthMonitorConfig { + pub self_node_id: u64, + pub self_address: String, + pub peers: Vec, + pub poll_interval: Duration, + pub request_timeout: Duration, + pub bridge_status: Option, +} + +pub fn derive_peer_endpoints(config: &NodeConfig, self_node_id: u64) -> Vec { + config + .nodes + .iter() + .enumerate() + .filter_map(|(idx, node)| { + let node_id = idx as u64; + if node_id == self_node_id { + return None; + } + let address = normalize_endpoint(&node.ip); + Some(PeerEndpoint { node_id, address }) + }) + .collect() +} + +pub fn initialize_health_state(peers: &[PeerEndpoint]) -> SharedHealthState { + let snapshots = peers + .iter() + .map(|peer| NodeHealthSnapshot { + node_id: peer.node_id, + address: peer.address.clone(), + status: NodeHealthStatus::Unreachable { + error: "pending".into(), + }, + latency_ms: None, + peer_uptime_ms: None, + last_updated: SystemTime::now(), + }) + .collect(); + Arc::new(RwLock::new(snapshots)) +} + +pub async fn run_health_monitor( + cfg: HealthMonitorConfig, + shared: SharedHealthState, +) -> Result<(), BridgeError> { + if cfg.peers.is_empty() { + return Ok(()); + } + + // Track consecutive failures per peer + let mut failure_counts: HashMap = HashMap::new(); + + let mut interval = tokio::time::interval(cfg.poll_interval); + loop { + interval.tick().await; + for peer in &cfg.peers { + let reading = check_peer(peer, &cfg).await; + + // Get previous state before recording new reading + let was_healthy = { + if let Ok(guard) = shared.read() { + guard + .iter() + .find(|s| s.node_id == peer.node_id) + .map(|s| matches!(s.status, NodeHealthStatus::Healthy)) + .unwrap_or(false) + } else { + false + } + }; + + record_reading(&shared, peer, reading.clone()); + + // Handle alerts if TUI state is available + if let Some(ref bridge_status) = cfg.bridge_status { + match &reading.status { + NodeHealthStatus::Healthy => { + // Peer recovered + if !was_healthy { + let count = failure_counts.get(&peer.node_id).unwrap_or(&0); + if *count > 0 { + bridge_status.push_alert( + AlertSeverity::Info, + format!("Peer {} Recovered", peer.node_id), + format!( + "Peer {} ({}) is now healthy", + peer.node_id, peer.address + ), + "health_monitor".to_string(), + ); + } + failure_counts.insert(peer.node_id, 0); + } + } + NodeHealthStatus::Unreachable { error } => { + // Peer became unreachable or remains unreachable + let count = failure_counts.entry(peer.node_id).or_insert(0); + *count += 1; + + if was_healthy { + // Just became unreachable - Warning + bridge_status.push_alert( + AlertSeverity::Warning, + format!("Peer {} Unreachable", peer.node_id), + format!("Peer {} ({}): {}", peer.node_id, peer.address, error), + "health_monitor".to_string(), + ); + } else if *count >= 3 { + // Persistent failure - escalate to Error every 3rd consecutive failure + if (*count).is_multiple_of(3) { + bridge_status.push_alert( + AlertSeverity::Error, + format!("Peer {} Persistently Down", peer.node_id), + format!( + "Peer {} ({}) has failed {} consecutive health checks: {}", + peer.node_id, peer.address, count, error + ), + "health_monitor".to_string(), + ); + } + } + } + } + } + } + } +} + +#[derive(Debug, Clone)] +struct PeerReading { + status: NodeHealthStatus, + latency_ms: Option, + peer_uptime_ms: Option, +} + +async fn check_peer(peer: &PeerEndpoint, cfg: &HealthMonitorConfig) -> PeerReading { + let start = Instant::now(); + let mut client = match BridgeIngressClient::connect(peer.address.clone()).await { + Ok(client) => client, + Err(err) => { + return PeerReading { + status: NodeHealthStatus::Unreachable { + error: format!("connect failed: {}", err), + }, + latency_ms: None, + peer_uptime_ms: None, + }; + } + }; + let request = HealthCheckRequest { + requester_node_id: cfg.self_node_id, + requester_address: cfg.self_address.clone(), + }; + let response = tokio::time::timeout(cfg.request_timeout, client.health_check(request)).await; + + match response { + Ok(Ok(resp)) => { + let latency = start.elapsed().as_millis(); + let body = resp.into_inner(); + debug!( + target: "bridge.health", + peer_id=peer.node_id, + latency_ms=latency, + uptime_ms=body.uptime_millis, + "peer responded to health check" + ); + PeerReading { + status: NodeHealthStatus::Healthy, + latency_ms: Some(latency), + peer_uptime_ms: Some(body.uptime_millis), + } + } + Ok(Err(status)) => { + warn!( + target: "bridge.health", + peer_id=peer.node_id, + error=%status, + "health check RPC failed" + ); + PeerReading { + status: NodeHealthStatus::Unreachable { + error: status.to_string(), + }, + latency_ms: None, + peer_uptime_ms: None, + } + } + Err(_) => { + warn!( + target: "bridge.health", + peer_id=peer.node_id, + "health check timed out" + ); + PeerReading { + status: NodeHealthStatus::Unreachable { + error: "timeout".into(), + }, + latency_ms: None, + peer_uptime_ms: None, + } + } + } +} + +fn record_reading(state: &SharedHealthState, peer: &PeerEndpoint, reading: PeerReading) { + if let Ok(mut guard) = state.write() { + if let Some(entry) = guard + .iter_mut() + .find(|snapshot| snapshot.node_id == peer.node_id) + { + entry.status = reading.status; + entry.latency_ms = reading.latency_ms; + entry.peer_uptime_ms = reading.peer_uptime_ms; + entry.last_updated = SystemTime::now(); + } else { + guard.push(NodeHealthSnapshot { + node_id: peer.node_id, + address: peer.address.clone(), + status: reading.status, + latency_ms: reading.latency_ms, + peer_uptime_ms: reading.peer_uptime_ms, + last_updated: SystemTime::now(), + }); + } + } +} + +/// Normalize an endpoint address by adding http:// prefix if missing. +/// This is required for tonic gRPC clients which expect a full URI. +pub fn normalize_endpoint(raw: &str) -> String { + if raw.starts_with("http://") || raw.starts_with("https://") { + raw.to_string() + } else { + format!("http://{}", raw) + } +} + +/// Bridge operational status based on number of healthy nodes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DegradationLevel { + /// 5/5 or 4/5 healthy - full operational capacity + Normal, + /// 3/5 healthy - operational but degraded (at threshold) + Degraded, + /// <3/5 healthy - bridge halted (below multisig threshold) + Critical, +} + +/// Check the bridge's operational health level based on healthy peer count. +/// +/// The bridge requires 3-of-5 signatures for multisig operations. +/// This function returns the degradation level based on how many nodes are healthy. +/// +/// # Arguments +/// * `healthy_count` - Number of currently healthy nodes (including self) +/// * `total_nodes` - Total number of nodes in the bridge (typically 5) +/// +/// # Returns +/// Degradation level indicating operational capacity +pub fn check_degradation(healthy_count: usize, total_nodes: usize) -> DegradationLevel { + let threshold = 3; // 3-of-5 multisig threshold + + if healthy_count >= total_nodes.saturating_sub(1) { + // 5/5 or 4/5 - full capacity + DegradationLevel::Normal + } else if healthy_count >= threshold { + // 3/5 - at threshold, degraded but operational + DegradationLevel::Degraded + } else { + // <3/5 - below threshold, cannot operate + DegradationLevel::Critical + } +} + +/// Count healthy nodes from a health snapshot. +/// +/// # Arguments +/// * `state` - Shared health state to check +/// * `include_self` - Whether to count self as healthy (typically true) +/// +/// # Returns +/// Number of healthy nodes +pub fn count_healthy(state: &SharedHealthState, include_self: bool) -> usize { + let mut count = if include_self { 1 } else { 0 }; + + if let Ok(guard) = state.read() { + count += guard + .iter() + .filter(|s| matches!(s.status, NodeHealthStatus::Healthy)) + .count(); + } + + count +} + +#[cfg(test)] +mod tests { + use nockchain_math::belt::Belt; + use nockchain_types::tx_engine::common::Hash as NockPkh; + + use super::*; + use crate::types::{AtomBytes, NodeConfig, NodeInfo, SchnorrSecretKey}; + + #[test] + fn derive_peers_skips_self() { + let config = NodeConfig { + node_id: 1, + nodes: vec![ + NodeInfo { + ip: "localhost:8001".into(), + eth_pubkey: AtomBytes(vec![]), + // Fake test PKH (valid format placeholder) + nock_pkh: NockPkh::from_base58( + "2222222222222222222222222222222222222222222222222222", + ) + .expect("pkh"), + }, + NodeInfo { + ip: "localhost:8002".into(), + eth_pubkey: AtomBytes(vec![]), + // Fake test PKH (valid format placeholder) + nock_pkh: NockPkh::from_base58( + "3333333333333333333333333333333333333333333333333333", + ) + .expect("pkh"), + }, + ], + my_eth_key: AtomBytes(vec![]), + my_nock_key: SchnorrSecretKey([Belt(0); 8]), + }; + let peers = derive_peer_endpoints(&config, 1); + assert_eq!(peers.len(), 1); + assert_eq!(peers[0].node_id, 0); + assert_eq!(peers[0].address, "http://localhost:8001"); + } + + #[test] + fn test_check_degradation_normal() { + // 5/5 - full capacity + assert_eq!(check_degradation(5, 5), DegradationLevel::Normal); + + // 4/5 - one node down but still healthy + assert_eq!(check_degradation(4, 5), DegradationLevel::Normal); + } + + #[test] + fn test_check_degradation_degraded() { + // 3/5 - at threshold, degraded but operational + assert_eq!(check_degradation(3, 5), DegradationLevel::Degraded); + } + + #[test] + fn test_check_degradation_critical() { + // 2/5 - below threshold, cannot operate + assert_eq!(check_degradation(2, 5), DegradationLevel::Critical); + + // 1/5 - severely degraded + assert_eq!(check_degradation(1, 5), DegradationLevel::Critical); + + // 0/5 - all nodes down + assert_eq!(check_degradation(0, 5), DegradationLevel::Critical); + } + + #[test] + fn test_count_healthy() { + let peers = vec![ + PeerEndpoint { + node_id: 0, + address: "http://localhost:8001".into(), + }, + PeerEndpoint { + node_id: 2, + address: "http://localhost:8002".into(), + }, + ]; + let state = initialize_health_state(&peers); + + // Initially all unreachable, only self is healthy + assert_eq!(count_healthy(&state, true), 1); + assert_eq!(count_healthy(&state, false), 0); + + // Make one peer healthy + { + let mut guard = state.write().unwrap(); + guard[0].status = NodeHealthStatus::Healthy; + } + + assert_eq!(count_healthy(&state, true), 2); + assert_eq!(count_healthy(&state, false), 1); + + // Make both peers healthy + { + let mut guard = state.write().unwrap(); + guard[1].status = NodeHealthStatus::Healthy; + } + + assert_eq!(count_healthy(&state, true), 3); + assert_eq!(count_healthy(&state, false), 2); + } +} diff --git a/crates/bridge/src/ingress.rs b/crates/bridge/src/ingress.rs new file mode 100644 index 000000000..c0dc33f6d --- /dev/null +++ b/crates/bridge/src/ingress.rs @@ -0,0 +1,1552 @@ +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +use alloy::primitives::{Address, Signature as AlloySignature, B256}; +use hex::encode; +use tonic::{async_trait, Request, Response, Status}; +use tonic_reflection::server::Builder as ReflectionBuilder; +use tracing::{debug, info, warn}; + +use crate::bridge_status::BridgeStatus; +use crate::errors::BridgeError; +use crate::metrics; +use crate::proposal_cache::{ProposalCache, SignatureAddResult}; +use crate::runtime::BridgeRuntimeHandle; +use crate::signing::BridgeSigner; +use crate::status::{BridgeStatusState, StatusService}; +use crate::tui::types::{AlertSeverity, Proposal, ProposalStatus}; +use crate::tui_api::proto::bridge_tui_server::BridgeTuiServer; +use crate::tui_api::BridgeTuiService; +use crate::types::DepositId; + +pub mod proto { + tonic::include_proto!("bridge.ingress.v1"); +} + +use proto::bridge_ingress_server::{BridgeIngress, BridgeIngressServer}; +use proto::{ + ConfirmationBroadcast, ConfirmationBroadcastResponse, HealthCheckRequest, HealthCheckResponse, + ProposalStatusRequest, ProposalStatusResponse, SignatureBroadcast, SignatureBroadcastResponse, + StopBroadcast, StopBroadcastResponse, +}; + +use crate::status::proto::bridge_status_server::BridgeStatusServer; + +pub fn spawn_broadcast_stop_to_peers( + peers: &[crate::health::PeerEndpoint], + msg: StopBroadcast, + component: &'static str, +) { + use tracing::{info, warn}; + + use crate::ingress::proto::bridge_ingress_client::BridgeIngressClient; + + for peer in peers { + let addr = peer.address.clone(); + let peer_id = peer.node_id; + let msg = msg.clone(); + // Note: there is no retry logic here, we fire-and-forget. + tokio::spawn(async move { + match BridgeIngressClient::connect(addr.clone()).await { + Ok(mut client) => match client.broadcast_stop(msg).await { + Ok(_) => { + info!(component, peer_node_id = peer_id, "broadcast stop to peer"); + } + Err(e) => { + warn!( + component, + peer_node_id=peer_id, + error=%e, + "failed to broadcast stop to peer" + ); + } + }, + Err(e) => { + warn!( + component, + peer_node_id=peer_id, + peer_address=%addr, + error=%e, + "failed to connect to peer for stop broadcast" + ); + } + } + }); + } +} + +pub struct IngressService { + runtime: Arc, + node_id: u64, + start_time: Instant, + /// Signer for creating Ethereum signatures on proposals + signer: Arc, + /// Cache for aggregating signatures from multiple bridge nodes + proposal_cache: Arc, + /// Shared TUI state for updating proposal display on peer broadcasts + bridge_status: BridgeStatus, + /// Mapping from Ethereum address to node ID for TUI signature display + address_to_node_id: std::collections::HashMap, + stop_controller: crate::stop::StopController, + peers: Vec, +} + +impl IngressService { + #[allow(clippy::too_many_arguments)] + fn new( + node_id: u64, + runtime: Arc, + signer: Arc, + proposal_cache: Arc, + bridge_status: BridgeStatus, + address_to_node_id: std::collections::HashMap, + stop_controller: crate::stop::StopController, + peers: Vec, + ) -> Self { + Self { + runtime, + node_id, + start_time: Instant::now(), + signer, + proposal_cache, + bridge_status, + address_to_node_id, + stop_controller, + peers, + } + } + + fn uptime_millis(&self) -> u64 { + self.start_time.elapsed().as_millis() as u64 + } + + async fn trigger_stop( + &self, + reason: String, + last: Option, + source: crate::stop::StopSource, + broadcast: bool, + ) { + use std::time::{SystemTime, UNIX_EPOCH}; + + use tracing::warn; + + use crate::stop::{StopInfo, StopSource}; + use crate::tui::types::AlertSeverity; + + let resolved_last = match last { + Some(last) => Some(last), + None => match self.runtime.peek_stop_info().await { + Ok(last) => last, + Err(err) => { + warn!( + target: "bridge.ingress", + error=%err, + "failed to peek stop-info while triggering stop" + ); + None + } + }, + }; + + let info = StopInfo { + reason: reason.clone(), + last: resolved_last.clone(), + source, + at: SystemTime::now(), + }; + + if !self.stop_controller.trigger(info) { + return; + } + + self.bridge_status.push_alert( + AlertSeverity::Error, + "Bridge Stopped".to_string(), + reason.clone(), + match source { + StopSource::KernelEffect => "kernel-stop".to_string(), + StopSource::PeerBroadcast => "peer-stop".to_string(), + StopSource::Local => "local-stop".to_string(), + }, + ); + + if let Some(last) = resolved_last.clone() { + if let Err(err) = self.runtime.send_stop(last).await { + warn!( + target: "bridge.ingress", + error=%err, + "failed to poke kernel with stop cause" + ); + } + } else { + warn!( + target: "bridge.ingress", + "stop triggered without stop-info; skipping kernel stop poke" + ); + } + + if !broadcast { + return; + } + + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let (last_base_hash, last_base_height, last_nock_hash, last_nock_height) = + if let Some(ref last) = resolved_last { + ( + Some(last.base.base_hash.to_be_limb_bytes().to_vec()), + Some(last.base.height), + Some(last.nock.nock_hash.to_be_limb_bytes().to_vec()), + Some(last.nock.height), + ) + } else { + (None, None, None, None) + }; + + let msg = StopBroadcast { + sender_node_id: self.node_id, + reason: reason.clone(), + last_base_hash, + last_base_height, + last_nock_hash, + last_nock_height, + timestamp, + }; + + spawn_broadcast_stop_to_peers(&self.peers, msg, "bridge.ingress"); + } + + /// Verify an Ethereum signature and recover the signer address. + /// Returns Some(address) if signature is valid, None otherwise. + fn verify_signature(proposal_hash: &[u8; 32], signature: &[u8]) -> Option
{ + if signature.len() != 65 { + warn!( + target: "bridge.ingress", + sig_len = signature.len(), + "invalid signature length, expected 65 bytes" + ); + return None; + } + + // Extract r, s, v from 65-byte signature + let mut r = [0u8; 32]; + let mut s = [0u8; 32]; + r.copy_from_slice(&signature[0..32]); + s.copy_from_slice(&signature[32..64]); + let v = signature[64]; + + // v must be 27 or 28 for Ethereum signatures + if v != 27 && v != 28 { + warn!( + target: "bridge.ingress", + v = v, + "invalid signature v value, expected 27 or 28" + ); + return None; + } + + let y_parity = v == 28; + let sig = AlloySignature::new( + alloy::primitives::U256::from_be_bytes(r), + alloy::primitives::U256::from_be_bytes(s), + y_parity, + ); + + // EIP-191 recovery (matches sign_hash in signing.rs which uses sign_message) + let hash = B256::from_slice(proposal_hash); + match sig.recover_address_from_msg(hash.as_slice()) { + Ok(addr) => Some(addr), + Err(e) => { + warn!( + target: "bridge.ingress", + error = %e, + sig_r = %hex::encode(r), + sig_s = %hex::encode(s), + sig_v = v, + hash = %hex::encode(proposal_hash), + "failed to recover address from signature" + ); + None + } + } + } +} + +#[async_trait] +impl BridgeIngress for IngressService { + async fn health_check( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + debug!( + target: "bridge.ingress", + requester_id=req.requester_node_id, + requester_addr=req.requester_address, + "received health check" + ); + let timestamp_millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or_default(); + let response = HealthCheckResponse { + responder_node_id: self.node_id, + uptime_millis: self.uptime_millis(), + status: "healthy".into(), + timestamp_millis, + }; + Ok(Response::new(response)) + } + + async fn broadcast_signature( + &self, + request: Request, + ) -> Result, Status> { + let metrics = metrics::init_metrics(); + metrics.ingress_broadcast_signature_requests.increment(); + let req = request.into_inner(); + + // Validate inputs + if req.deposit_id.len() != 120 { + metrics + .ingress_broadcast_signature_invalid_deposit_id_len + .increment(); + return Ok(Response::new(SignatureBroadcastResponse { + accepted: false, + error: format!( + "invalid deposit_id length: expected 120, got {}", + req.deposit_id.len() + ), + })); + } + if req.proposal_hash.len() != 32 { + metrics + .ingress_broadcast_signature_invalid_proposal_hash_len + .increment(); + return Ok(Response::new(SignatureBroadcastResponse { + accepted: false, + error: format!( + "invalid proposal_hash length: expected 32, got {}", + req.proposal_hash.len() + ), + })); + } + if req.signature.len() != 65 { + metrics + .ingress_broadcast_signature_invalid_signature_len + .increment(); + return Ok(Response::new(SignatureBroadcastResponse { + accepted: false, + error: format!( + "invalid signature length: expected 65, got {}", + req.signature.len() + ), + })); + } + if req.signer_address.len() != 20 { + metrics + .ingress_broadcast_signature_invalid_signer_address_len + .increment(); + return Ok(Response::new(SignatureBroadcastResponse { + accepted: false, + error: format!( + "invalid signer_address length: expected 20, got {}", + req.signer_address.len() + ), + })); + } + + // Deserialize deposit_id + let deposit_id = match DepositId::from_bytes(&req.deposit_id) { + Ok(id) => id, + Err(e) => { + metrics + .ingress_broadcast_signature_invalid_deposit_id_decode + .increment(); + return Ok(Response::new(SignatureBroadcastResponse { + accepted: false, + error: format!("failed to deserialize deposit_id: {}", e), + })); + } + }; + + let signer_address = Address::from_slice(&req.signer_address); + let mut proposal_hash = [0u8; 32]; + proposal_hash.copy_from_slice(&req.proposal_hash); + + if signer_address == self.signer.address() { + metrics.ingress_broadcast_signature_ignored_self.increment(); + tracing::debug!( + target: "bridge.ingress", + signer=%signer_address, + "ignoring self signature broadcast" + ); + return Ok(Response::new(SignatureBroadcastResponse { + accepted: true, + error: String::new(), + })); + } + + let signer_is_known = self.address_to_node_id.contains_key(&signer_address); + if signer_is_known { + metrics.ingress_broadcast_signature_known_signer.increment(); + } else { + metrics.ingress_broadcast_signature_unknown_signer.increment(); + } + + info!( + target: "bridge.ingress", + deposit_id = ?deposit_id, + deposit_id_bytes = %encode(&req.deposit_id), + signer = %signer_address, + proposal_hash = %encode(proposal_hash), + timestamp = req.timestamp, + "received signature broadcast" + ); + + let existing_state = match self.proposal_cache.get_state(&deposit_id) { + Ok(state) => state, + Err(err) => { + warn!( + target: "bridge.ingress", + error=%err, + "failed to read proposal cache state" + ); + None + } + }; + + if let Some(state) = existing_state.as_ref() { + metrics.ingress_broadcast_signature_known_proposal.increment(); + if !signer_is_known { + metrics + .ingress_broadcast_signature_unknown_signer_known_proposal + .increment(); + } + if state.proposal_hash != proposal_hash { + metrics.ingress_broadcast_signature_hash_mismatch.increment(); + let expected_hex = encode(state.proposal_hash); + let received_hex = encode(proposal_hash); + warn!( + target: "bridge.ingress", + deposit_id = %encode(&req.deposit_id), + signer = %signer_address, + expected_hash = %expected_hex, + received_hash = %received_hex, + "peer signature proposal hash mismatch, possible nonce divergence" + ); + self.bridge_status.push_alert( + AlertSeverity::Error, + "Nonce Divergence Suspected".to_string(), + format!( + "Peer signature proposal hash mismatch for deposit {}. expected={}, received={}, signer={}", + encode(&req.deposit_id), + expected_hex, + received_hex, + signer_address + ), + "nonce-divergence".to_string(), + ); + return Ok(Response::new(SignatureBroadcastResponse { + accepted: false, + error: "proposal hash mismatch (possible nonce divergence)".to_string(), + })); + } + } else { + metrics.ingress_broadcast_signature_unknown_proposal.increment(); + } + + // TODO: Verify signer is authorized bridge node (check against node config) + // For now, we'll accept any signature and let the verification fail if invalid + + // Add signature to cache (or queue if we haven't processed this deposit yet) + // The verify_fn will check that signature recovers to claimed signer + let result = self.proposal_cache.add_signature( + &deposit_id, + crate::proposal_cache::SignatureData { + signer_address, + signature: req.signature.clone(), + proposal_hash, + is_mine: false, // not our signature + }, + None, // No proposal data - we generate our own from kernel + Self::verify_signature, + ); + + // Convert proposal_hash to hex string for TUI lookup + let proposal_hash_hex = encode(proposal_hash); + + match result { + Ok(SignatureAddResult::Added) => { + metrics.ingress_broadcast_signature_result_added.increment(); + info!( + target: "bridge.ingress", + deposit_id = ?deposit_id, + signer = %signer_address, + "signature added to cache" + ); + + if let Ok(Some(state)) = self.proposal_cache.get_state(&deposit_id) { + self.bridge_status.sync_proposal_signatures_from_cache( + &proposal_hash_hex, &state, &self.address_to_node_id, self.node_id, + ); + } + + Ok(Response::new(SignatureBroadcastResponse { + accepted: true, + error: String::new(), + })) + } + Ok(SignatureAddResult::ThresholdReached) => { + metrics + .ingress_broadcast_signature_result_threshold_reached + .increment(); + info!( + target: "bridge.ingress", + deposit_id = ?deposit_id, + signer = %signer_address, + "signature added - threshold reached!" + ); + + if let Ok(Some(state)) = self.proposal_cache.get_state(&deposit_id) { + self.bridge_status.sync_proposal_signatures_from_cache( + &proposal_hash_hex, &state, &self.address_to_node_id, self.node_id, + ); + } + + Ok(Response::new(SignatureBroadcastResponse { + accepted: true, + error: String::new(), + })) + } + Ok(SignatureAddResult::Duplicate) => { + metrics + .ingress_broadcast_signature_result_duplicate + .increment(); + info!( + target: "bridge.ingress", + deposit_id = ?deposit_id, + signer = %signer_address, + "duplicate signature ignored" + ); + Ok(Response::new(SignatureBroadcastResponse { + accepted: true, // not an error, just already have it + error: String::new(), + })) + } + Ok(SignatureAddResult::Stale) => { + metrics.ingress_broadcast_signature_result_stale.increment(); + info!( + target: "bridge.ingress", + deposit_id = ?deposit_id, + signer = %signer_address, + "deposit proposal stale (already confirmed), rejecting signature" + ); + Ok(Response::new(SignatureBroadcastResponse { + accepted: false, + error: "deposit proposal stale (already confirmed)".to_string(), + })) + } + Ok(SignatureAddResult::Invalid(msg)) => { + metrics.ingress_broadcast_signature_result_invalid.increment(); + warn!( + target: "bridge.ingress", + deposit_id = ?deposit_id, + signer = %signer_address, + error = %msg, + "signature verification failed" + ); + Ok(Response::new(SignatureBroadcastResponse { + accepted: false, + error: msg, + })) + } + Err(e) => { + metrics.ingress_broadcast_signature_result_error.increment(); + // This can happen if peer broadcasts signature before we've seen the deposit + // (race condition) - not a real error, peer should retry + warn!( + target: "bridge.ingress", + deposit_id = ?deposit_id, + signer = %signer_address, + error = %e, + "cannot add signature to cache (peer may retry)" + ); + Ok(Response::new(SignatureBroadcastResponse { + accepted: false, + error: e, + })) + } + } + } + + async fn get_proposal_status( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + // Validate deposit_id + if req.deposit_id.len() != 120 { + return Err(Status::invalid_argument(format!( + "invalid deposit_id length: expected 120, got {}", + req.deposit_id.len() + ))); + } + + // Deserialize deposit_id + let deposit_id = DepositId::from_bytes(&req.deposit_id).map_err(|e| { + Status::invalid_argument(format!("failed to deserialize deposit_id: {}", e)) + })?; + + // Get proposal state from cache + let state = self + .proposal_cache + .get_state(&deposit_id) + .map_err(|e| Status::internal(format!("failed to get proposal state: {}", e)))?; + + match state { + Some(state) => { + // Convert status enum to string + let status_str = match state.status { + crate::proposal_cache::ProposalStatus::Collecting => "collecting", + crate::proposal_cache::ProposalStatus::Ready => "ready", + crate::proposal_cache::ProposalStatus::Posting => "posting", + crate::proposal_cache::ProposalStatus::Confirmed => "confirmed", + crate::proposal_cache::ProposalStatus::Failed => "failed", + }; + + let signature_count = (state.peer_signatures.len() + + if state.my_signature.is_some() { 1 } else { 0 }) + as u32; + + // Collect signer addresses + let mut signers = Vec::new(); + if state.my_signature.is_some() { + signers.push(self.signer.address().to_vec()); + } + for addr in state.peer_signatures.keys() { + signers.push(addr.to_vec()); + } + + info!( + target: "bridge.ingress", + deposit_id = ?deposit_id, + status = status_str, + signature_count = signature_count, + "retrieved proposal status" + ); + + Ok(Response::new(ProposalStatusResponse { + status: status_str.to_string(), + signature_count, + signers, + tx_hash: None, // TODO: add tx_hash tracking when we integrate with poster + })) + } + None => { + info!( + target: "bridge.ingress", + deposit_id = ?deposit_id, + "proposal not found in cache" + ); + Ok(Response::new(ProposalStatusResponse { + status: "not_found".to_string(), + signature_count: 0, + signers: vec![], + tx_hash: None, + })) + } + } + } + + /// Handle confirmation broadcast from the node that posted to BASE. + /// This allows non-proposer nodes to mark proposals as confirmed and stop + /// waiting for their failover turn. + async fn broadcast_confirmation( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + // Parse deposit_id from bytes + let deposit_id = match DepositId::from_bytes(&req.deposit_id) { + Ok(id) => id, + Err(e) => { + warn!( + target: "bridge.ingress", + error = %e, + "failed to parse deposit_id from confirmation broadcast" + ); + return Ok(Response::new(ConfirmationBroadcastResponse { + accepted: false, + })); + } + }; + + let proposal_hash = hex::encode(&req.proposal_hash); + let tx_hash = hex::encode(&req.tx_hash); + + info!( + target: "bridge.ingress", + deposit_id = ?deposit_id, + proposal_hash = %proposal_hash, + tx_hash = %tx_hash, + block_number = req.block_number, + "received confirmation broadcast" + ); + + // Mark the proposal as confirmed in our cache + match self.proposal_cache.mark_confirmed(&deposit_id) { + Ok(()) => { + info!( + target: "bridge.ingress", + deposit_id = ?deposit_id, + proposal_hash = %proposal_hash, + "marked proposal as confirmed from broadcast" + ); + + // Update TUI to show Executed status + // Try to find existing proposal and update it + if let Some(mut tui_proposal) = self.bridge_status.find_proposal(&proposal_hash) { + tui_proposal.status = ProposalStatus::Executed; + tui_proposal.tx_hash = Some(tx_hash.clone()); + tui_proposal.executed_at_block = Some(req.block_number); + self.bridge_status.update_proposal(tui_proposal); + info!( + target: "bridge.ingress", + proposal_hash = %proposal_hash, + "updated TUI proposal to Executed status" + ); + } else { + // Confirmation arrived before we processed the deposit locally + // Create a minimal placeholder proposal so TUI shows something + let placeholder = Proposal { + id: proposal_hash.clone(), + proposal_type: "deposit".to_string(), + description: "Confirmed via peer broadcast".to_string(), + signatures_collected: 3, // threshold reached + signatures_required: 3, + signers: vec![], + created_at: std::time::SystemTime::now(), + status: ProposalStatus::Executed, + data_hash: proposal_hash.clone(), + submitted_at_block: Some(req.block_number), + submitted_at: Some(std::time::SystemTime::now()), + tx_hash: Some(tx_hash.clone()), + time_to_submit_ms: None, + executed_at_block: Some(req.block_number), + source_block: None, + amount: None, + recipient: None, + nonce: None, + source_tx_id: None, + current_proposer: None, + is_my_turn: false, + time_until_takeover: None, + }; + self.bridge_status.update_proposal(placeholder); + info!( + target: "bridge.ingress", + proposal_hash = %proposal_hash, + "created placeholder TUI proposal for early confirmation" + ); + } + + Ok(Response::new(ConfirmationBroadcastResponse { + accepted: true, + })) + } + Err(e) => { + warn!( + target: "bridge.ingress", + error = %e, + deposit_id = ?deposit_id, + "failed to mark proposal as confirmed" + ); + Ok(Response::new(ConfirmationBroadcastResponse { + accepted: false, + })) + } + } + } + + async fn broadcast_stop( + &self, + request: Request, + ) -> Result, Status> { + let req = request.into_inner(); + + let last_base_hash_src = match req.last_base_hash.as_ref() { + Some(bytes) => bytes.as_slice(), + None => &[], + }; + if let Some(ref bytes) = req.last_base_hash { + if bytes.len() != 40 { + warn!( + target: "bridge.ingress", + len=bytes.len(), + "received stop broadcast with malformed last_base_hash; decoding lossy" + ); + } + } + + let last_nock_hash_src = match req.last_nock_hash.as_ref() { + Some(bytes) => bytes.as_slice(), + None => &[], + }; + if let Some(ref bytes) = req.last_nock_hash { + if bytes.len() != 40 { + warn!( + target: "bridge.ingress", + len=bytes.len(), + "received stop broadcast with malformed last_nock_hash; decoding lossy" + ); + } + } + + let last = match ( + req.last_base_hash.as_ref(), + req.last_base_height, + req.last_nock_hash.as_ref(), + req.last_nock_height, + ) { + (Some(_), Some(base_height), Some(_), Some(nock_height)) => { + let mut last_base_hash_bytes = [0u8; 40]; + let base_copy_len = + std::cmp::min(last_base_hash_src.len(), last_base_hash_bytes.len()); + last_base_hash_bytes[..base_copy_len] + .copy_from_slice(&last_base_hash_src[..base_copy_len]); + let base_hash = + crate::types::Tip5Hash::from_be_limb_bytes(&last_base_hash_bytes).ok(); + + let mut last_nock_hash_bytes = [0u8; 40]; + let nock_copy_len = + std::cmp::min(last_nock_hash_src.len(), last_nock_hash_bytes.len()); + last_nock_hash_bytes[..nock_copy_len] + .copy_from_slice(&last_nock_hash_src[..nock_copy_len]); + let nock_hash = + crate::types::Tip5Hash::from_be_limb_bytes(&last_nock_hash_bytes).ok(); + + match (base_hash, nock_hash) { + (Some(base_hash), Some(nock_hash)) => Some(crate::types::StopLastBlocks { + base: crate::types::StopTipBase { + base_hash, + height: base_height, + }, + nock: crate::types::StopTipNock { + nock_hash, + height: nock_height, + }, + }), + _ => None, + } + } + _ => None, + }; + + info!( + target: "bridge.ingress", + sender_node_id=req.sender_node_id, + reason=%req.reason, + // The timestamp here is purely informational and does not affect the stop process. + timestamp=req.timestamp, + "received stop broadcast" + ); + + self.trigger_stop( + format!("peer {} requested stop: {}", req.sender_node_id, req.reason), + last, + crate::stop::StopSource::PeerBroadcast, + false, + ) + .await; + + Ok(Response::new(StopBroadcastResponse { accepted: true })) + } +} + +#[allow(clippy::too_many_arguments)] +pub async fn serve_ingress( + addr: SocketAddr, + node_id: u64, + runtime: Arc, + status_state: BridgeStatusState, + deposit_log: Arc, + nonce_epoch: crate::config::NonceEpochConfig, + signer: Arc, + proposal_cache: Arc, + bridge_status: BridgeStatus, + address_to_node_id: std::collections::HashMap, + stop_controller: crate::stop::StopController, + peers: Vec, +) -> Result<(), BridgeError> { + info!( + target: "bridge.ingress", + %addr, + "starting bridge ingress gRPC server" + ); + let status_service = StatusService::new( + status_state.clone(), + deposit_log.clone(), + nonce_epoch.clone(), + bridge_status.clone(), + ); + let tui_service = BridgeTuiService::new( + bridge_status.clone(), + status_state, + deposit_log, + nonce_epoch, + ) + .await?; + let reflection_service = ReflectionBuilder::configure() + .register_encoded_file_descriptor_set(crate::grpc::FILE_DESCRIPTOR_SET) + .build_v1() + .map_err(|err| BridgeError::Runtime(format!("reflection build error: {}", err)))?; + let service = IngressService::new( + node_id, runtime, signer, proposal_cache, bridge_status, address_to_node_id, + stop_controller, peers, + ); + tonic::transport::Server::builder() + .add_service(reflection_service) + .add_service(BridgeIngressServer::new(service)) + .add_service(BridgeStatusServer::new(status_service)) + .add_service(BridgeTuiServer::new(tui_service)) + .serve(addr) + .await + .map_err(|err| BridgeError::Runtime(format!("ingress server error: {}", err))) +} + +#[allow(clippy::too_many_arguments)] +pub async fn serve_ingress_with_shutdown( + addr: SocketAddr, + node_id: u64, + runtime: Arc, + status_state: BridgeStatusState, + deposit_log: Arc, + nonce_epoch: crate::config::NonceEpochConfig, + signer: Arc, + proposal_cache: Arc, + bridge_status: BridgeStatus, + address_to_node_id: std::collections::HashMap, + stop_controller: crate::stop::StopController, + peers: Vec, + shutdown: tokio::sync::oneshot::Receiver<()>, +) -> Result<(), BridgeError> { + info!( + target: "bridge.ingress", + %addr, + "starting bridge ingress gRPC server with shutdown" + ); + let status_service = StatusService::new( + status_state.clone(), + deposit_log.clone(), + nonce_epoch.clone(), + bridge_status.clone(), + ); + let tui_service = BridgeTuiService::new( + bridge_status.clone(), + status_state, + deposit_log, + nonce_epoch, + ) + .await?; + let reflection_service = ReflectionBuilder::configure() + .register_encoded_file_descriptor_set(crate::grpc::FILE_DESCRIPTOR_SET) + .build_v1() + .map_err(|err| BridgeError::Runtime(format!("reflection build error: {}", err)))?; + let service = IngressService::new( + node_id, runtime, signer, proposal_cache, bridge_status, address_to_node_id, + stop_controller, peers, + ); + tonic::transport::Server::builder() + .add_service(reflection_service) + .add_service(BridgeIngressServer::new(service)) + .add_service(BridgeStatusServer::new(status_service)) + .add_service(BridgeTuiServer::new(tui_service)) + .serve_with_shutdown(addr, async move { + let _ = shutdown.await; + }) + .await + .map_err(|err| BridgeError::Runtime(format!("ingress server error: {}", err))) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::{Arc, RwLock}; + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + + use nockchain_types::v1::Name; + use tempfile::TempDir; + use tokio::time::{sleep, Instant}; + use tonic::Request; + + use super::*; + use crate::config::NonceEpochConfig; + use crate::deposit_log::{DepositLog, DepositLogEntry}; + use crate::health::SharedHealthState; + use crate::proposal_cache::{ + PendingSignatureReport, ProposalCache, ProposalStatus, SignatureAddResult, SignatureData, + }; + use crate::runtime::{ + BridgeEvent, BridgeRuntime, BridgeRuntimeHandle, CauseBuildOutcome, CauseBuilder, + EventEnvelope, + }; + use crate::types::{DepositId, EthAddress, NockDepositRequestData, Tip5Hash}; + + // Test private key (same as in signing.rs tests) + const TEST_PRIVATE_KEY: &str = + "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; + const TEST_PRIVATE_KEYS: [&str; 3] = [ + "4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318", + "5c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362319", + "6c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f36231a", + ]; + + fn test_bridge_signer() -> Arc { + Arc::new( + BridgeSigner::new(format!("0x{}", TEST_PRIVATE_KEY)) + .expect("valid test key for BridgeSigner"), + ) + } + + fn test_bridge_signer_for(key: &str) -> Arc { + Arc::new(BridgeSigner::new(format!("0x{}", key)).expect("valid test key for BridgeSigner")) + } + + fn test_bridge_status() -> BridgeStatus { + let health: SharedHealthState = Arc::new(RwLock::new(Vec::new())); + BridgeStatus::new(health) + } + + fn test_address_map() -> std::collections::HashMap { + std::collections::HashMap::new() + } + + // Type synonym for test runtime setup + type RuntimeTaskHandle = tokio::task::JoinHandle>; + + struct NoOpBuilder; + + impl CauseBuilder for NoOpBuilder { + fn build_poke( + &self, + _event: &EventEnvelope, + ) -> Result { + Ok(CauseBuildOutcome::Deferred("test".into())) + } + } + + fn make_runtime() -> (RuntimeTaskHandle, Arc) { + let builder = Arc::new(NoOpBuilder); + let (runtime, handle) = BridgeRuntime::new(builder); + let runtime_handle = Arc::new(handle); + let task = tokio::spawn(runtime.run()); + (task, runtime_handle) + } + + struct TestNode { + signer: Arc, + proposal_cache: Arc, + bridge_status: BridgeStatus, + deposit_log: Arc, + _data_dir: TempDir, + _runtime_task: RuntimeTaskHandle, + service: IngressService, + } + + impl TestNode { + async fn new( + node_id: u64, + signer: Arc, + address_map: HashMap, + ) -> Result { + let (_runtime_task, runtime_handle) = make_runtime(); + let proposal_cache = Arc::new(ProposalCache::new()); + let bridge_status = test_bridge_status(); + let (stop_controller, _stop_handle) = crate::stop::StopController::new(); + let data_dir = tempfile::tempdir() + .map_err(|e| BridgeError::Runtime(format!("failed to create temp dir: {}", e)))?; + let deposit_log_path = data_dir.path().join("deposit-log.sqlite"); + let deposit_log = Arc::new(DepositLog::open(deposit_log_path).await?); + let service = IngressService::new( + node_id, + runtime_handle, + signer.clone(), + proposal_cache.clone(), + bridge_status.clone(), + address_map, + stop_controller, + Vec::new(), + ); + Ok(Self { + signer, + proposal_cache, + bridge_status, + deposit_log, + _data_dir: data_dir, + _runtime_task, + service, + }) + } + + async fn insert_entries(&self, entries: &[DepositLogEntry]) { + for entry in entries { + self.deposit_log.insert_entry(entry).await.unwrap(); + } + } + + async fn build_request( + &self, + epoch: &NonceEpochConfig, + next_nonce: u64, + ) -> NockDepositRequestData { + let mut records = self + .deposit_log + .records_from_nonce(next_nonce, 1, epoch) + .await + .expect("deposit log query failed"); + let (nonce, entry) = records.pop().expect("missing deposit log entry"); + NockDepositRequestData { + tx_id: entry.tx_id, + name: entry.name, + recipient: entry.recipient, + amount: entry.amount_to_mint, + block_height: entry.block_height, + as_of: entry.as_of, + nonce, + } + } + + async fn sign_request(&self, req: &NockDepositRequestData) -> Vec { + let hash = req.compute_proposal_hash(); + self.signer + .sign_hash(&hash) + .await + .expect("signing failed") + .as_bytes() + .to_vec() + } + + fn add_signature( + &self, + req: &NockDepositRequestData, + signature: Vec, + is_mine: bool, + ) -> SignatureAddResult { + let deposit_id = DepositId::from_effect_payload(req); + let proposal_hash = req.compute_proposal_hash(); + self.proposal_cache + .add_signature( + &deposit_id, + SignatureData { + signer_address: self.signer.address(), + signature, + proposal_hash, + is_mine, + }, + Some(req.clone()), + IngressService::verify_signature, + ) + .expect("failed to add signature") + } + + fn apply_pending(&self, deposit_id: &DepositId) -> PendingSignatureReport { + self.proposal_cache + .apply_pending_signatures(deposit_id, IngressService::verify_signature) + .expect("failed to apply pending signatures") + } + + async fn broadcast_signature(&self, msg: SignatureBroadcast) -> bool { + let response = self + .service + .broadcast_signature(Request::new(msg)) + .await + .expect("broadcast failed"); + response.into_inner().accepted + } + } + + fn make_hash(seed: u64) -> Tip5Hash { + Tip5Hash::from_limbs(&[seed, seed + 1, seed + 2, seed + 3, seed + 4]) + } + + fn make_entry( + block_height: u64, + seed: u64, + recipient_byte: u8, + amount: u64, + ) -> DepositLogEntry { + DepositLogEntry { + block_height, + tx_id: make_hash(seed), + as_of: make_hash(seed + 1000), + name: Name::new(make_hash(seed + 2000), make_hash(seed + 3000)), + recipient: EthAddress([recipient_byte; 20]), + amount_to_mint: amount, + } + } + + fn make_broadcast( + deposit_id: &DepositId, + proposal_hash: [u8; 32], + signature: Vec, + signer_address: Address, + ) -> SignatureBroadcast { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + SignatureBroadcast { + deposit_id: deposit_id.to_bytes(), + proposal_hash: proposal_hash.to_vec(), + signature, + signer_address: signer_address.as_slice().to_vec(), + timestamp, + } + } + + async fn wait_for_ready(node: &TestNode, deposit_id: &DepositId) { + let deadline = Instant::now() + Duration::from_secs(2); + loop { + if let Ok(Some(state)) = node.proposal_cache.get_state(deposit_id) { + if state.status == ProposalStatus::Ready && state.has_threshold() { + return; + } + } + if Instant::now() > deadline { + panic!("proposal never reached Ready status"); + } + sleep(Duration::from_millis(20)).await; + } + } + + #[tokio::test] + async fn health_check_reports_node_details() -> Result<(), BridgeError> { + let (_task, runtime_handle) = make_runtime(); + let cache = Arc::new(ProposalCache::new()); + let (stop_controller, _stop_handle) = crate::stop::StopController::new(); + let service = IngressService::new( + 3, + runtime_handle.clone(), + test_bridge_signer(), + cache, + test_bridge_status(), + test_address_map(), + stop_controller, + vec![], + ); + let response = service + .health_check(Request::new(HealthCheckRequest { + requester_node_id: 2, + requester_address: "local-test".into(), + })) + .await + .expect("health response"); + let body = response.get_ref(); + assert_eq!(body.responder_node_id, 3); + assert_eq!(body.status, "healthy"); + assert!(body.uptime_millis < 5000); + assert!(body.timestamp_millis > 0); + Ok(()) + } + + #[test] + fn stop_broadcast_fields_are_optional() { + // Ensures proto regeneration worked and these fields are optional in Rust. + let msg = StopBroadcast { + sender_node_id: 1, + reason: "x".into(), + last_base_hash: None, + last_base_height: None, + last_nock_hash: None, + last_nock_height: None, + timestamp: 0, + }; + assert!(msg.last_base_hash.is_none()); + assert!(msg.last_base_height.is_none()); + assert!(msg.last_nock_hash.is_none()); + assert!(msg.last_nock_height.is_none()); + } + + #[tokio::test] + async fn multi_node_signature_convergence_out_of_order() -> Result<(), BridgeError> { + let epoch = NonceEpochConfig { + base: 0, + start_height: 0, + start_tx_id: None, + }; + + let signers: Vec<_> = TEST_PRIVATE_KEYS + .iter() + .map(|key| test_bridge_signer_for(key)) + .collect(); + let address_map: HashMap = signers + .iter() + .enumerate() + .map(|(idx, signer)| (signer.address(), idx as u64)) + .collect(); + + let mut nodes = Vec::new(); + for (idx, signer) in signers.iter().enumerate() { + nodes.push(TestNode::new(idx as u64, signer.clone(), address_map.clone()).await?); + } + + let entry_a = make_entry(10, 1, 0x11, 1000); + let entry_b = make_entry(11, 2, 0x22, 2000); + + nodes[0] + .insert_entries(&[entry_b.clone(), entry_a.clone()]) + .await; + nodes[1] + .insert_entries(&[entry_a.clone(), entry_b.clone()]) + .await; + nodes[2] + .insert_entries(&[entry_a.clone(), entry_b.clone()]) + .await; + + let next_nonce = epoch.first_epoch_nonce(); + let req0 = nodes[0].build_request(&epoch, next_nonce).await; + let req1 = nodes[1].build_request(&epoch, next_nonce).await; + let req2 = nodes[2].build_request(&epoch, next_nonce).await; + + assert_eq!(req0.tx_id, entry_a.tx_id); + assert_eq!(req0.compute_proposal_hash(), req1.compute_proposal_hash()); + assert_eq!(req0.compute_proposal_hash(), req2.compute_proposal_hash()); + + let deposit_id = DepositId::from_effect_payload(&req0); + let sig0 = nodes[0].sign_request(&req0).await; + nodes[0].add_signature(&req0, sig0.clone(), true); + + let msg0 = make_broadcast( + &deposit_id, + req0.compute_proposal_hash(), + sig0, + nodes[0].signer.address(), + ); + assert!(nodes[1].broadcast_signature(msg0.clone()).await); + assert!(nodes[2].broadcast_signature(msg0).await); + + let sig1 = nodes[1].sign_request(&req1).await; + nodes[1].add_signature(&req1, sig1.clone(), true); + let report1 = nodes[1].apply_pending(&deposit_id); + assert_eq!(report1.applied, 1); + assert!(report1.mismatched.is_empty()); + + let sig2 = nodes[2].sign_request(&req2).await; + nodes[2].add_signature(&req2, sig2.clone(), true); + let report2 = nodes[2].apply_pending(&deposit_id); + assert_eq!(report2.applied, 1); + assert!(report2.mismatched.is_empty()); + + let msg1 = make_broadcast( + &deposit_id, + req1.compute_proposal_hash(), + sig1, + nodes[1].signer.address(), + ); + assert!(nodes[0].broadcast_signature(msg1.clone()).await); + assert!(nodes[2].broadcast_signature(msg1).await); + + let msg2 = make_broadcast( + &deposit_id, + req2.compute_proposal_hash(), + sig2, + nodes[2].signer.address(), + ); + assert!(nodes[0].broadcast_signature(msg2.clone()).await); + assert!(nodes[1].broadcast_signature(msg2).await); + + for node in nodes.iter() { + wait_for_ready(node, &deposit_id).await; + } + + Ok(()) + } + + #[tokio::test] + async fn pending_signatures_refresh_tui_signers() -> Result<(), BridgeError> { + // Simulate a peer signature arriving before we process the deposit, + // then ensure TUI signers update once pending signatures are applied. + let epoch = NonceEpochConfig { + base: 0, + start_height: 0, + start_tx_id: None, + }; + + let self_signer = test_bridge_signer_for(TEST_PRIVATE_KEYS[0]); + let peer_signer = test_bridge_signer_for(TEST_PRIVATE_KEYS[1]); + let mut address_map = HashMap::new(); + address_map.insert(peer_signer.address(), 1); + + let node = TestNode::new(0, self_signer.clone(), address_map.clone()).await?; + + let entry = make_entry(10, 1, 0x11, 1000); + node.insert_entries(std::slice::from_ref(&entry)).await; + + let next_nonce = epoch.first_epoch_nonce(); + let req = node.build_request(&epoch, next_nonce).await; + let deposit_id = DepositId::from_effect_payload(&req); + let proposal_hash = req.compute_proposal_hash(); + let proposal_hash_hex = encode(proposal_hash); + + // Queue a peer signature while the proposal is still unknown locally. + let peer_sig = peer_signer + .sign_hash(&proposal_hash) + .await + .expect("peer signing failed") + .as_bytes() + .to_vec(); + let queued = node + .proposal_cache + .add_signature( + &deposit_id, + SignatureData { + signer_address: peer_signer.address(), + signature: peer_sig, + proposal_hash, + is_mine: false, + }, + None, + IngressService::verify_signature, + ) + .map_err(BridgeError::Runtime)?; + assert_eq!(queued, SignatureAddResult::Added); + + // Seed the TUI with a proposal so we can verify the signers list later. + node.bridge_status.update_proposal(Proposal { + id: proposal_hash_hex.clone(), + proposal_type: "deposit".to_string(), + description: "pending signature refresh test".to_string(), + signatures_collected: 0, + signatures_required: crate::proposal_cache::SIGNATURE_THRESHOLD as u8, + signers: Vec::new(), + created_at: SystemTime::now(), + status: crate::tui::types::ProposalStatus::Pending, + data_hash: proposal_hash_hex.clone(), + submitted_at_block: None, + submitted_at: None, + tx_hash: None, + time_to_submit_ms: None, + executed_at_block: None, + source_block: Some(req.block_height), + amount: Some(req.amount as u128), + recipient: None, + nonce: Some(req.nonce), + source_tx_id: None, + current_proposer: None, + is_my_turn: false, + time_until_takeover: None, + }); + + // Add our own signature, then apply the queued peer signature. + let my_sig = node.sign_request(&req).await; + let add_result = node.add_signature(&req, my_sig, true); + assert!(matches!( + add_result, + SignatureAddResult::Added | SignatureAddResult::ThresholdReached + )); + + let report = node.apply_pending(&deposit_id); + assert_eq!(report.applied, 1); + + let cache_state = node + .proposal_cache + .get_state(&deposit_id) + .expect("cache lookup failed") + .expect("missing cache state"); + node.bridge_status + .sync_proposal_signatures_from_cache(&proposal_hash_hex, &cache_state, &address_map, 0); + + // After syncing from cache, the TUI should show both signer ids. + let proposal = node + .bridge_status + .find_proposal(&proposal_hash_hex) + .expect("missing TUI proposal"); + assert_eq!(proposal.signatures_collected, 2); + assert!(proposal.signers.contains(&0)); + assert!(proposal.signers.contains(&1)); + + let bridge_status = node.bridge_status.proposals(); + let pending = bridge_status + .pending_inbound + .iter() + .find(|p| p.id == proposal_hash_hex) + .expect("proposal not in pending inbound"); + assert_eq!(pending.signatures_collected, 2); + assert!(pending.signers.contains(&0)); + assert!(pending.signers.contains(&1)); + + Ok(()) + } + + #[tokio::test] + async fn nonce_divergence_alerts_on_mismatch() -> Result<(), BridgeError> { + let epoch = NonceEpochConfig { + base: 0, + start_height: 0, + start_tx_id: None, + }; + + let signers: Vec<_> = TEST_PRIVATE_KEYS + .iter() + .take(2) + .map(|key| test_bridge_signer_for(key)) + .collect(); + let address_map: HashMap = signers + .iter() + .enumerate() + .map(|(idx, signer)| (signer.address(), idx as u64)) + .collect(); + + let mut nodes = Vec::new(); + for (idx, signer) in signers.iter().enumerate() { + nodes.push(TestNode::new(idx as u64, signer.clone(), address_map.clone()).await?); + } + + let entry = make_entry(10, 42, 0x33, 3000); + nodes[0].insert_entries(std::slice::from_ref(&entry)).await; + nodes[1].insert_entries(std::slice::from_ref(&entry)).await; + + let next_nonce = epoch.first_epoch_nonce(); + let req0 = nodes[0].build_request(&epoch, next_nonce).await; + let mut req1 = nodes[1].build_request(&epoch, next_nonce).await; + req1.nonce += 1; + + let sig1 = nodes[1].sign_request(&req1).await; + nodes[1].add_signature(&req1, sig1, true); + + let deposit_id = DepositId::from_effect_payload(&req0); + let sig0 = nodes[0].sign_request(&req0).await; + let msg0 = make_broadcast( + &deposit_id, + req0.compute_proposal_hash(), + sig0, + nodes[0].signer.address(), + ); + + let accepted = nodes[1].broadcast_signature(msg0).await; + assert!(!accepted, "mismatched proposal hash should be rejected"); + + let has_divergence = { + let alerts = nodes[1] + .bridge_status + .alerts + .read() + .expect("alert lock poisoned"); + alerts + .alerts + .iter() + .any(|alert| alert.source == "nonce-divergence") + }; + assert!(has_divergence, "expected nonce divergence alert"); + + Ok(()) + } +} diff --git a/crates/bridge/src/lib.rs b/crates/bridge/src/lib.rs new file mode 100644 index 000000000..e3a6efdf2 --- /dev/null +++ b/crates/bridge/src/lib.rs @@ -0,0 +1,27 @@ +// Allow unwrap in unit-test-only code paths; production code is still linted with `-D clippy::unwrap_used`. +#![cfg_attr(test, allow(clippy::unwrap_used))] + +#[cfg(all(feature = "snmalloc", feature = "jemalloc"))] +compile_error!("features `snmalloc` and `jemalloc` are mutually exclusive"); + +pub mod bridge_status; +pub mod config; +pub mod deposit_log; +pub mod errors; +pub mod ethereum; +pub mod grpc; +pub mod health; +pub mod ingress; +pub mod metrics; +pub mod nockchain; +pub mod proposal_cache; +pub mod proposer; +pub mod runtime; +pub mod schema; +pub mod signing; +pub mod status; +pub mod stop; +pub mod tui; +pub mod tui_api; +pub mod tui_client; +pub mod types; diff --git a/crates/bridge/src/main.rs b/crates/bridge/src/main.rs new file mode 100644 index 000000000..4dfdfe0d9 --- /dev/null +++ b/crates/bridge/src/main.rs @@ -0,0 +1,988 @@ +use std::fs; +use std::net::SocketAddr; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::Duration; + +use bridge::bridge_status::{run_hourly_rotation, BridgeStatus}; +use bridge::config::NonceEpochConfig; +use bridge::deposit_log::{ + create_commit_nock_deposits_driver, sync_deposit_log_from_hashchain, + validate_deposit_log_against_chain_nonce_prefix, +}; +use bridge::errors::BridgeError; +use bridge::ethereum::BaseBridge; +use bridge::health::{derive_peer_endpoints, initialize_health_state, HealthMonitorConfig}; +use bridge::ingress; +use bridge::nockchain::NockchainWatcher; +use bridge::proposal_cache::ProposalCache; +use bridge::runtime::{ + run_posting_loop, run_signing_cursor_loop, BridgeRuntime, KernelCauseBuilder, +}; +use bridge::signing::{extract_valid_bridge_addresses, BridgeSigner}; +use bridge::status::BridgeStatusState; +use bridge::stop::create_stop_driver; +use bridge::tui::{cleanup_old_logs, init_bridge_tracing}; +use bridge::types::NodeConfig; +use clap::Parser; +use kernels_open_bridge::KERNEL; +use nockapp::kernel::boot::{self, Cli as BootCli}; +use nockapp::nockapp::wire::Wire; +use nockapp::noun::slab::{NockJammer, NounSlab}; +use nockapp::{exit_driver, markdown_driver, system_data_dir}; +use nockapp_grpc::services::public_nockchain::v1::driver::grpc_listener_driver; +use noun_serde::NounSerdeEncodeExt; +use tokio::{fs as tokio_fs, signal}; +use tracing::info; +use zkvm_jetpack::hot::produce_prover_hot_state; + +// When enabled, use snmalloc as the global allocator. +#[cfg(feature = "snmalloc")] +#[global_allocator] +static ALLOC: snmalloc_rs::SnMalloc = snmalloc_rs::SnMalloc; + +// When enabled, use jemalloc as the global allocator. +#[cfg(feature = "jemalloc")] +#[global_allocator] +static ALLOC: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc; + +#[derive(Parser, Debug, Clone)] +#[command(author, version, about, long_about = None)] +struct BridgeCli { + #[command(flatten)] + boot: BootCli, + + #[arg(long, short = 'c')] + config_path: Option, + + #[arg(long)] + data_dir: Option, + + #[arg( + long, + help = "Send a %start poke to the kernel on boot (clears stop state)" + )] + start: bool, + + #[arg(long, help = "Directory for log files (default: {data_dir}/logs/)")] + log_dir: Option, + + #[arg( + long, + help = "Number of days of logs to maintain (default: 7, disable with 0)" + )] + log_retention_days: Option, +} + +const DEFAULT_CONFIG_TEMPLATE: &str = include_str!("../bridge-conf.example.toml"); +const NETWORK_MONITOR_POLL_SECS: u64 = 15; + +async fn bridge_data_dir(cli_dir: Option) -> Result { + let bridge_data_dir = cli_dir.unwrap_or_else(|| system_data_dir().join("bridge")); + if !bridge_data_dir.exists() { + tokio_fs::create_dir_all(&bridge_data_dir) + .await + .map_err(|e| BridgeError::Config(format!("Failed to create bridge data dir: {}", e)))?; + } + Ok(bridge_data_dir) +} + +fn ensure_config_file(path: &Path) -> Result<(), BridgeError> { + if path.exists() { + return Ok(()); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|e| { + BridgeError::Config(format!("Failed to create config directory: {}", e)) + })?; + } + fs::write(path, DEFAULT_CONFIG_TEMPLATE).map_err(|e| { + BridgeError::Config(format!( + "Failed to write default config to {}: {}", + path.display(), + e + )) + })?; + info!("wrote default config template to {}", path.display()); + Ok(()) +} + +fn default_ingress_listen_address(node_config: &NodeConfig) -> Result { + let idx = node_config.node_id as usize; + let node = node_config.nodes.get(idx).ok_or_else(|| { + BridgeError::Config(format!( + "node_id {} missing from nodes list", + node_config.node_id + )) + })?; + let trimmed = node.ip.trim(); + if trimmed.is_empty() { + return Err(BridgeError::Config(format!( + "nodes[{}] ip must not be empty when ingress_listen_address is unset", + idx + ))); + } + Ok(trimmed.to_string()) +} + +fn build_nonce_epoch_config( + config_toml: &bridge::config::BridgeConfigToml, +) -> Result { + let nonce_epoch_base_opt = config_toml.nonce_epoch_base; + let nonce_epoch_start_height = config_toml.nonce_epoch_start_height; + let nonce_epoch_start_tx_id = config_toml.nonce_epoch_start_tx_id()?; + + if nonce_epoch_start_height.is_none() && nonce_epoch_start_tx_id.is_some() { + return Err(BridgeError::Config( + "nonce_epoch_start_tx_id_base58 requires nonce_epoch_start_height".into(), + )); + } + + let start_key_set = nonce_epoch_start_height.is_some() || nonce_epoch_start_tx_id.is_some(); + if let Some(base) = nonce_epoch_base_opt { + if base == 0 { + if start_key_set { + return Err(BridgeError::Config( + "nonce_epoch_base must be non-zero when nonce_epoch_start_height or nonce_epoch_start_tx_id_base58 is set".into(), + )); + } + } else { + let Some(height) = nonce_epoch_start_height else { + return Err(BridgeError::Config( + "nonce_epoch_start_height must be set when nonce_epoch_base is non-zero".into(), + )); + }; + if height == 0 { + return Err(BridgeError::Config( + "nonce_epoch_start_height must be greater than 0 when set".into(), + )); + } + if nonce_epoch_start_tx_id.is_none() { + return Err(BridgeError::Config( + "nonce_epoch_start_tx_id_base58 must be set when nonce_epoch_base is non-zero" + .into(), + )); + } + } + } else if start_key_set { + return Err(BridgeError::Config( + "nonce_epoch_base must be set when nonce_epoch_start_height or nonce_epoch_start_tx_id_base58 is set".into(), + )); + } + + let nonce_epoch_base = nonce_epoch_base_opt.unwrap_or(0); + let nonce_epoch_start_height = nonce_epoch_start_height.unwrap_or(1); + Ok(NonceEpochConfig { + base: nonce_epoch_base, + start_height: nonce_epoch_start_height, + start_tx_id: nonce_epoch_start_tx_id, + }) +} + +#[tokio::main] +async fn main() -> Result<(), BridgeError> { + let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); + let cli = BridgeCli::parse(); + + // Compute data_dir first (needed for log_dir default) + let data_dir = bridge_data_dir(cli.data_dir.clone()).await?; + + // Determine log directory: CLI override or default to {data_dir}/logs/ + let log_dir = cli.log_dir.clone().unwrap_or_else(|| data_dir.join("logs")); + let log_retention_days = cli.log_retention_days.unwrap_or(7); + + // Initialize tracing with file logging - keep guard alive for program duration + let _log_guard = init_bridge_tracing(&cli.boot, None, log_dir.clone(), log_retention_days)?; + + info!("Logging to directory: {}", log_dir.display()); + + // Clean up old log files (best effort, don't fail startup) + if log_retention_days > 0 { + cleanup_old_logs(&log_dir, log_retention_days as u64); + } + + let prover_hot_state = produce_prover_hot_state(); + + let mut app = boot::setup::( + KERNEL, + cli.boot.clone(), + prover_hot_state.as_slice(), + "bridge", + Some(data_dir.clone()), + ) + .await + .map_err(|e| BridgeError::NockappTask(format!("Kernel setup failed: {}", e)))?; + + info!("bridge nockapp started"); + + let config_path = if let Some(path) = cli.config_path.clone() { + path + } else { + bridge::config::default_config_path()? + }; + ensure_config_file(&config_path)?; + + let config_toml = bridge::config::BridgeConfigToml::from_file(&config_path)?; + let node_config = config_toml.to_node_config()?; + + info!("loaded config from {}", config_path.display()); + + info!("NodeConfig: {:?}", node_config); + + let cause_builder = Arc::new(KernelCauseBuilder); + let (mut bridge_runtime, runtime_handle) = BridgeRuntime::new(cause_builder); + let runtime_handle = Arc::new(runtime_handle); + bridge_runtime.install_driver(&mut app).await?; + + let (stop_controller, stop_handle) = bridge::stop::StopController::new(); + + if cli.start { + let mut start_slab = NounSlab::new(); + let start_cause = bridge::types::BridgeCause::start(); + let start_noun = start_cause.encode(&mut start_slab); + start_slab.set_root(start_noun); + let start_wire = nockapp::one_punch::OnePunchWire::Poke.to_wire(); + app.poke(start_wire, start_slab) + .await + .map_err(|e| BridgeError::NockappTask(format!("Start poke failed: {}", e)))?; + info!("sent %start poke to kernel"); + } + + let mut cfg_slab = NounSlab::new(); + let cfg_cause = bridge::types::BridgeCause::cfg_load(Some(node_config.clone())); + let cfg_noun = cfg_cause.encode(&mut cfg_slab); + cfg_slab.set_root(cfg_noun); + let cfg_wire = nockapp::one_punch::OnePunchWire::Poke.to_wire(); + app.poke(cfg_wire, cfg_slab) + .await + .map_err(|e| BridgeError::NockappTask(format!("Config poke failed: {}", e)))?; + + info!("sent config to kernel"); + + // Send constants to kernel + let bridge_constants = config_toml.bridge_constants()?; + let base_blocks_chunk = bridge_constants.base_blocks_chunk; // Extract before move + info!( + "sending bridge constants: min_signers={}, total_signers={}, base_start={}, nock_start={}, base_blocks_chunk={}", + bridge_constants.min_signers, + bridge_constants.total_signers, + bridge_constants.base_start_height, + bridge_constants.nockchain_start_height, + base_blocks_chunk + ); + + let mut constants_slab = NounSlab::new(); + let constants_cause = bridge::types::BridgeCause::set_constants(bridge_constants); + let constants_noun = constants_cause.encode(&mut constants_slab); + constants_slab.set_root(constants_noun); + let constants_wire = nockapp::one_punch::OnePunchWire::Poke.to_wire(); + app.poke(constants_wire, constants_slab) + .await + .map_err(|e| BridgeError::NockappTask(format!("Constants poke failed: {}", e)))?; + + info!("sent constants to kernel"); + + let base_confirmation_depth = config_toml.base_confirmation_depth; + if base_confirmation_depth == 0 { + return Err(BridgeError::Config( + "base_confirmation_depth must be greater than 0".into(), + )); + } + + let nockchain_confirmation_depth = config_toml.nockchain_confirmation_depth; + if nockchain_confirmation_depth == 0 { + return Err(BridgeError::Config( + "nockchain_confirmation_depth must be greater than 0".into(), + )); + } + + let nonce_epoch = build_nonce_epoch_config(&config_toml)?; + info!( + "driver finality: base_confirmation_depth={}, nockchain_confirmation_depth={}", + base_confirmation_depth, nockchain_confirmation_depth + ); + + let base_bridge = Arc::new( + BaseBridge::new( + config_toml.base_ws_url().to_string(), + config_toml.inbox_contract_address()?, + config_toml.nock_contract_address()?, + config_toml.my_eth_key_hex().to_string(), + runtime_handle.clone(), + base_blocks_chunk, + base_confirmation_depth, + stop_handle.clone(), + ) + .await?, + ); + + let ingress_addr_raw = if let Some(address) = config_toml.ingress_listen_address() { + address.to_string() + } else { + default_ingress_listen_address(&node_config)? + }; + let ingress_addr: SocketAddr = ingress_addr_raw + .parse() + .map_err(|e| BridgeError::Config(format!("invalid ingress listen address: {}", e)))?; + let self_address = ingress_addr.to_string(); + let ingress_runtime = runtime_handle.clone(); + let node_id = node_config.node_id; + + let bridge_signer = Arc::new(BridgeSigner::new(config_toml.my_eth_key_hex().to_string())?); + info!("Base bridge and signer initialized successfully"); + + // Create proposal cache for signature aggregation + let proposal_cache = Arc::new(ProposalCache::new()); + + // Per-node deposit log for deterministic nonce assignment. + let deposit_log_path = data_dir.join("deposit-queue.sqlite"); + let deposit_log = + Arc::new(bridge::deposit_log::DepositLog::open(deposit_log_path.clone()).await?); + info!("Using deposit queue log at {}", deposit_log_path.display()); + + // Deposit log sync happens after the kernel action loop is running. + + // Create peers, health state, and bridge status BEFORE ingress spawn + // so ingress can update state when receiving peer broadcasts + let peers = derive_peer_endpoints(&node_config, node_config.node_id); + let health_state = initialize_health_state(&peers); + + // Create BridgeStatus for drivers to update proposal state. + let bridge_status = BridgeStatus::new(health_state.clone()); + let status_state = BridgeStatusState::new(); + + let nock_watcher = NockchainWatcher::new( + config_toml.grpc_address().to_string(), + runtime_handle.clone(), + nockchain_confirmation_depth, + stop_handle.clone(), + ) + .with_bridge_status(bridge_status.clone()); + let nock_handle = tokio::spawn(async move { nock_watcher.run().await }); + + // Build address-to-node-id mapping for TUI signature display + // node_id is derived from index in nodes array (same as derive_peer_endpoints) + // eth_pubkey is actually the 20-byte Ethereum address (naming is misleading) + let address_to_node_id: std::collections::HashMap = + node_config + .nodes + .iter() + .enumerate() + .filter_map(|(idx, node)| { + if node.eth_pubkey.0.len() == 20 { + let addr = alloy::primitives::Address::from_slice(&node.eth_pubkey.0); + Some((addr, idx as u64)) + } else { + None + } + }) + .collect(); + + let ingress_signer = bridge_signer.clone(); + let ingress_cache = proposal_cache.clone(); + let ingress_tui = bridge_status.clone(); + let ingress_addr_map = address_to_node_id.clone(); + let ingress_stop_controller = stop_controller.clone(); + let ingress_peers = peers.clone(); + let ingress_status_state = status_state.clone(); + let ingress_deposit_log = deposit_log.clone(); + let ingress_nonce_epoch = nonce_epoch.clone(); + let ingress_handle = tokio::spawn(async move { + ingress::serve_ingress( + ingress_addr, node_id, ingress_runtime, ingress_status_state, ingress_deposit_log, + ingress_nonce_epoch, ingress_signer, ingress_cache, ingress_tui, ingress_addr_map, + ingress_stop_controller, ingress_peers, + ) + .await + }); + + // core/admin drivers + app.add_io_driver(markdown_driver()).await; + app.add_io_driver(exit_driver()).await; + + // grpc listener driver: forwards %grpc effects to the configured gRPC endpoint + app.add_io_driver(grpc_listener_driver(config_toml.grpc_address().to_string())) + .await; + + // stop driver: observes STOP effects and propagates stop pokes to peers + let stop_driver = create_stop_driver( + runtime_handle.clone(), + stop_controller.clone(), + bridge_status.clone(), + peers.clone(), + node_config.node_id, + ); + app.add_io_driver(stop_driver).await; + + // Note: proposal_cache was already created above (line 161) and passed to ingress. + info!("Using shared proposal cache for signature aggregation"); + + // Add commit-nock-deposits CDC driver to persist effect data. + let propose_driver = create_commit_nock_deposits_driver( + runtime_handle.clone(), + stop_controller.clone(), + bridge_status.clone(), + None, + stop_handle.clone(), + deposit_log.clone(), + nonce_epoch.clone(), + ); + app.add_io_driver(propose_driver).await; + + let app_handle = tokio::spawn(async move { app.run().await }); + let runtime_task = tokio::spawn(async move { bridge_runtime.run().await }); + + // Seed stop controller from persisted kernel stop-state so the TUI reflects it on boot. + let stop_seed_runtime = runtime_handle.clone(); + let stop_seed_controller = stop_controller.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(200)).await; + + let stopped = + match tokio::time::timeout(Duration::from_secs(5), stop_seed_runtime.peek_stop_state()) + .await + { + Ok(Ok(value)) => value, + Ok(Err(err)) => { + tracing::warn!( + target: "bridge.stop", + error=%err, + "failed to peek stop-state on boot" + ); + return; + } + Err(_) => { + tracing::warn!( + target: "bridge.stop", + "timed out peeking stop-state on boot" + ); + return; + } + }; + + if !stopped { + return; + } + + let last = + match tokio::time::timeout(Duration::from_secs(5), stop_seed_runtime.peek_stop_info()) + .await + { + Ok(Ok(info)) => info, + Ok(Err(err)) => { + tracing::warn!( + target: "bridge.stop", + error=%err, + "failed to peek stop-info on boot" + ); + None + } + Err(_) => { + tracing::warn!( + target: "bridge.stop", + "timed out peeking stop-info on boot" + ); + None + } + }; + + let info = bridge::stop::StopInfo { + reason: "kernel stop-state present on boot".to_string(), + last, + source: bridge::stop::StopSource::KernelEffect, + at: std::time::SystemTime::now(), + }; + let _ = stop_seed_controller.trigger(info); + }); + + // Deterministically seed/sync the per-node deposit log from the kernel hashchain + // before we start signing/proposing. + sync_deposit_log_from_hashchain( + runtime_handle.clone(), + deposit_log.clone(), + &nonce_epoch, + Duration::from_secs(2), + ) + .await?; + + let tip_height = runtime_handle.nock_hashchain_tip().await?.unwrap_or(0); + if tip_height >= nonce_epoch.start_height { + validate_deposit_log_against_chain_nonce_prefix( + base_bridge.clone(), + deposit_log.clone(), + nonce_epoch.clone(), + ) + .await?; + } else { + info!( + target: "bridge.deposit_log", + tip_height, + nonce_epoch_start_height = nonce_epoch.start_height, + "skipping deposit log validation until hashchain reaches epoch start height" + ); + } + + // Spawn signing cursor loop so nodes can deterministically (re)sign pending deposits + // after restart without requiring re-processing the originating nock block. + let cursor_runtime = runtime_handle.clone(); + let cursor_base = base_bridge.clone(); + let cursor_deposit_log = deposit_log.clone(); + let cursor_cache = proposal_cache.clone(); + let cursor_signer = bridge_signer.clone(); + let cursor_valid_addrs = extract_valid_bridge_addresses(&node_config); + let cursor_tui = bridge_status.clone(); + let cursor_stop_controller = stop_controller.clone(); + let cursor_stop = stop_handle.clone(); + let cursor_peers = peers.clone(); + let cursor_node_id = node_config.node_id; + let cursor_addr_map = address_to_node_id.clone(); + let nonce_epoch_clone = nonce_epoch.clone(); + let _cursor_handle = tokio::spawn(async move { + run_signing_cursor_loop( + cursor_runtime, cursor_base, cursor_deposit_log, &nonce_epoch_clone, cursor_cache, + cursor_signer, cursor_valid_addrs, cursor_peers, cursor_node_id, cursor_tui, + cursor_addr_map, cursor_stop_controller, cursor_stop, + ) + .await + }); + info!("Spawned signing cursor loop"); + + // Spawn posting loop to monitor cache and post ready proposals to BASE + // Turn-based posting is handled inside run_posting_loop using hoon_proposer() + let posting_cache = proposal_cache.clone(); + let posting_base = base_bridge.clone(); + let posting_config = node_config.clone(); + let posting_tui = bridge_status.clone(); + let posting_stop = stop_handle.clone(); + let posting_status_state = status_state.clone(); + let _posting_handle = tokio::spawn(async move { + run_posting_loop( + posting_cache, posting_base, posting_config, posting_tui, posting_stop, + posting_status_state, + ) + .await + }); + info!("Spawned proposal posting loop with turn-based posting"); + + let base_bridge_for_ack = base_bridge.clone(); + let bridge_status_for_ack = bridge_status.clone(); + let ack_handle = tokio::spawn(async move { + base_bridge_for_ack + .stream_base_events(Some(bridge_status_for_ack)) + .await + }); + + let health_cfg = HealthMonitorConfig { + self_node_id: node_config.node_id, + self_address, + peers: peers.clone(), + poll_interval: Duration::from_secs(5), + request_timeout: Duration::from_secs(2), + bridge_status: Some(bridge_status.clone()), + }; + let monitor_state = health_state.clone(); + let health_handle = + tokio::spawn( + async move { bridge::health::run_health_monitor(health_cfg, monitor_state).await }, + ); + + // Network monitor: polls chain heights and updates bridge status + let network_runtime = runtime_handle.clone(); + let network_bridge_status = bridge_status.clone(); + let _network_handle = tokio::spawn(async move { + bridge::nockchain::run_network_monitor( + network_runtime, + network_bridge_status, + Duration::from_secs(NETWORK_MONITOR_POLL_SECS), + ) + .await + }); + + // Hourly metrics rotation: shifts hourly_tx_counts left and adds 0 + let hourly_bridge_status = bridge_status.clone(); + let _hourly_rotation_handle = + tokio::spawn(async move { run_hourly_rotation(hourly_bridge_status).await }); + + tokio::select! { + result = app_handle => { + match result { + Ok(app_result) => { + app_result.map_err(|e| BridgeError::NockappTask(format!("App run failed: {}", e)))?; + } + Err(e) => { + return Err(BridgeError::NockappTask(format!("App task failed: {}", e))); + } + } + } + result = nock_handle => { + match result { + Ok(nock_result) => { + nock_result?; + } + Err(e) => { + return Err(BridgeError::Runtime(format!("Nock watcher failed: {}", e))); + } + } + } + result = runtime_task => { + match result { + Ok(runtime_result) => { + runtime_result?; + } + Err(e) => { + return Err(BridgeError::Runtime(format!("Runtime task failed: {}", e))); + } + } + } + result = ingress_handle => { + match result { + Ok(ingress_result) => { + ingress_result?; + } + Err(e) => { + return Err(BridgeError::Runtime(format!("Ingress server failed: {}", e))); + } + } + } + result = ack_handle => { + match result { + Ok(ack_result) => { + ack_result?; + } + Err(e) => { + return Err(BridgeError::AckTask(format!("Ack task failed: {}", e))); + } + } + } + result = health_handle => { + match result { + Ok(health_result) => { + health_result?; + } + Err(e) => { + return Err(BridgeError::Runtime(format!("Health monitor failed: {}", e))); + } + } + } + _ = signal::ctrl_c() => { + info!("Ctrl+C received, shutting down"); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::sync::Once; + + use bridge::config::{BridgeConfigToml, NodeInfoToml}; + use bridge::deposit_log::persist_commit_nock_deposits_requests; + use bridge::tui; + use bridge::types::{EthAddress, Tip5Hash}; + use nockapp::kernel::boot; + use nockchain_math::belt::Belt; + use nockchain_types::v1::Name; + use tempfile::TempDir; + + use super::*; + + static INIT: Once = Once::new(); + const VALID_START_TX_ID: &str = "2uYre9HXRP8X6BD7w3GvgfUAU47RSmZDGkz9uJgJmD9CxN7JA69k6MF"; + + fn base_config() -> BridgeConfigToml { + BridgeConfigToml { + node_id: 0, + base_ws_url: "wss://example.invalid".to_string(), + inbox_contract_address: None, + nock_contract_address: None, + my_eth_key: "0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318" + .to_string(), + my_nock_key: "5KZuFKrctV5iUburT54Z9fhpf3V3hv2sPf9GRQnjFR8T".to_string(), + grpc_address: "http://localhost:5555".to_string(), + base_confirmation_depth: 1, + nockchain_confirmation_depth: 1, + nonce_epoch_base: None, + nonce_epoch_start_height: None, + nonce_epoch_start_tx_id_base58: None, + ingress_listen_address: None, + nodes: vec![ + NodeInfoToml { + ip: "localhost:8001".to_string(), + eth_pubkey: "0x1111111111111111111111111111111111111111".to_string(), + nock_pkh: "2222222222222222222222222222222222222222222222222222".to_string(), + }, + NodeInfoToml { + ip: "localhost:8002".to_string(), + eth_pubkey: "0x2222222222222222222222222222222222222222".to_string(), + nock_pkh: "3333333333333333333333333333333333333333333333333333".to_string(), + }, + NodeInfoToml { + ip: "localhost:8003".to_string(), + eth_pubkey: "0x3333333333333333333333333333333333333333".to_string(), + nock_pkh: "4444444444444444444444444444444444444444444444444444".to_string(), + }, + NodeInfoToml { + ip: "localhost:8004".to_string(), + eth_pubkey: "0x4444444444444444444444444444444444444444".to_string(), + nock_pkh: "5555555555555555555555555555555555555555555555555555".to_string(), + }, + NodeInfoToml { + ip: "localhost:8005".to_string(), + eth_pubkey: "0x5555555555555555555555555555555555555555".to_string(), + nock_pkh: "6666666666666666666666666666666666666666666666666666".to_string(), + }, + ], + constants: None, + } + } + + fn init_tracing() { + INIT.call_once(|| { + // Set RUST_LOG for tests if not already set + if std::env::var("RUST_LOG").is_err() { + std::env::set_var("RUST_LOG", "debug"); + } + let cli = boot::default_boot_cli(true); + let temp_log_dir = std::env::temp_dir().join("bridge-test-logs"); + let _guard = init_bridge_tracing(&cli, Some(tui::new_log_buffer()), temp_log_dir, 7) + .expect("failed to init tracing for tests"); + // Note: guard is dropped here but that's OK for tests - we just need tracing initialized + // In production, the guard is kept alive in main() + }); + } + + #[test] + fn nonce_epoch_config_allows_missing_base_and_start() { + let cfg = base_config(); + let epoch = build_nonce_epoch_config(&cfg).expect("base omitted should be ok"); + assert_eq!(epoch.base, 0); + assert_eq!(epoch.start_height, 1); + assert!(epoch.start_tx_id.is_none()); + } + + #[test] + fn nonce_epoch_config_rejects_start_without_base() { + let mut cfg = base_config(); + cfg.nonce_epoch_start_height = Some(10); + assert!(build_nonce_epoch_config(&cfg).is_err()); + + let mut cfg = base_config(); + cfg.nonce_epoch_start_tx_id_base58 = Some(VALID_START_TX_ID.to_string()); + assert!(build_nonce_epoch_config(&cfg).is_err()); + } + + #[test] + fn nonce_epoch_config_allows_zero_base_without_anchor() { + let mut cfg = base_config(); + cfg.nonce_epoch_base = Some(0); + let epoch = build_nonce_epoch_config(&cfg).expect("base=0 should be ok"); + assert_eq!(epoch.base, 0); + assert_eq!(epoch.start_height, 1); + assert!(epoch.start_tx_id.is_none()); + } + + #[test] + fn nonce_epoch_config_rejects_zero_base_with_anchor() { + let mut cfg = base_config(); + cfg.nonce_epoch_base = Some(0); + cfg.nonce_epoch_start_height = Some(10); + assert!(build_nonce_epoch_config(&cfg).is_err()); + } + + #[test] + fn nonce_epoch_config_rejects_nonzero_base_with_zero_height() { + let mut cfg = base_config(); + cfg.nonce_epoch_base = Some(5); + cfg.nonce_epoch_start_height = Some(0); + cfg.nonce_epoch_start_tx_id_base58 = Some(VALID_START_TX_ID.to_string()); + assert!(build_nonce_epoch_config(&cfg).is_err()); + } + + #[test] + fn nonce_epoch_config_rejects_missing_anchor_with_nonzero_base() { + let mut cfg = base_config(); + cfg.nonce_epoch_base = Some(5); + assert!(build_nonce_epoch_config(&cfg).is_err()); + + let mut cfg = base_config(); + cfg.nonce_epoch_base = Some(5); + cfg.nonce_epoch_start_height = Some(10); + assert!(build_nonce_epoch_config(&cfg).is_err()); + } + + #[test] + fn nonce_epoch_config_rejects_tx_id_without_height() { + let mut cfg = base_config(); + cfg.nonce_epoch_base = Some(5); + cfg.nonce_epoch_start_tx_id_base58 = Some(VALID_START_TX_ID.to_string()); + assert!(build_nonce_epoch_config(&cfg).is_err()); + } + + #[test] + fn nonce_epoch_config_accepts_anchor_for_nonzero_base() { + let mut cfg = base_config(); + cfg.nonce_epoch_base = Some(5); + cfg.nonce_epoch_start_height = Some(10); + cfg.nonce_epoch_start_tx_id_base58 = Some(VALID_START_TX_ID.to_string()); + let epoch = build_nonce_epoch_config(&cfg).expect("anchor should be accepted"); + assert_eq!(epoch.base, 5); + assert_eq!(epoch.start_height, 10); + assert!(epoch.start_tx_id.is_some()); + } + + #[tokio::test] + async fn test_signature_flow() -> Result<(), BridgeError> { + init_tracing(); + + let config_toml = base_config(); + + let bridge_signer = Arc::new(BridgeSigner::new(config_toml.my_eth_key_hex().to_string())?); + + let proposal_hash = [42u8; 32]; + let mut slab: NounSlab = NounSlab::new(); + let noun = unsafe { + let mut ia = + nockvm::noun::IndirectAtom::new_raw_bytes(&mut slab, 32, proposal_hash.as_ptr()); + ia.normalize_as_atom() + }; + let signature = bridge_signer.sign_proposal(noun.as_noun()).await?; + + assert!(!signature.r().is_zero(), "Expected valid r component"); + assert!(!signature.s().is_zero(), "Expected valid s component"); + let sig_bytes = signature.as_bytes(); + assert!( + sig_bytes[64] == 27 || sig_bytes[64] == 28, + "Expected valid v component" + ); + + // Verify the signer can also sign a raw hash directly + let hash_signature = bridge_signer.sign_hash(&proposal_hash).await?; + assert!( + !hash_signature.r().is_zero(), + "Expected valid r component from sign_hash" + ); + assert!( + !hash_signature.s().is_zero(), + "Expected valid s component from sign_hash" + ); + + Ok(()) + } + + fn tip5(a: u64, b: u64, c: u64, d: u64, e: u64) -> Tip5Hash { + Tip5Hash([Belt(a), Belt(b), Belt(c), Belt(d), Belt(e)]) + } + + fn addr(byte: u8) -> EthAddress { + EthAddress([byte; 20]) + } + + #[tokio::test] + async fn cdc_persists_epoch_requests_and_skips_pre_epoch() -> Result<(), BridgeError> { + let dir = TempDir::new().expect("temp dir"); + let path = dir.path().join("deposit-log.sqlite"); + let log = bridge::deposit_log::DepositLog::open(path).await?; + let epoch = NonceEpochConfig { + base: 100, + start_height: 10, + start_tx_id: None, + }; + + let req_pre = bridge::types::NockDepositRequestKernelData { + block_height: 9, + tx_id: tip5(1, 0, 0, 0, 0), + as_of: tip5(9, 9, 9, 9, 9), + name: Name::new(tip5(10, 0, 0, 0, 0), tip5(11, 0, 0, 0, 0)), + recipient: addr(0x11), + amount: 1, + }; + let req_a = bridge::types::NockDepositRequestKernelData { + block_height: 10, + tx_id: tip5(2, 0, 0, 0, 0), + as_of: tip5(8, 8, 8, 8, 8), + name: Name::new(tip5(12, 0, 0, 0, 0), tip5(13, 0, 0, 0, 0)), + recipient: addr(0x22), + amount: 2, + }; + let req_b = bridge::types::NockDepositRequestKernelData { + block_height: 11, + tx_id: tip5(3, 0, 0, 0, 0), + as_of: tip5(7, 7, 7, 7, 7), + name: Name::new(tip5(14, 0, 0, 0, 0), tip5(15, 0, 0, 0, 0)), + recipient: addr(0x33), + amount: 3, + }; + + let inserted = persist_commit_nock_deposits_requests( + vec![req_b.clone(), req_pre, req_a.clone()], + &log, + &epoch, + ) + .await?; + assert_eq!(inserted, 2); + assert_eq!(log.number_of_deposits_in_epoch(&epoch).await?, 2); + + let first = log + .get_by_nonce(epoch.base + 1, &epoch) + .await? + .expect("expected first nonce"); + assert_eq!(first.tx_id, req_a.tx_id); + + let inserted_again = + persist_commit_nock_deposits_requests(vec![req_a, req_b], &log, &epoch).await?; + assert_eq!(inserted_again, 0); + + Ok(()) + } + + #[tokio::test] + async fn cdc_orders_by_height_then_tx_id() -> Result<(), BridgeError> { + let dir = TempDir::new().expect("temp dir"); + let path = dir.path().join("deposit-log.sqlite"); + let log = bridge::deposit_log::DepositLog::open(path).await?; + let epoch = NonceEpochConfig { + base: 10, + start_height: 1, + start_tx_id: None, + }; + + let req_low = bridge::types::NockDepositRequestKernelData { + block_height: 5, + tx_id: tip5(1, 0, 0, 0, 0), + as_of: tip5(1, 1, 1, 1, 1), + name: Name::new(tip5(2, 0, 0, 0, 0), tip5(3, 0, 0, 0, 0)), + recipient: addr(0x44), + amount: 4, + }; + let req_high = bridge::types::NockDepositRequestKernelData { + block_height: 5, + tx_id: tip5(2, 0, 0, 0, 0), + as_of: tip5(2, 2, 2, 2, 2), + name: Name::new(tip5(4, 0, 0, 0, 0), tip5(5, 0, 0, 0, 0)), + recipient: addr(0x55), + amount: 5, + }; + + let inserted = persist_commit_nock_deposits_requests( + vec![req_high.clone(), req_low.clone()], + &log, + &epoch, + ) + .await?; + assert_eq!(inserted, 2); + + let first = log + .get_by_nonce(epoch.base + 1, &epoch) + .await? + .expect("expected first nonce"); + let second = log + .get_by_nonce(epoch.base + 2, &epoch) + .await? + .expect("expected second nonce"); + assert_eq!(first.tx_id, req_low.tx_id); + assert_eq!(second.tx_id, req_high.tx_id); + + Ok(()) + } +} diff --git a/crates/bridge/src/metrics.rs b/crates/bridge/src/metrics.rs new file mode 100644 index 000000000..5243f35b2 --- /dev/null +++ b/crates/bridge/src/metrics.rs @@ -0,0 +1,559 @@ +use std::sync::{Arc, OnceLock}; + +use gnort::instrument::UnitOfTime; +use gnort::*; + +use crate::tui::types::NetworkState; + +metrics_struct![ + BridgeHealthMetrics, + (running_status, "bridge.health.running_status", Gauge), + (base_hold_height, "bridge.health.base_hold_height", Gauge), + (nock_hold_height, "bridge.health.nock_hold_height", Gauge), + (base_block_height, "bridge.health.base_block_height", Gauge), + (nock_block_height, "bridge.health.nock_block_height", Gauge), + (last_deposit_nonce, "bridge.health.last_deposit_nonce", Gauge), + ( + ingress_broadcast_signature_requests, + "bridge.ingress.broadcast_signature.requests", + Count + ), + ( + ingress_broadcast_signature_invalid_deposit_id_len, + "bridge.ingress.broadcast_signature.invalid.deposit_id_length", + Count + ), + ( + ingress_broadcast_signature_invalid_proposal_hash_len, + "bridge.ingress.broadcast_signature.invalid.proposal_hash_length", + Count + ), + ( + ingress_broadcast_signature_invalid_signature_len, + "bridge.ingress.broadcast_signature.invalid.signature_length", + Count + ), + ( + ingress_broadcast_signature_invalid_signer_address_len, + "bridge.ingress.broadcast_signature.invalid.signer_address_length", + Count + ), + ( + ingress_broadcast_signature_invalid_deposit_id_decode, + "bridge.ingress.broadcast_signature.invalid.deposit_id_decode", + Count + ), + ( + ingress_broadcast_signature_ignored_self, + "bridge.ingress.broadcast_signature.ignored.self", + Count + ), + ( + ingress_broadcast_signature_known_proposal, + "bridge.ingress.broadcast_signature.proposal.known", + Count + ), + ( + ingress_broadcast_signature_unknown_proposal, + "bridge.ingress.broadcast_signature.proposal.unknown", + Count + ), + ( + ingress_broadcast_signature_known_signer, + "bridge.ingress.broadcast_signature.signer.known", + Count + ), + ( + ingress_broadcast_signature_unknown_signer, + "bridge.ingress.broadcast_signature.signer.unknown", + Count + ), + ( + ingress_broadcast_signature_unknown_signer_known_proposal, + "bridge.ingress.broadcast_signature.signer.unknown_for_known_proposal", + Count + ), + ( + ingress_broadcast_signature_hash_mismatch, + "bridge.ingress.broadcast_signature.proposal_hash_mismatch", + Count + ), + ( + ingress_broadcast_signature_result_added, + "bridge.ingress.broadcast_signature.result.added", + Count + ), + ( + ingress_broadcast_signature_result_threshold_reached, + "bridge.ingress.broadcast_signature.result.threshold_reached", + Count + ), + ( + ingress_broadcast_signature_result_duplicate, + "bridge.ingress.broadcast_signature.result.duplicate", + Count + ), + ( + ingress_broadcast_signature_result_stale, + "bridge.ingress.broadcast_signature.result.stale", + Count + ), + ( + ingress_broadcast_signature_result_invalid, + "bridge.ingress.broadcast_signature.result.invalid", + Count + ), + ( + ingress_broadcast_signature_result_error, + "bridge.ingress.broadcast_signature.result.error", + Count + ), + ( + proposal_cache_total, + "bridge.proposal_cache.entries.total", + Gauge + ), + ( + proposal_cache_collecting, + "bridge.proposal_cache.entries.collecting", + Gauge + ), + ( + proposal_cache_ready, + "bridge.proposal_cache.entries.ready", + Gauge + ), + ( + proposal_cache_posting, + "bridge.proposal_cache.entries.posting", + Gauge + ), + ( + proposal_cache_confirmed, + "bridge.proposal_cache.entries.confirmed", + Gauge + ), + ( + proposal_cache_failed, + "bridge.proposal_cache.entries.failed", + Gauge + ), + ( + proposal_cache_total_peer_signatures, + "bridge.proposal_cache.signatures.peer.total", + Gauge + ), + ( + proposal_cache_max_peer_signatures_per_proposal, + "bridge.proposal_cache.signatures.peer.max_per_proposal", + Gauge + ), + ( + proposal_cache_proposals_with_my_signature, + "bridge.proposal_cache.signatures.my.proposals", + Gauge + ), + ( + proposal_cache_pending_signature_deposit_count, + "bridge.proposal_cache.pending.deposits", + Gauge + ), + ( + proposal_cache_pending_signature_total, + "bridge.proposal_cache.pending.signatures", + Gauge + ), + ( + proposal_cache_oldest_age_secs, + "bridge.proposal_cache.age.oldest_seconds", + Gauge + ), + ( + proposal_cache_oldest_confirmed_age_secs, + "bridge.proposal_cache.age.oldest_confirmed_seconds", + Gauge + ), + ( + proposal_cache_oldest_failed_age_secs, + "bridge.proposal_cache.age.oldest_failed_seconds", + Gauge + ), + ( + proposal_cache_pending_oldest_age_secs, + "bridge.proposal_cache.age.oldest_pending_signature_seconds", + Gauge + ), + ( + proposal_cache_approx_state_bytes, + "bridge.proposal_cache.approx_bytes.states", + Gauge + ), + ( + proposal_cache_approx_peer_signature_bytes, + "bridge.proposal_cache.approx_bytes.peer_signatures", + Gauge + ), + ( + proposal_cache_approx_my_signature_bytes, + "bridge.proposal_cache.approx_bytes.my_signatures", + Gauge + ), + ( + proposal_cache_approx_pending_signature_bytes, + "bridge.proposal_cache.approx_bytes.pending_signatures", + Gauge + ), + ( + proposal_cache_approx_total_bytes, + "bridge.proposal_cache.approx_bytes.total", + Gauge + ), + ( + proposal_cache_metrics_update_error, + "bridge.proposal_cache.metrics_update_error", + Count + ), + ( + proposal_cache_pending_signature_queued_unknown_deposit, + "bridge.proposal_cache.pending.queued_unknown_deposit", + Count + ), + ( + proposal_cache_pending_signature_applied, + "bridge.proposal_cache.pending.applied", + Count + ), + ( + proposal_cache_pending_signature_mismatched, + "bridge.proposal_cache.pending.mismatched_hash", + Count + ), + ( + proposal_cache_pending_signature_verify_failed, + "bridge.proposal_cache.pending.verify_failed", + Count + ), + ( + proposal_cache_pending_signature_address_mismatch, + "bridge.proposal_cache.pending.address_mismatch", + Count + ), + ( + proposal_cache_signature_duplicate, + "bridge.proposal_cache.signature.duplicate", + Count + ), + ( + proposal_cache_signature_verify_failed, + "bridge.proposal_cache.signature.verify_failed", + Count + ), + ( + proposal_cache_signature_address_mismatch, + "bridge.proposal_cache.signature.address_mismatch", + Count + ), + ( + proposal_cache_gc_runs, + "bridge.proposal_cache.gc.runs", + Count + ), + ( + proposal_cache_gc_last_removed, + "bridge.proposal_cache.gc.last_removed", + Gauge + ), + ( + tui_snapshot_requests, + "bridge.tui.snapshot.requests", + Count + ), + ( + tui_snapshot_uncached_requests, + "bridge.tui.snapshot.uncached_requests", + Count + ), + ( + tui_snapshot_alert_limit_requested, + "bridge.tui.snapshot.requested.alert_limit", + Gauge + ), + ( + tui_snapshot_limit_requested, + "bridge.tui.snapshot.requested.limit", + Gauge + ), + ( + tui_snapshot_offset_requested, + "bridge.tui.snapshot.requested.offset", + Gauge + ), + ( + tui_snapshot_limit_over_cache, + "bridge.tui.snapshot.requested.limit_over_cache", + Count + ), + ( + tui_snapshot_limit_over_10000, + "bridge.tui.snapshot.requested.limit_over_10000", + Count + ), + ( + tui_snapshot_response_time, + "bridge.tui.snapshot.response_time", + TimingCount + ), + ( + tui_snapshot_to_response_time, + "bridge.tui.snapshot.to_response_time", + TimingCount + ), + ( + tui_snapshot_uncached_load_time, + "bridge.tui.snapshot.uncached_load_time", + TimingCount + ), + ( + tui_snapshot_build_cache_time, + "bridge.tui.snapshot.build_cache_time", + TimingCount + ), + ( + tui_snapshot_build_proposals_time, + "bridge.tui.snapshot.build_proposals_time", + TimingCount + ), + ( + tui_proposals_pending_inbound_count, + "bridge.tui.proposals.pending_inbound_count", + Gauge + ), + ( + tui_proposals_history_count, + "bridge.tui.proposals.history_count", + Gauge + ), + ( + tui_proposals_last_submitted_present, + "bridge.tui.proposals.last_submitted_present", + Gauge + ), + ( + tui_proposals_pending_inbound_signature_count, + "bridge.tui.proposals.pending_inbound_signature_count", + Gauge + ), + ( + tui_proposals_history_signature_count, + "bridge.tui.proposals.history_signature_count", + Gauge + ), + ( + tui_proposals_pending_inbound_approx_bytes, + "bridge.tui.proposals.pending_inbound_approx_bytes", + Gauge + ), + ( + tui_proposals_history_approx_bytes, + "bridge.tui.proposals.history_approx_bytes", + Gauge + ), + ( + tui_proposals_last_submitted_approx_bytes, + "bridge.tui.proposals.last_submitted_approx_bytes", + Gauge + ), + ( + tui_proposals_approx_total_bytes, + "bridge.tui.proposals.approx_total_bytes", + Gauge + ), + ( + deposit_log_snapshot_time, + "bridge.tui.deposit_log.snapshot_time", + TimingCount + ), + ( + deposit_log_count_time, + "bridge.tui.deposit_log.count_time", + TimingCount + ), + ( + deposit_log_page_time, + "bridge.tui.deposit_log.page_time", + TimingCount + ), + ( + bridge_state_snapshot_time, + "bridge.runtime.bridge_state.snapshot_time", + TimingCount + ), + ( + bridge_state_peek_unsettled_deposits_time, + "bridge.runtime.bridge_state.peek.unsettled_deposits_time", TimingCount + ), + ( + bridge_state_peek_unsettled_withdrawals_time, + "bridge.runtime.bridge_state.peek.unsettled_withdrawals_time", TimingCount + ), + ( + bridge_state_peek_base_next_height_time, + "bridge.runtime.bridge_state.peek.base_next_height_time", TimingCount + ), + ( + bridge_state_peek_nock_next_height_time, + "bridge.runtime.bridge_state.peek.nock_next_height_time", TimingCount + ), + ( + bridge_state_peek_base_hold_info_time, + "bridge.runtime.bridge_state.peek.base_hold_info_time", TimingCount + ), + ( + bridge_state_peek_nock_hold_info_time, + "bridge.runtime.bridge_state.peek.nock_hold_info_time", TimingCount + ), + ( + bridge_state_peek_stop_state_time, "bridge.runtime.bridge_state.peek.stop_state_time", + TimingCount + ), + ( + bridge_state_peek_is_fakenet_time, "bridge.runtime.bridge_state.peek.is_fakenet_time", + TimingCount + ), + ( + tui_deposit_log_limit_requested, + "bridge.tui.deposit_log.requested.limit", + Gauge + ), + ( + tui_deposit_log_offset_requested, + "bridge.tui.deposit_log.requested.offset", + Gauge + ), + ( + tui_deposit_log_rows_returned, + "bridge.tui.deposit_log.returned.rows", + Gauge + ), + ( + tui_deposit_log_limit_over_cache, + "bridge.tui.deposit_log.requested.limit_over_cache", + Count + ), + ( + tui_deposit_log_limit_over_10000, + "bridge.tui.deposit_log.requested.limit_over_10000", + Count + ) +]; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[repr(i32)] +pub enum RunningStatusMetric { + Stop = 0, + Running = 1, +} + +impl From for f64 { + fn from(value: RunningStatusMetric) -> Self { + value as i32 as f64 + } +} + +static METRICS: OnceLock> = OnceLock::new(); + +pub fn init_metrics() -> Arc { + METRICS + .get_or_init(|| { + let mut metrics = BridgeHealthMetrics::register(gnort::global_metrics_registry()) + .expect("Failed to register metrics!"); + metrics.deposit_log_snapshot_time = metrics + .deposit_log_snapshot_time + .with_unit(UnitOfTime::Micros); + metrics.deposit_log_count_time = + metrics.deposit_log_count_time.with_unit(UnitOfTime::Micros); + metrics.deposit_log_page_time = + metrics.deposit_log_page_time.with_unit(UnitOfTime::Micros); + metrics.bridge_state_snapshot_time = metrics + .bridge_state_snapshot_time + .with_unit(UnitOfTime::Micros); + metrics.bridge_state_peek_unsettled_deposits_time = metrics + .bridge_state_peek_unsettled_deposits_time + .with_unit(UnitOfTime::Micros); + metrics.bridge_state_peek_unsettled_withdrawals_time = metrics + .bridge_state_peek_unsettled_withdrawals_time + .with_unit(UnitOfTime::Micros); + metrics.bridge_state_peek_base_next_height_time = metrics + .bridge_state_peek_base_next_height_time + .with_unit(UnitOfTime::Micros); + metrics.bridge_state_peek_nock_next_height_time = metrics + .bridge_state_peek_nock_next_height_time + .with_unit(UnitOfTime::Micros); + metrics.bridge_state_peek_base_hold_info_time = metrics + .bridge_state_peek_base_hold_info_time + .with_unit(UnitOfTime::Micros); + metrics.bridge_state_peek_nock_hold_info_time = metrics + .bridge_state_peek_nock_hold_info_time + .with_unit(UnitOfTime::Micros); + metrics.bridge_state_peek_stop_state_time = metrics + .bridge_state_peek_stop_state_time + .with_unit(UnitOfTime::Micros); + metrics.bridge_state_peek_is_fakenet_time = metrics + .bridge_state_peek_is_fakenet_time + .with_unit(UnitOfTime::Micros); + metrics.tui_snapshot_response_time = + metrics.tui_snapshot_response_time.with_unit(UnitOfTime::Micros); + metrics.tui_snapshot_to_response_time = metrics + .tui_snapshot_to_response_time + .with_unit(UnitOfTime::Micros); + metrics.tui_snapshot_uncached_load_time = metrics + .tui_snapshot_uncached_load_time + .with_unit(UnitOfTime::Micros); + metrics.tui_snapshot_build_cache_time = metrics + .tui_snapshot_build_cache_time + .with_unit(UnitOfTime::Micros); + metrics.tui_snapshot_build_proposals_time = metrics + .tui_snapshot_build_proposals_time + .with_unit(UnitOfTime::Micros); + Arc::new(metrics) + }) + .clone() +} + +pub fn update_bridge_metrics(network: &NetworkState, last_deposit_nonce: Option) { + let metrics = init_metrics(); + + let running_metric = if network.kernel_stopped { + RunningStatusMetric::Stop + } else { + RunningStatusMetric::Running + }; + let hold_base_height = if network.base_hold { + network.base_hold_height.unwrap_or_default() + } else { + 0 + }; + let hold_nock_height = if network.nock_hold { + network.nock_hold_height.unwrap_or_default() + } else { + 0 + }; + let base_height = if network.base.last_updated.is_some() { + network.base.height + } else { + 0 + }; + let nock_height = if network.nockchain.last_updated.is_some() { + network.nockchain.height + } else { + 0 + }; + let last_deposit_nonce = last_deposit_nonce.unwrap_or_default(); + + metrics.running_status.swap(f64::from(running_metric)); + metrics.base_hold_height.swap(hold_base_height as f64); + metrics.nock_hold_height.swap(hold_nock_height as f64); + metrics.base_block_height.swap(base_height as f64); + metrics.nock_block_height.swap(nock_height as f64); + metrics.last_deposit_nonce.swap(last_deposit_nonce as f64); +} diff --git a/crates/bridge/src/nockchain.rs b/crates/bridge/src/nockchain.rs new file mode 100644 index 000000000..c4cc0e919 --- /dev/null +++ b/crates/bridge/src/nockchain.rs @@ -0,0 +1,899 @@ +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +use backon::{ExponentialBuilder, Retryable}; +use nockapp::noun::slab::{NockJammer, NounSlab}; +use nockapp::{Bytes, ToBytes}; +use nockapp_grpc::services::private_nockapp::client::PrivateNockAppGrpcClient; +use nockchain_types::tx_engine::common::{BlockId, Heavy, Page, TxId}; +use noun_serde::prelude::*; +use tokio::time::sleep; +use tracing::{debug, info, warn}; + +use crate::bridge_status::BridgeStatus; +use crate::errors::BridgeError; +use crate::metrics; +use crate::runtime::{BridgeEvent, BridgeRuntimeHandle, ChainEvent, NockBlockEvent}; +use crate::stop::StopHandle; +use crate::tui::types::{AlertSeverity, ChainState, NetworkState, NockchainApiStatus}; +use crate::types::Tx; + +const DEFAULT_POLL_INTERVAL: Duration = Duration::from_secs(10); +const CLIENT_PID: i32 = 1; + +/// Default nockchain confirmation depth used by the driver if not specified in config. +/// +/// The bridge kernel assumes blocks it receives are final; this is enforced by the Rust driver. +pub const DEFAULT_NOCKCHAIN_CONFIRMATION_DEPTH: u64 = 100; + +fn confirmed_height(chain_tip: u64, confirmation_depth: u64) -> Option { + if confirmation_depth == 0 { + return None; + } + let target = chain_tip.saturating_sub(confirmation_depth); + if target == 0 { + None + } else { + Some(target) + } +} + +struct NockBlockObservation { + block: Page, + page_slab: NounSlab, + page_noun: nockapp::Noun, + txs: Vec<(TxId, Tx)>, +} + +pub struct NockchainWatcher { + endpoint: String, + poll_interval: Duration, + runtime: Arc, + confirmation_depth: u64, + stop: StopHandle, + /// Optional bridge_status for connection status + alert updates. + bridge_status: Option, +} + +impl NockchainWatcher { + pub fn new( + endpoint: String, + runtime: Arc, + confirmation_depth: u64, + stop: StopHandle, + ) -> Self { + Self { + endpoint, + poll_interval: DEFAULT_POLL_INTERVAL, + runtime, + confirmation_depth, + stop, + bridge_status: None, + } + } + + pub fn with_poll_interval( + endpoint: String, + runtime: Arc, + poll_interval: Duration, + confirmation_depth: u64, + stop: StopHandle, + ) -> Self { + Self { + endpoint, + poll_interval, + runtime, + confirmation_depth, + stop, + bridge_status: None, + } + } + + /// Set the TUI state for connection status updates. + pub fn with_bridge_status(mut self, bridge_status: BridgeStatus) -> Self { + self.bridge_status = Some(bridge_status); + self + } + + /// Update the nockchain API connection status in the TUI. + fn update_status(&self, status: NockchainApiStatus) { + if let Some(ref bridge_status) = self.bridge_status { + bridge_status.update_nockchain_api_status(status); + } + } + + /// Update the nockchain tip hash in the TUI. + fn update_tip_hash(&self, tip_hash: String) { + if let Some(ref bridge_status) = self.bridge_status { + bridge_status.update_nockchain_tip_hash(tip_hash); + } + } + + /// Push an alert to the TUI. + fn push_alert(&self, severity: AlertSeverity, title: String, message: String) { + if let Some(ref bridge_status) = self.bridge_status { + bridge_status.push_alert(severity, title, message, "nock-watcher".to_string()); + } + } + + pub async fn run(self) -> Result<(), BridgeError> { + let mut was_connected = false; + + // Unlimited retries with exponential backoff + let backoff = || { + ExponentialBuilder::default() + .with_min_delay(Duration::from_secs(1)) + .with_max_delay(Duration::from_secs(300)) + .with_jitter() + }; + + self.update_status(NockchainApiStatus::connecting(0, None)); + + loop { + if self.stop.is_stopped() { + sleep(self.poll_interval).await; + continue; + } + + let attempt_count = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)); + let attempt_count_notify = attempt_count.clone(); + let endpoint = self.endpoint.clone(); + let connect = || async { PrivateNockAppGrpcClient::connect(endpoint.clone()).await }; + + self.update_status(NockchainApiStatus::connecting(1, None)); + + let connect_result = connect + .retry(backoff()) + .notify(|err, dur| { + let attempt = + attempt_count_notify.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1; + let error_msg = err.to_string(); + + self.update_status(NockchainApiStatus::connecting( + attempt + 1, + Some(error_msg.clone()), + )); + + warn!( + target: "bridge.nock-watcher", + endpoint=%self.endpoint, + error=%error_msg, + attempt=attempt, + backoff_secs=dur.as_secs(), + "failed to connect, will retry" + ); + + if attempt == 1 || attempt.is_multiple_of(10) { + self.push_alert( + AlertSeverity::Warning, + "Nockchain API Connection Failed".to_string(), + format!( + "Attempt {}: {}", + attempt, + truncate_error_msg(&error_msg, 50) + ), + ); + } + }) + .await; + + match connect_result { + Ok(mut client) => { + self.update_status(NockchainApiStatus::connected()); + + if was_connected { + info!( + target: "bridge.nock-watcher", + endpoint=%self.endpoint, + "reconnected to nockchain gRPC endpoint" + ); + self.push_alert( + AlertSeverity::Info, + "Nockchain API Reconnected".to_string(), + format!("Reconnected to {}", self.endpoint), + ); + } else { + info!( + target: "bridge.nock-watcher", + endpoint=%self.endpoint, + "connected to nockchain gRPC endpoint" + ); + } + was_connected = true; + + if let Err(err) = self.stream_events(&mut client).await { + let error_msg = err.to_string(); + warn!( + target: "bridge.nock-watcher", + error=%error_msg, + "nockchain watcher stream failed, reconnecting" + ); + self.update_status(NockchainApiStatus::disconnected(error_msg.clone())); + self.push_alert( + AlertSeverity::Warning, + "Nockchain API Disconnected".to_string(), + format!("Connection lost: {}", truncate_error_msg(&error_msg, 60)), + ); + } + } + Err(err) => { + // Should not happen with unlimited retries + let error_msg = err.to_string(); + warn!( + target: "bridge.nock-watcher", + endpoint=%self.endpoint, + error=%error_msg, + "connect failed unexpectedly" + ); + self.update_status(NockchainApiStatus::disconnected(error_msg)); + sleep(Duration::from_secs(1)).await; + } + } + } + } + + async fn stream_events( + &self, + client: &mut PrivateNockAppGrpcClient, + ) -> Result<(), BridgeError> { + info!( + target: "bridge.nock-watcher", + confirmation_depth = self.confirmation_depth, + "starting nock observer with confirmation depth" + ); + loop { + if self.stop.is_stopped() { + sleep(self.poll_interval).await; + continue; + } + let (tip_height, tip_hash) = match self.fetch_tip_info(client).await { + Ok(Some(info)) => info, + Ok(None) => { + debug!( + target: "bridge.nock-watcher", + "no heaviest block available from private nockapp" + ); + sleep(self.poll_interval).await; + continue; + } + Err(err) => { + warn!( + target: "bridge.nock-watcher", + error=%err, + "failed to fetch tip height" + ); + sleep(self.poll_interval).await; + continue; + } + }; + self.update_tip_hash(tip_hash); + + let Some(confirmed_target) = confirmed_height(tip_height, self.confirmation_depth) + else { + debug!( + target: "bridge.nock-watcher", + tip_height, + "no confirmed block yet (bootstrap)" + ); + sleep(self.poll_interval).await; + continue; + }; + + let next_needed_height = match self.runtime.peek_nock_next_height().await { + Ok(Some(height)) => height, + Ok(None) => { + debug!( + target: "bridge.nock-watcher", + tip_height, + "kernel has no pending nock block" + ); + sleep(self.poll_interval).await; + continue; + } + Err(err) => { + warn!( + target: "bridge.nock-watcher", + error=%err, + "failed to peek nock next height" + ); + sleep(self.poll_interval).await; + continue; + } + }; + + if confirmed_target < next_needed_height { + debug!( + target: "bridge.nock-watcher", + tip_height, + confirmed_target, + next_needed_height, + "target height not yet confirmed for kernel need" + ); + sleep(self.poll_interval).await; + continue; + } + + match self.fetch_block_at_height(client, next_needed_height).await { + Ok(Some(observation)) => { + let height = observation.block.height; + let block_hash = observation.block.digest.to_base58(); + let txs_count = observation.txs.len(); + let event = NockBlockEvent { + block: observation.block, + page_slab: observation.page_slab, + page_noun: observation.page_noun, + txs: observation.txs, + }; + self.runtime + .send_event(BridgeEvent::Chain(Box::new(ChainEvent::Nock(event)))) + .await?; + info!( + target: "bridge.nock-watcher", + height, + tip_height, + confirmations = tip_height - height, + hash=%block_hash, + txs_count=%txs_count, + "emitted confirmed nock block" + ); + } + Ok(None) => { + debug!( + target: "bridge.nock-watcher", + target = next_needed_height, + "block at target height not found" + ); + } + Err(err) => { + warn!( + target: "bridge.nock-watcher", + target = next_needed_height, + error=%err, + "failed to fetch block at height" + ); + } + } + sleep(self.poll_interval).await; + } + } + + async fn fetch_tip_info( + &self, + client: &mut PrivateNockAppGrpcClient, + ) -> Result, BridgeError> { + let heavy_path = vec![Bytes::from("heavy")]; + let heavy_bytes = jam_path(&heavy_path)?; + let response = client + .peek(CLIENT_PID, heavy_bytes) + .await + .map_err(|err| BridgeError::EventMonitoring(err.to_string()))?; + let (_heavy_slab, heavy_noun) = cue_response(response)?; + let heavy: Heavy = heavy_noun.decode().map_err(|err| { + BridgeError::EventMonitoring(format!("failed to decode heavy response: {}", err)) + })?; + let Some(block_id_base58) = heavy.to_base58() else { + return Ok(None); + }; + let tip_hash = block_id_base58.clone(); + + let block_path = vec![Bytes::from("block"), Bytes::from(block_id_base58.clone())]; + let block_bytes = jam_path(&block_path)?; + let response = client + .peek(CLIENT_PID, block_bytes) + .await + .map_err(|err| BridgeError::EventMonitoring(err.to_string()))?; + let (_page_slab, block_noun) = cue_response(response)?; + + let (page, _page_noun) = decode_page_from_peek(&block_noun)?; + Ok(Some((page.height, tip_hash))) + } + + async fn fetch_block_at_height( + &self, + client: &mut PrivateNockAppGrpcClient, + height: u64, + ) -> Result, BridgeError> { + let heavy_n_path = vec![Bytes::from("heavy-n"), Bytes::from(height.to_bytes()?)]; + let heavy_n_bytes = jam_path(&heavy_n_path)?; + let response = client + .peek(CLIENT_PID, heavy_n_bytes) + .await + .map_err(|err| BridgeError::EventMonitoring(err.to_string()))?; + let (page_slab, block_noun) = cue_response(response)?; + + let (page, page_noun) = match decode_page_from_peek(&block_noun) { + Ok(result) => result, + Err(_) => return Ok(None), + }; + + let txs = self + .fetch_transactions(client, &page.digest, &page.tx_ids) + .await?; + + Ok(Some(NockBlockObservation { + block: page, + page_slab, + page_noun, + txs, + })) + } + + async fn fetch_transactions( + &self, + client: &mut PrivateNockAppGrpcClient, + block_id: &BlockId, + tx_ids: &[TxId], + ) -> Result, BridgeError> { + let block_id_base58 = block_id.to_base58(); + let mut txs = Vec::with_capacity(tx_ids.len()); + for tx_id in tx_ids { + let tx_id_base58 = tx_id.to_base58(); + let tx_path = vec![ + Bytes::from("block-transaction"), + Bytes::from(block_id_base58.clone()), + Bytes::from(tx_id_base58), + ]; + let tx_bytes = jam_path(&tx_path)?; + let response = client + .peek(CLIENT_PID, tx_bytes) + .await + .map_err(|err| BridgeError::EventMonitoring(err.to_string()))?; + let (_tx_slab, tx_noun) = cue_response(response)?; + let tx = decode_tx_from_peek(&tx_noun)?; + txs.push((tx_id.clone(), tx)); + } + Ok(txs) + } +} + +/// Poll chain heights and kernel state, update TUI NetworkState. +pub async fn run_network_monitor( + runtime: Arc, + bridge_status: BridgeStatus, + poll_interval: Duration, +) -> Result<(), BridgeError> { + let mut interval = tokio::time::interval(poll_interval); + + // Fetch fakenet status once; retry until available. + // The peek returns true for fakenet, false for mainnet. + let mut is_fakenet: Option = None; + + loop { + interval.tick().await; + + // Peek kernel state counts (includes hold status) + // This method never fails - returns defaults on error + let current_bridge_state = runtime.update_bridge_state().await; + let base_height = current_bridge_state + .base_next_height + .map(|height| height.saturating_sub(1)); + let nock_height = current_bridge_state + .nock_next_height + .map(|height| height.saturating_sub(1)); + + if is_fakenet.is_none() { + match current_bridge_state.is_fakenet { + Some(status) => { + info!( + target: "bridge.network-monitor", + is_fakenet = status, + "detected network mode: {}", + if status { "fakenet" } else { "mainnet" } + ); + is_fakenet = Some(status); + } + None => { + warn!( + target: "bridge.network-monitor", + "failed to peek network mode, will retry" + ); + } + } + } + + let now = SystemTime::now(); + let state = bridge_status.network(); + let base_tip_hash = current_bridge_state + .base_tip_hash + .clone() + .unwrap_or_else(|| state.base.tip_hash.clone()); + let mut network_state = NetworkState { + nockchain_api_status: state.nockchain_api_status.clone(), + ..Default::default() + }; + network_state.base.tip_hash = base_tip_hash.clone(); + network_state.nockchain.tip_hash = state.nockchain.tip_hash.clone(); + network_state.base_next_height = current_bridge_state.base_next_height; + network_state.nock_next_height = current_bridge_state.nock_next_height; + + if let Some(height) = base_height { + network_state.base = ChainState { + height, + tip_hash: base_tip_hash.clone(), + confirmations: 0, + is_syncing: false, + last_updated: Some(now), + }; + debug!( + target: "bridge.network-monitor", + base_height = height, + "updated base chain height" + ); + } + + if let Some(height) = nock_height { + let tip_hash = state.nockchain.tip_hash.clone(); + + network_state.nockchain = ChainState { + height, + tip_hash, + confirmations: 0, + is_syncing: false, + last_updated: Some(now), + }; + debug!( + target: "bridge.network-monitor", + nock_height = height, + "updated nockchain height" + ); + } + + // Populate kernel state counts + network_state.unsettled_deposit_count = current_bridge_state.unsettled_deposits; + network_state.unsettled_withdrawal_count = current_bridge_state.unsettled_withdrawals; + + // Pending deposits come from kernel state counts (independent of TUI focus). + network_state.pending_deposits = current_bridge_state.unsettled_deposits; + network_state.pending_withdrawals = current_bridge_state.unsettled_withdrawals; + + // Populate hold status + network_state.base_hold = current_bridge_state.base_hold; + network_state.nock_hold = current_bridge_state.nock_hold; + network_state.kernel_stopped = current_bridge_state.kernel_stopped; + network_state.base_hold_height = if current_bridge_state.base_hold { + current_bridge_state.base_hold_height + } else { + None + }; + network_state.nock_hold_height = if current_bridge_state.nock_hold { + current_bridge_state.nock_hold_height + } else { + None + }; + + // Populate network mode (mainnet vs fakenet) + // is_fakenet is true for fakenet, so invert for is_mainnet + network_state.is_mainnet = is_fakenet.map(|f| !f); + + debug!( + target: "bridge.network-monitor", + unsettled_deposits = current_bridge_state.unsettled_deposits, + unsettled_withdrawals = current_bridge_state.unsettled_withdrawals, + base_hold = current_bridge_state.base_hold, + nock_hold = current_bridge_state.nock_hold, + kernel_stopped = current_bridge_state.kernel_stopped, + "updated kernel state counts" + ); + + metrics::update_bridge_metrics(&network_state, bridge_status.last_deposit_nonce()); + bridge_status.update_network(network_state); + } +} + +/// Truncate an error message for display in alerts. +fn truncate_error_msg(error: &str, max_len: usize) -> String { + if error.len() <= max_len { + error.to_string() + } else { + format!("{}...", &error[..max_len]) + } +} + +fn jam_path(path: &[Bytes]) -> Result, BridgeError> { + let mut slab: NounSlab = NounSlab::new(); + let mut list = nockvm::noun::D(0); + for segment in path.iter().rev() { + let atom = unsafe { + let mut ia = nockvm::noun::IndirectAtom::new_raw_bytes( + &mut slab, + segment.len(), + segment.as_ptr(), + ); + ia.normalize_as_atom().as_noun() + }; + list = nockvm::noun::T(&mut slab, &[atom, list]); + } + slab.set_root(list); + Ok(slab.jam().to_vec()) +} + +fn cue_response(bytes: Vec) -> Result<(NounSlab, nockapp::Noun), BridgeError> { + let mut slab: NounSlab = NounSlab::new(); + let noun = slab + .cue_into(Bytes::from(bytes)) + .map_err(|err| BridgeError::EventMonitoring(err.to_string()))?; + Ok((slab, noun)) +} + +fn decode_page_from_peek(noun: &nockapp::Noun) -> Result<(Page, nockapp::Noun), BridgeError> { + let outer_cell = noun + .as_cell() + .map_err(|_| BridgeError::EventMonitoring("peek response expected to be cell".into()))?; + + let outer_head = outer_cell.head(); + let outer_tail = outer_cell.tail(); + + let outer_tag = outer_head.as_atom().map_err(|_| { + BridgeError::EventMonitoring("peek response outer unit tag expected to be atom".into()) + })?; + + let outer_tag_val = outer_tag + .as_u64() + .map_err(|_| BridgeError::EventMonitoring("peek response outer tag too large".into()))?; + + if outer_tag_val != 0 { + return Err(BridgeError::EventMonitoring(format!( + "peek response indicates no data (outer unit tag={})", + outer_tag_val + ))); + } + + let inner = outer_tail; + + if inner.is_atom() { + return Err(BridgeError::EventMonitoring( + "peek response inner is atom (no data)".into(), + )); + } + + let inner_cell = inner.as_cell().map_err(|_| { + BridgeError::EventMonitoring("peek response inner expected to be cell".into()) + })?; + + let inner_head = inner_cell.head(); + let inner_tail = inner_cell.tail(); + + if let Ok(tag_atom) = inner_head.as_atom() { + if let Ok(tag_val) = tag_atom.as_u64() { + if tag_val == 0 { + if inner_tail.is_atom() { + if let Ok(atom) = inner_tail.as_atom() { + if let Ok(val) = atom.as_u64() { + if val == 0 { + return Err(BridgeError::EventMonitoring( + "block not found (inner unit is null)".into(), + )); + } + } + } + return Err(BridgeError::EventMonitoring( + "inner_tail is atom but not null - unexpected structure".into(), + )); + } + + let page = Page::from_noun(&inner_tail).map_err(|err| { + BridgeError::EventMonitoring(format!( + "failed to decode Page (after inner unwrap): {}", + err + )) + })?; + return Ok((page, inner_tail)); + } + } + } + + let page = Page::from_noun(&inner) + .map_err(|err| BridgeError::EventMonitoring(format!("failed to decode Page: {}", err)))?; + Ok((page, inner)) +} + +fn decode_tx_from_peek(noun: &nockapp::Noun) -> Result { + // peek returns (unit (unit tx:t)), need to unwrap both layers + let outer_cell = noun + .as_cell() + .map_err(|_| BridgeError::EventMonitoring("peek response expected to be cell".into()))?; + + let outer_tag = outer_cell.head().as_atom().map_err(|_| { + BridgeError::EventMonitoring("peek response outer unit tag expected to be atom".into()) + })?; + + if outer_tag + .as_u64() + .map_err(|_| BridgeError::EventMonitoring("peek response outer tag too large".into()))? + != 0 + { + return Err(BridgeError::EventMonitoring( + "peek response indicates no data for transaction (outer unit)".into(), + )); + } + + let inner_cell = outer_cell.tail().as_cell().map_err(|_| { + BridgeError::EventMonitoring("peek response inner unit expected to be cell".into()) + })?; + + let inner_tag = inner_cell.head().as_atom().map_err(|_| { + BridgeError::EventMonitoring("peek response inner unit tag expected to be atom".into()) + })?; + + if inner_tag + .as_u64() + .map_err(|_| BridgeError::EventMonitoring("peek response inner tag too large".into()))? + != 0 + { + return Err(BridgeError::EventMonitoring( + "peek response indicates no data for transaction (inner unit)".into(), + )); + } + + Tx::from_noun(&inner_cell.tail()) + .map_err(|err| BridgeError::EventMonitoring(format!("failed to decode Tx: {}", err))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn atom_to_string(atom: nockvm::noun::Atom) -> String { + let mut bytes = atom.to_be_bytes(); + while bytes.first() == Some(&0) { + bytes.remove(0); + } + bytes.reverse(); + String::from_utf8(bytes).expect("utf8") + } + + #[test] + fn jam_path_roundtrips_through_cue() { + let path = vec![Bytes::from("block"), Bytes::from("42")]; + let jammed = jam_path(&path).expect("jam path"); + let mut slab: NounSlab = NounSlab::new(); + let mut current = slab + .cue_into(Bytes::from(jammed.clone())) + .expect("cue jammed path"); + for segment in path { + let cell = current.as_cell().expect("cell"); + let atom = cell.head().as_atom().expect("atom"); + let decoded = atom_to_string(atom); + assert_eq!(decoded, segment); + current = cell.tail(); + } + } + + #[test] + fn hash_to_base58_produces_valid_output() { + use nockchain_math::belt::Belt; + use nockchain_types::tx_engine::common::Hash; + + let hash = Hash([Belt(1), Belt(2), Belt(3), Belt(4), Belt(5)]); + let base58 = hash.to_base58(); + assert!(!base58.is_empty()); + } + + #[test] + fn tx_id_to_base58_does_not_error() { + use nockchain_math::belt::Belt; + use nockchain_types::tx_engine::common::Hash; + + let tx_id = Hash([Belt(1), Belt(2), Belt(3), Belt(4), Belt(5)]); + let result = tx_id.to_base58(); + assert!(!result.is_empty()); + } + + #[test] + fn decode_page_from_peek_decodes_tagged_bn_numbers() { + use nockchain_math::belt::Belt; + use nockchain_types::tx_engine::common::{CoinbaseSplit, Hash}; + use noun_serde::NounEncode; + use num_bigint::BigUint; + + fn tagged_bn_noun(allocator: &mut NounSlab, chunks: &[u32]) -> nockapp::Noun { + let chunks_noun = chunks.to_vec().to_noun(allocator); + nockvm::noun::T(allocator, &[nockvm::noun::D(28258), chunks_noun]) + } + + fn biguint_from_u32_chunks(chunks: &[u32]) -> BigUint { + let mut bytes = Vec::with_capacity(chunks.len() * 4); + for &chunk in chunks { + bytes.extend_from_slice(&chunk.to_le_bytes()); + } + while bytes.last() == Some(&0) { + bytes.pop(); + } + BigUint::from_bytes_le(&bytes) + } + + let digest = Hash([Belt(1), Belt(2), Belt(3), Belt(4), Belt(5)]); + let parent = Hash([Belt(6), Belt(7), Belt(8), Belt(9), Belt(10)]); + let expected_coinbase = CoinbaseSplit::V0(vec![0xaa, 0xbb]); + let expected_msg = vec![7u32, 8u32, 9u32]; + + let target_chunks = [0x89abcdef, 0x01234567, 0xfedcba98, 0x76543210]; + let accumulated_work_chunks = [0xffffffff, 0x00000000, 0x22222222, 0x33333333, 0x44444444]; + + let mut slab: NounSlab = NounSlab::new(); + let digest_noun = digest.to_noun(&mut slab); + let parent_noun = parent.to_noun(&mut slab); + let coinbase_noun = expected_coinbase.to_noun(&mut slab); + let msg_noun = expected_msg.to_noun(&mut slab); + let target_noun = tagged_bn_noun(&mut slab, &target_chunks); + let accumulated_work_noun = tagged_bn_noun(&mut slab, &accumulated_work_chunks); + + let page_noun = nockvm::noun::T( + &mut slab, + &[ + nockvm::noun::D(1), // page version + digest_noun, + nockvm::noun::D(0), // no pow + parent_noun, + nockvm::noun::D(0), // empty tx_ids z-set + coinbase_noun, + nockvm::noun::D(1_717_171), + nockvm::noun::D(42), + target_noun, + accumulated_work_noun, + nockvm::noun::D(77), + msg_noun, + ], + ); + + // peek response shape: [~ [~ page]] + let inner_unit = nockvm::noun::T(&mut slab, &[nockvm::noun::D(0), page_noun]); + let peek_noun = nockvm::noun::T(&mut slab, &[nockvm::noun::D(0), inner_unit]); + + let (page, _) = decode_page_from_peek(&peek_noun).expect("decode tagged-bn page"); + + assert_eq!(page.digest, digest, "digest should decode correctly"); + assert_eq!(page.parent, parent, "parent should decode correctly"); + assert_eq!(page.coinbase, expected_coinbase, "coinbase should decode"); + assert_eq!(page.timestamp, 1_717_171, "timestamp should decode"); + assert_eq!(page.epoch_counter, 42, "epoch counter should decode"); + assert_eq!(page.height, 77, "height should decode"); + assert_eq!(page.msg, expected_msg, "msg should decode"); + assert_eq!( + page.target.0, + biguint_from_u32_chunks(&target_chunks), + "target should decode via [%bn (list u32)] path" + ); + assert_eq!( + page.accumulated_work.0, + biguint_from_u32_chunks(&accumulated_work_chunks), + "accumulated_work should decode via [%bn (list u32)] path" + ); + } + + #[test] + fn confirmed_height_returns_none_during_bootstrap() { + let depth = DEFAULT_NOCKCHAIN_CONFIRMATION_DEPTH; + assert!(confirmed_height(0, depth).is_none()); + assert!(confirmed_height(depth, depth).is_none()); + } + + #[test] + fn confirmed_height_returns_target_when_ready() { + let depth = DEFAULT_NOCKCHAIN_CONFIRMATION_DEPTH; + let tip = depth + 50; + let target = confirmed_height(tip, depth); + assert!(target.is_some()); + assert_eq!(target.expect("target should be Some for valid input"), 50); + } + + #[test] + fn truncate_error_msg_short_string() { + let msg = "short error"; + assert_eq!(truncate_error_msg(msg, 50), "short error"); + } + + #[test] + fn truncate_error_msg_exact_length() { + let msg = "12345"; + assert_eq!(truncate_error_msg(msg, 5), "12345"); + } + + #[test] + fn truncate_error_msg_long_string() { + let msg = "this is a very long error message that should be truncated"; + let result = truncate_error_msg(msg, 20); + assert_eq!(result, "this is a very long ..."); + assert_eq!(result.len(), 23); // 20 + "..." + } +} diff --git a/crates/bridge/src/proposal_cache.rs b/crates/bridge/src/proposal_cache.rs new file mode 100644 index 000000000..a2461f643 --- /dev/null +++ b/crates/bridge/src/proposal_cache.rs @@ -0,0 +1,1283 @@ +use std::collections::HashMap; +use std::mem::size_of; +use std::sync::{Arc, RwLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use alloy::primitives::Address; + +use crate::types::{DepositId, NockDepositRequestData}; + +/// Signature threshold for multisig aggregation (3-of-5). +pub const SIGNATURE_THRESHOLD: usize = 3; + +/// Status of a proposal in the cache. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ProposalStatus { + /// Collecting signatures from peers + Collecting, + /// Threshold reached, ready to post on-chain + Ready, + /// Currently being posted to Ethereum + Posting, + /// Successfully confirmed on-chain + Confirmed, + /// Failed to post or invalid + Failed, +} + +/// Result of adding a signature to a proposal. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SignatureAddResult { + /// Signature added successfully + Added, + /// Signature already exists from this address + Duplicate, + /// Signature verification failed + Invalid(String), + /// Threshold reached, proposal is now ready + ThresholdReached, + /// Deposit already confirmed on-chain + Stale, +} + +/// State for a single proposal being aggregated. +#[derive(Debug, Clone)] +pub struct ProposalState { + /// The proposal payload (deposit data) + pub proposal: NockDepositRequestData, + /// Keccak256 hash of the proposal for signature verification + pub proposal_hash: [u8; 32], + /// Our own signature (if we signed it) + pub my_signature: Option>, + /// Signatures from peers, keyed by their Ethereum address + pub peer_signatures: HashMap>, + /// Unix timestamp when this proposal was created + pub created_at: u64, + /// Unix timestamp when threshold was reached (for failover timing) + pub ready_at: Option, + /// Unix timestamp when this proposal failed (for skip timeout) + pub failed_at: Option, + /// Current status of the proposal + pub status: ProposalStatus, +} + +impl ProposalState { + /// Create a new proposal state. + pub fn new(proposal: NockDepositRequestData) -> Self { + let proposal_hash = proposal.compute_proposal_hash(); + let created_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + Self { + proposal, + proposal_hash, + my_signature: None, + peer_signatures: HashMap::new(), + created_at, + ready_at: None, + failed_at: None, + status: ProposalStatus::Collecting, + } + } + + /// Check if this proposal has reached the signature threshold. + pub fn has_threshold(&self) -> bool { + let total_sigs = + self.peer_signatures.len() + if self.my_signature.is_some() { 1 } else { 0 }; + total_sigs >= SIGNATURE_THRESHOLD + } + + /// Get all signatures (mine + peers) for posting to Ethereum. + pub fn all_signatures(&self) -> Vec> { + let mut sigs = Vec::new(); + if let Some(ref my_sig) = self.my_signature { + sigs.push(my_sig.clone()); + } + for sig in self.peer_signatures.values() { + sigs.push(sig.clone()); + } + sigs + } +} + +/// Data for adding a signature to a proposal. +#[derive(Debug, Clone)] +pub struct SignatureData { + /// The Ethereum address of the signer + pub signer_address: Address, + /// The 65-byte ECDSA signature (r, s, v) + pub signature: Vec, + /// The expected proposal hash (for queuing if deposit unknown) + pub proposal_hash: [u8; 32], + /// Whether this is our own signature + pub is_mine: bool, +} + +/// A pending signature waiting for its proposal to be processed. +#[derive(Debug, Clone)] +pub struct PendingSignature { + pub signer_address: Address, + pub signature: Vec, + pub proposal_hash: [u8; 32], + pub received_at: u64, +} + +#[derive(Debug, Clone)] +pub struct PendingSignatureMismatch { + pub signer_address: Address, + pub expected_hash: [u8; 32], + pub received_hash: [u8; 32], +} + +#[derive(Debug, Clone, Default)] +pub struct PendingSignatureReport { + pub applied: usize, + pub mismatched: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct ProposalCacheMetricsSnapshot { + pub proposal_total: usize, + pub collecting: usize, + pub ready: usize, + pub posting: usize, + pub confirmed: usize, + pub failed: usize, + pub total_peer_signatures: usize, + pub max_peer_signatures_per_proposal: usize, + pub proposals_with_my_signature: usize, + pub pending_signature_deposit_count: usize, + pub pending_signature_total: usize, + pub oldest_age_secs: u64, + pub oldest_confirmed_age_secs: u64, + pub oldest_failed_age_secs: u64, + pub pending_oldest_age_secs: u64, + pub approx_state_bytes: usize, + pub approx_peer_signature_bytes: usize, + pub approx_my_signature_bytes: usize, + pub approx_pending_signature_bytes: usize, + pub approx_total_bytes: usize, +} + +/// Thread-safe cache for aggregating signatures on proposals. +/// +/// This cache stores proposals keyed by DepositId and tracks signatures from +/// multiple bridge nodes until the threshold is reached. +/// +/// Signatures that arrive before we've processed the deposit ourselves are +/// queued in `pending_signatures` and applied when we create the proposal. +#[derive(Debug, Clone)] +pub struct ProposalCache { + inner: Arc>>, + /// Signatures received before we processed the deposit ourselves + pending_signatures: Arc>>>, +} + +impl Default for ProposalCache { + fn default() -> Self { + Self::new() + } +} + +impl ProposalCache { + /// Create a new empty proposal cache. + pub fn new() -> Self { + Self { + inner: Arc::new(RwLock::new(HashMap::new())), + pending_signatures: Arc::new(RwLock::new(HashMap::new())), + } + } + + /// Add a signature for a proposal. + /// + /// # Arguments + /// * `deposit_id` - The unique identifier for the deposit/proposal + /// * `sig_data` - The signature data (signer address, signature bytes, hash, ownership) + /// * `proposal` - The proposal data (needed if this is the first signature) + /// * `verify_fn` - Function to verify signature validity + /// + /// # Returns + /// Result indicating whether the signature was added and if threshold was reached. + pub fn add_signature( + &self, + deposit_id: &DepositId, + sig_data: SignatureData, + proposal: Option, + verify_fn: F, + ) -> Result + where + F: FnOnce(&[u8; 32], &[u8]) -> Option
, + { + let metrics = crate::metrics::init_metrics(); + // If we have proposal data, pre-emptively evict any existing proposal that uses + // the same nonce but a different proposal_hash. This prevents stale entries from + // blocking the fresh proposal at that nonce. + let mut cache = match self.inner.write() { + Ok(guard) => guard, + Err(e) => { + return Err(format!("Lock poisoned: {}", e)); + } + }; + + if let Some(ref proposal) = proposal { + let incoming_nonce = proposal.nonce; + let incoming_hash = proposal.compute_proposal_hash(); + + cache.retain(|existing_id, state| { + let same_nonce = state.proposal.nonce == incoming_nonce; + let different_hash = state.proposal_hash != incoming_hash; + if same_nonce && different_hash { + tracing::warn!( + target: "bridge.cache", + existing_deposit_id = %hex::encode(existing_id.to_bytes()), + existing_nonce = state.proposal.nonce, + existing_hash = %hex::encode(state.proposal_hash), + incoming_nonce = incoming_nonce, + incoming_hash = %hex::encode(incoming_hash), + "evicting stale proposal at nonce in favor of incoming proposal" + ); + false + } else { + true + } + }); + } + + // Get existing entry or create new one if we have proposal data + let state = match cache.entry(deposit_id.clone()) { + std::collections::hash_map::Entry::Occupied(entry) => entry.into_mut(), + std::collections::hash_map::Entry::Vacant(entry) => { + // For new entries, we need proposal data + let Some(proposal) = proposal else { + // Peer sent signature for deposit we haven't processed yet + // Queue it for later application + drop(cache); // Release cache lock before acquiring pending lock + let mut pending = self + .pending_signatures + .write() + .map_err(|e| format!("Pending lock poisoned: {}", e))?; + + let received_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let pending_sig = PendingSignature { + signer_address: sig_data.signer_address, + signature: sig_data.signature, + proposal_hash: sig_data.proposal_hash, + received_at, + }; + + pending + .entry(deposit_id.clone()) + .or_insert_with(Vec::new) + .push(pending_sig); + metrics + .proposal_cache_pending_signature_queued_unknown_deposit + .increment(); + + tracing::info!( + target: "bridge.cache", + deposit_id = %hex::encode(deposit_id.to_bytes()), + signer = %sig_data.signer_address, + "Queued signature for unknown deposit - will apply when processed" + ); + return Ok(SignatureAddResult::Added); + }; + entry.insert(ProposalState::new(proposal)) + } + }; + + // Early reject if already confirmed on-chain + if state.status == ProposalStatus::Confirmed { + return Ok(SignatureAddResult::Stale); + } + + // Verify signature recovers to expected address + let recovered = match verify_fn(&state.proposal_hash, &sig_data.signature) { + Some(address) => address, + None => { + metrics.proposal_cache_signature_verify_failed.increment(); + return Err("Signature verification failed".to_string()); + } + }; + + if recovered != sig_data.signer_address { + metrics.proposal_cache_signature_address_mismatch.increment(); + return Err(format!( + "Signature address mismatch: expected {}, recovered {}", + sig_data.signer_address, recovered + )); + } + + // Check for duplicate + if sig_data.is_mine && state.my_signature.is_some() { + metrics.proposal_cache_signature_duplicate.increment(); + return Ok(SignatureAddResult::Duplicate); + } + if !sig_data.is_mine && state.peer_signatures.contains_key(&sig_data.signer_address) { + metrics.proposal_cache_signature_duplicate.increment(); + return Ok(SignatureAddResult::Duplicate); + } + + // Add signature + if sig_data.is_mine { + state.my_signature = Some(sig_data.signature); + } else { + state + .peer_signatures + .insert(sig_data.signer_address, sig_data.signature); + } + + // Check if threshold reached + if state.has_threshold() && state.status == ProposalStatus::Collecting { + state.status = ProposalStatus::Ready; + state.ready_at = Some( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + ); + Ok(SignatureAddResult::ThresholdReached) + } else { + Ok(SignatureAddResult::Added) + } + } + + /// Apply any pending signatures that were received before we processed the deposit. + /// + /// Call this after creating a new proposal entry to apply signatures that + /// arrived before we processed the deposit ourselves. + pub fn apply_pending_signatures( + &self, + deposit_id: &DepositId, + verify_fn: F, + ) -> Result + where + F: Fn(&[u8; 32], &[u8]) -> Option
, + { + let metrics = crate::metrics::init_metrics(); + // First, take any pending signatures for this deposit + let pending_sigs = { + let mut pending = self + .pending_signatures + .write() + .map_err(|e| format!("Pending lock poisoned: {}", e))?; + pending.remove(deposit_id).unwrap_or_default() + }; + + if pending_sigs.is_empty() { + return Ok(PendingSignatureReport::default()); + } + + let mut cache = self + .inner + .write() + .map_err(|e| format!("Lock poisoned: {}", e))?; + + let Some(state) = cache.get_mut(deposit_id) else { + return Err("Deposit not found in cache".to_string()); + }; + + let mut report = PendingSignatureReport::default(); + for pending in pending_sigs { + // Verify proposal hash matches + if pending.proposal_hash != state.proposal_hash { + tracing::warn!( + target: "bridge.cache", + deposit_id = %hex::encode(deposit_id.to_bytes()), + signer = %pending.signer_address, + expected_hash = %hex::encode(state.proposal_hash), + received_hash = %hex::encode(pending.proposal_hash), + "Pending signature has wrong proposal hash - discarding" + ); + report.mismatched.push(PendingSignatureMismatch { + signer_address: pending.signer_address, + expected_hash: state.proposal_hash, + received_hash: pending.proposal_hash, + }); + metrics.proposal_cache_pending_signature_mismatched.increment(); + continue; + } + + // Skip if already have signature from this address + if state.peer_signatures.contains_key(&pending.signer_address) { + continue; + } + + // Verify signature + let Some(recovered) = verify_fn(&state.proposal_hash, &pending.signature) else { + tracing::warn!( + target: "bridge.cache", + deposit_id = %hex::encode(deposit_id.to_bytes()), + signer = %pending.signer_address, + "Pending signature verification failed - discarding" + ); + metrics.proposal_cache_pending_signature_verify_failed.increment(); + continue; + }; + + if recovered != pending.signer_address { + tracing::warn!( + target: "bridge.cache", + deposit_id = %hex::encode(deposit_id.to_bytes()), + expected = %pending.signer_address, + recovered = %recovered, + "Pending signature address mismatch - discarding" + ); + metrics + .proposal_cache_pending_signature_address_mismatch + .increment(); + continue; + } + + // Add the signature + state + .peer_signatures + .insert(pending.signer_address, pending.signature); + report.applied += 1; + metrics.proposal_cache_pending_signature_applied.increment(); + + tracing::info!( + target: "bridge.cache", + deposit_id = %hex::encode(deposit_id.to_bytes()), + signer = %pending.signer_address, + "Applied pending signature" + ); + } + + // Check if threshold reached after applying pending signatures + if report.applied > 0 && state.has_threshold() && state.status == ProposalStatus::Collecting + { + state.status = ProposalStatus::Ready; + state.ready_at = Some( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + ); + tracing::info!( + target: "bridge.cache", + deposit_id = %hex::encode(deposit_id.to_bytes()), + "Threshold reached after applying pending signatures" + ); + } + + Ok(report) + } + + /// Check if a deposit is already known in the cache. + pub fn is_known(&self, deposit_id: &DepositId) -> bool { + if let Ok(cache) = self.inner.read() { + cache.contains_key(deposit_id) + } else { + false + } + } + + /// Check if a deposit is already confirmed on-chain. + pub fn is_confirmed(&self, deposit_id: &DepositId) -> bool { + if let Ok(cache) = self.inner.read() { + cache + .get(deposit_id) + .map(|state| state.status == ProposalStatus::Confirmed) + .unwrap_or(false) + } else { + false + } + } + + /// Check if a proposal is ready to be posted (threshold reached). + pub fn is_ready(&self, deposit_id: &DepositId) -> Result { + let cache = self + .inner + .read() + .map_err(|e| format!("Lock poisoned: {}", e))?; + Ok(cache + .get(deposit_id) + .map(|state| state.status == ProposalStatus::Ready) + .unwrap_or(false)) + } + + /// Get all signatures for a proposal that's ready to post. + pub fn get_signatures_for_posting( + &self, + deposit_id: &DepositId, + ) -> Result>>, String> { + let cache = self + .inner + .read() + .map_err(|e| format!("Lock poisoned: {}", e))?; + Ok(cache.get(deposit_id).and_then(|state| { + if state.status == ProposalStatus::Ready && state.has_threshold() { + Some(state.all_signatures()) + } else { + None + } + })) + } + + /// Mark a proposal as currently being posted to Ethereum. + pub fn mark_posting(&self, deposit_id: &DepositId) -> Result<(), String> { + let mut cache = self + .inner + .write() + .map_err(|e| format!("Lock poisoned: {}", e))?; + if let Some(state) = cache.get_mut(deposit_id) { + state.status = ProposalStatus::Posting; + } + Ok(()) + } + + /// Mark a proposal as confirmed on-chain. + pub fn mark_confirmed(&self, deposit_id: &DepositId) -> Result<(), String> { + let mut cache = self + .inner + .write() + .map_err(|e| format!("Lock poisoned: {}", e))?; + if let Some(state) = cache.get_mut(deposit_id) { + state.status = ProposalStatus::Confirmed; + } + Ok(()) + } + + /// Mark a proposal as failed. + pub fn mark_failed(&self, deposit_id: &DepositId) -> Result<(), String> { + let mut cache = self + .inner + .write() + .map_err(|e| format!("Lock poisoned: {}", e))?; + if let Some(state) = cache.get_mut(deposit_id) { + state.status = ProposalStatus::Failed; + state.failed_at = Some( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + ); + } + Ok(()) + } + + /// Garbage collect old confirmed or failed proposals. + /// + /// # Arguments + /// * `max_age_secs` - Remove proposals older than this many seconds + pub fn gc(&self, max_age_secs: u64) -> Result { + let metrics = crate::metrics::init_metrics(); + metrics.proposal_cache_gc_runs.increment(); + let mut cache = self + .inner + .write() + .map_err(|e| format!("Lock poisoned: {}", e))?; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let mut removed = 0; + cache.retain(|_, state| { + let age = now.saturating_sub(state.created_at); + let should_remove = age >= max_age_secs + && (state.status == ProposalStatus::Confirmed + || state.status == ProposalStatus::Failed); + if should_remove { + removed += 1; + } + !should_remove + }); + + metrics.proposal_cache_gc_last_removed.swap(removed as f64); + Ok(removed) + } + + /// Get the current state of a proposal (for debugging/monitoring). + pub fn get_state(&self, deposit_id: &DepositId) -> Result, String> { + let cache = self + .inner + .read() + .map_err(|e| format!("Lock poisoned: {}", e))?; + Ok(cache.get(deposit_id).cloned()) + } + + /// Get count of proposals in each status. + pub fn status_counts(&self) -> Result, String> { + let cache = self + .inner + .read() + .map_err(|e| format!("Lock poisoned: {}", e))?; + let mut counts = HashMap::new(); + for state in cache.values() { + *counts.entry(state.status).or_insert(0) += 1; + } + Ok(counts) + } + + /// Build a point-in-time snapshot of proposal-cache memory pressure indicators. + pub fn metrics_snapshot(&self) -> Result { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let mut snapshot = ProposalCacheMetricsSnapshot::default(); + + { + let cache = self + .inner + .read() + .map_err(|e| format!("Lock poisoned: {}", e))?; + snapshot.proposal_total = cache.len(); + + for state in cache.values() { + let age = now.saturating_sub(state.created_at); + snapshot.oldest_age_secs = snapshot.oldest_age_secs.max(age); + snapshot.approx_state_bytes = snapshot + .approx_state_bytes + .saturating_add(size_of::() + size_of::()); + + match state.status { + ProposalStatus::Collecting => { + snapshot.collecting = snapshot.collecting.saturating_add(1) + } + ProposalStatus::Ready => snapshot.ready = snapshot.ready.saturating_add(1), + ProposalStatus::Posting => { + snapshot.posting = snapshot.posting.saturating_add(1) + } + ProposalStatus::Confirmed => { + snapshot.confirmed = snapshot.confirmed.saturating_add(1); + snapshot.oldest_confirmed_age_secs = + snapshot.oldest_confirmed_age_secs.max(age); + } + ProposalStatus::Failed => { + snapshot.failed = snapshot.failed.saturating_add(1); + let failed_age = state + .failed_at + .map(|failed_at| now.saturating_sub(failed_at)) + .unwrap_or(age); + snapshot.oldest_failed_age_secs = + snapshot.oldest_failed_age_secs.max(failed_age); + } + } + + if let Some(sig) = &state.my_signature { + snapshot.proposals_with_my_signature = + snapshot.proposals_with_my_signature.saturating_add(1); + snapshot.approx_my_signature_bytes = snapshot + .approx_my_signature_bytes + .saturating_add(size_of::>() + sig.len()); + } + + let peer_count = state.peer_signatures.len(); + snapshot.total_peer_signatures = + snapshot.total_peer_signatures.saturating_add(peer_count); + snapshot.max_peer_signatures_per_proposal = + snapshot.max_peer_signatures_per_proposal.max(peer_count); + + for sig in state.peer_signatures.values() { + snapshot.approx_peer_signature_bytes = + snapshot.approx_peer_signature_bytes.saturating_add( + size_of::
() + size_of::>() + sig.len(), + ); + } + } + } + + { + let pending = self + .pending_signatures + .read() + .map_err(|e| format!("Pending lock poisoned: {}", e))?; + snapshot.pending_signature_deposit_count = pending.len(); + + for pending_sigs in pending.values() { + snapshot.pending_signature_total = snapshot + .pending_signature_total + .saturating_add(pending_sigs.len()); + for pending_sig in pending_sigs { + snapshot.pending_oldest_age_secs = snapshot + .pending_oldest_age_secs + .max(now.saturating_sub(pending_sig.received_at)); + snapshot.approx_pending_signature_bytes = snapshot + .approx_pending_signature_bytes + .saturating_add(size_of::() + pending_sig.signature.len()); + } + } + } + + snapshot.approx_total_bytes = snapshot + .approx_state_bytes + .saturating_add(snapshot.approx_peer_signature_bytes) + .saturating_add(snapshot.approx_my_signature_bytes) + .saturating_add(snapshot.approx_pending_signature_bytes); + + Ok(snapshot) + } + + /// Collect proposals that are still collecting signatures but already have our signature. + /// + /// Used to re-gossip our own signature to late/briefly-offline peers. + pub fn collecting_with_my_sig(&self) -> Result, String> { + let cache = self + .inner + .read() + .map_err(|e| format!("Lock poisoned: {}", e))?; + + Ok(cache + .iter() + .filter(|(_, state)| { + state.status == ProposalStatus::Collecting && state.my_signature.is_some() + }) + .map(|(id, state)| (id.clone(), state.clone())) + .collect()) + } + + /// Get all proposals that are ready to be posted (threshold reached, status=Ready). + /// Returns iterator of (DepositId, ProposalState) pairs. + pub fn ready_proposals(&self) -> Result, String> { + let cache = self + .inner + .read() + .map_err(|e| format!("Lock poisoned: {}", e))?; + let mut ready: Vec<(DepositId, ProposalState)> = cache + .iter() + .filter(|(_, state)| state.status == ProposalStatus::Ready && state.has_threshold()) + .map(|(id, state)| (id.clone(), state.clone())) + .collect(); + + // Ensure deterministic posting order: lowest nonce first. + ready.sort_by_key(|(_, state)| state.proposal.nonce); + + Ok(ready) + } + + /// Get the lowest nonce among proposals that have been Failed for longer than the timeout. + /// + /// These proposals should be skipped - they've been stuck too long and are blocking + /// subsequent nonces. Returns None if no proposals have timed out. + pub fn lowest_timed_out_failed_nonce(&self, timeout_secs: u64) -> Result, String> { + let cache = self + .inner + .read() + .map_err(|e| format!("Lock poisoned: {}", e))?; + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + Ok(cache + .values() + .filter(|state| { + state.status == ProposalStatus::Failed + && state + .failed_at + .map(|t| now.saturating_sub(t) >= timeout_secs) + .unwrap_or(false) + }) + .map(|state| state.proposal.nonce) + .min()) + } + + /// Get the proposal with a specific nonce (for skip handling). + pub fn get_proposal_by_nonce( + &self, + nonce: u64, + ) -> Result, String> { + let cache = self + .inner + .read() + .map_err(|e| format!("Lock poisoned: {}", e))?; + + Ok(cache + .iter() + .find(|(_, state)| state.proposal.nonce == nonce) + .map(|(id, state)| (id.clone(), state.clone()))) + } + + /// Check if a specific node has signed this deposit. + /// + /// Used for health-aware failover: if the proposer hasn't even signed yet, + /// they're definitely not ready to post, so we can accelerate failover. + /// + /// # Arguments + /// * `deposit_id` - The deposit to check + /// * `signer_address` - The Ethereum address to check for + /// + /// # Returns + /// `true` if this address has signed the deposit (either as us or a peer) + pub fn has_signature(&self, deposit_id: &DepositId, signer_address: Address) -> bool { + let Ok(cache) = self.inner.read() else { + return false; + }; + + cache + .get(deposit_id) + .map(|state| { + // Check if it's our signature and the address matches + // Note: We can't directly verify our address without eth_key context, + // so we check peer signatures only for now + state.peer_signatures.contains_key(&signer_address) + }) + .unwrap_or(false) + } +} + +#[cfg(test)] +mod tests { + use nockchain_math::belt::Belt; + use nockchain_types::tx_engine::common::Hash as Tip5Hash; + use nockchain_types::v1::Name; + + use super::*; + use crate::types::{zero_tip5_hash, EthAddress}; + + fn test_proposal() -> NockDepositRequestData { + NockDepositRequestData { + tx_id: Tip5Hash([Belt(1), Belt(2), Belt(3), Belt(4), Belt(5)]), + name: Name::new(zero_tip5_hash(), zero_tip5_hash()), + recipient: EthAddress::ZERO, + amount: 1000, + block_height: 100, + as_of: zero_tip5_hash(), + nonce: 1, + } + } + + fn mock_verify(_hash: &[u8; 32], _sig: &[u8]) -> Option
{ + Some(Address::ZERO) + } + + #[test] + fn test_proposal_state_new() { + let proposal = test_proposal(); + let state = ProposalState::new(proposal.clone()); + assert_eq!(state.status, ProposalStatus::Collecting); + assert!(state.my_signature.is_none()); + assert_eq!(state.peer_signatures.len(), 0); + assert!(!state.has_threshold()); + } + + #[test] + fn test_proposal_state_threshold() { + let proposal = test_proposal(); + let mut state = ProposalState::new(proposal); + + // Add own signature + state.my_signature = Some(vec![1, 2, 3]); + assert!(!state.has_threshold()); + + // Add two peer signatures + state + .peer_signatures + .insert(Address::from([1u8; 20]), vec![4, 5, 6]); + state + .peer_signatures + .insert(Address::from([2u8; 20]), vec![7, 8, 9]); + + // Now should have threshold (3 total) + assert!(state.has_threshold()); + } + + #[test] + fn test_cache_add_signature() { + let cache = ProposalCache::new(); + let proposal = test_proposal(); + let proposal_hash = proposal.compute_proposal_hash(); + let deposit_id = DepositId::from_effect_payload(&proposal); + + // Add first signature (ours) + let result = cache + .add_signature( + &deposit_id, + SignatureData { + signer_address: Address::ZERO, + signature: vec![1, 2, 3], + proposal_hash, + is_mine: true, + }, + Some(proposal.clone()), + mock_verify, + ) + .unwrap(); + assert_eq!(result, SignatureAddResult::Added); + + // Add second signature + let result = cache + .add_signature( + &deposit_id, + SignatureData { + signer_address: Address::from([1u8; 20]), + signature: vec![4, 5, 6], + proposal_hash, + is_mine: false, + }, + None, + |_, _| Some(Address::from([1u8; 20])), + ) + .unwrap(); + assert_eq!(result, SignatureAddResult::Added); + + // Add third signature - should reach threshold + let result = cache + .add_signature( + &deposit_id, + SignatureData { + signer_address: Address::from([2u8; 20]), + signature: vec![7, 8, 9], + proposal_hash, + is_mine: false, + }, + None, + |_, _| Some(Address::from([2u8; 20])), + ) + .unwrap(); + assert_eq!(result, SignatureAddResult::ThresholdReached); + + // Check ready status + assert!(cache.is_ready(&deposit_id).unwrap()); + } + + #[test] + fn test_cache_duplicate_signature() { + let cache = ProposalCache::new(); + let proposal = test_proposal(); + let proposal_hash = proposal.compute_proposal_hash(); + let deposit_id = DepositId::from_effect_payload(&proposal); + + // Add first signature + cache + .add_signature( + &deposit_id, + SignatureData { + signer_address: Address::ZERO, + signature: vec![1, 2, 3], + proposal_hash, + is_mine: true, + }, + Some(proposal), + mock_verify, + ) + .unwrap(); + + // Try to add same signature again + let result = cache + .add_signature( + &deposit_id, + SignatureData { + signer_address: Address::ZERO, + signature: vec![1, 2, 3], + proposal_hash, + is_mine: true, + }, + None, + mock_verify, + ) + .unwrap(); + assert_eq!(result, SignatureAddResult::Duplicate); + } + + #[test] + fn test_cache_get_signatures_for_posting() { + let cache = ProposalCache::new(); + let proposal = test_proposal(); + let proposal_hash = proposal.compute_proposal_hash(); + let deposit_id = DepositId::from_effect_payload(&proposal); + + // Add enough signatures to reach threshold + cache + .add_signature( + &deposit_id, + SignatureData { + signer_address: Address::ZERO, + signature: vec![1, 2, 3], + proposal_hash, + is_mine: true, + }, + Some(proposal), + mock_verify, + ) + .unwrap(); + cache + .add_signature( + &deposit_id, + SignatureData { + signer_address: Address::from([1u8; 20]), + signature: vec![4, 5, 6], + proposal_hash, + is_mine: false, + }, + None, + |_, _| Some(Address::from([1u8; 20])), + ) + .unwrap(); + cache + .add_signature( + &deposit_id, + SignatureData { + signer_address: Address::from([2u8; 20]), + signature: vec![7, 8, 9], + proposal_hash, + is_mine: false, + }, + None, + |_, _| Some(Address::from([2u8; 20])), + ) + .unwrap(); + + // Get signatures + let sigs = cache.get_signatures_for_posting(&deposit_id).unwrap(); + assert!(sigs.is_some()); + assert_eq!(sigs.unwrap().len(), 3); + } + + #[test] + fn test_cache_status_transitions() { + let cache = ProposalCache::new(); + let proposal = test_proposal(); + let proposal_hash = proposal.compute_proposal_hash(); + let deposit_id = DepositId::from_effect_payload(&proposal); + + // Add proposal + cache + .add_signature( + &deposit_id, + SignatureData { + signer_address: Address::ZERO, + signature: vec![1, 2, 3], + proposal_hash, + is_mine: true, + }, + Some(proposal), + mock_verify, + ) + .unwrap(); + + // Mark posting + cache.mark_posting(&deposit_id).unwrap(); + let state = cache.get_state(&deposit_id).unwrap().unwrap(); + assert_eq!(state.status, ProposalStatus::Posting); + + // Mark confirmed + cache.mark_confirmed(&deposit_id).unwrap(); + let state = cache.get_state(&deposit_id).unwrap().unwrap(); + assert_eq!(state.status, ProposalStatus::Confirmed); + } + + #[test] + fn test_cache_gc() { + let cache = ProposalCache::new(); + let proposal = test_proposal(); + let proposal_hash = proposal.compute_proposal_hash(); + let deposit_id = DepositId::from_effect_payload(&proposal); + + // Add and confirm a proposal + cache + .add_signature( + &deposit_id, + SignatureData { + signer_address: Address::ZERO, + signature: vec![1, 2, 3], + proposal_hash, + is_mine: true, + }, + Some(proposal), + mock_verify, + ) + .unwrap(); + cache.mark_confirmed(&deposit_id).unwrap(); + + // GC with 0 max age should remove it + let removed = cache.gc(0).unwrap(); + assert_eq!(removed, 1); + assert!(cache.get_state(&deposit_id).unwrap().is_none()); + } + + #[test] + fn test_cache_status_counts() { + let cache = ProposalCache::new(); + + // Add multiple proposals in different states + // Each needs a unique nonce to avoid eviction (same nonce + different hash triggers eviction) + for i in 0..3 { + let mut proposal = test_proposal(); + proposal.as_of = Tip5Hash([Belt(i as u64); 5]); + proposal.nonce = i as u64 + 1; + let proposal_hash = proposal.compute_proposal_hash(); + let deposit_id = DepositId::from_effect_payload(&proposal); + cache + .add_signature( + &deposit_id, + SignatureData { + signer_address: Address::ZERO, + signature: vec![1, 2, 3], + proposal_hash, + is_mine: true, + }, + Some(proposal), + mock_verify, + ) + .unwrap(); + } + + let counts = cache.status_counts().unwrap(); + assert_eq!(*counts.get(&ProposalStatus::Collecting).unwrap(), 3); + } + + #[test] + fn test_has_signature() { + let cache = ProposalCache::new(); + let proposal = test_proposal(); + let proposal_hash = proposal.compute_proposal_hash(); + let deposit_id = DepositId::from_effect_payload(&proposal); + + let addr1 = Address::from([1u8; 20]); + let addr2 = Address::from([2u8; 20]); + + // No signatures yet + assert!(!cache.has_signature(&deposit_id, addr1)); + assert!(!cache.has_signature(&deposit_id, addr2)); + + // Add signature from addr1 + cache + .add_signature( + &deposit_id, + SignatureData { + signer_address: addr1, + signature: vec![1, 2, 3], + proposal_hash, + is_mine: false, + }, + Some(proposal), + |_, _| Some(addr1), + ) + .unwrap(); + + // Now addr1 should be present + assert!(cache.has_signature(&deposit_id, addr1)); + assert!(!cache.has_signature(&deposit_id, addr2)); + + // Add signature from addr2 + cache + .add_signature( + &deposit_id, + SignatureData { + signer_address: addr2, + signature: vec![4, 5, 6], + proposal_hash, + is_mine: false, + }, + None, + |_, _| Some(addr2), + ) + .unwrap(); + + // Both should be present + assert!(cache.has_signature(&deposit_id, addr1)); + assert!(cache.has_signature(&deposit_id, addr2)); + } + + #[test] + fn test_is_known() { + let cache = ProposalCache::new(); + let proposal = test_proposal(); + let proposal_hash = proposal.compute_proposal_hash(); + let deposit_id = DepositId::from_effect_payload(&proposal); + + // Not known initially + assert!(!cache.is_known(&deposit_id)); + + // Add signature + cache + .add_signature( + &deposit_id, + SignatureData { + signer_address: Address::ZERO, + signature: vec![1, 2, 3], + proposal_hash, + is_mine: true, + }, + Some(proposal), + mock_verify, + ) + .unwrap(); + + // Now known + assert!(cache.is_known(&deposit_id)); + } + + #[test] + fn test_is_confirmed() { + let cache = ProposalCache::new(); + let proposal = test_proposal(); + let proposal_hash = proposal.compute_proposal_hash(); + let deposit_id = DepositId::from_effect_payload(&proposal); + + // Not confirmed initially + assert!(!cache.is_confirmed(&deposit_id)); + + // Add and confirm + cache + .add_signature( + &deposit_id, + SignatureData { + signer_address: Address::ZERO, + signature: vec![1, 2, 3], + proposal_hash, + is_mine: true, + }, + Some(proposal), + mock_verify, + ) + .unwrap(); + cache.mark_confirmed(&deposit_id).unwrap(); + + // Now confirmed + assert!(cache.is_confirmed(&deposit_id)); + } + + #[test] + fn test_reject_signature_when_confirmed() { + let cache = ProposalCache::new(); + let proposal = test_proposal(); + let proposal_hash = proposal.compute_proposal_hash(); + let deposit_id = DepositId::from_effect_payload(&proposal); + + // Add first signature + cache + .add_signature( + &deposit_id, + SignatureData { + signer_address: Address::ZERO, + signature: vec![1, 2, 3], + proposal_hash, + is_mine: true, + }, + Some(proposal), + mock_verify, + ) + .unwrap(); + + // Mark as confirmed + cache.mark_confirmed(&deposit_id).unwrap(); + + // Try to add another signature - should be rejected + let result = cache + .add_signature( + &deposit_id, + SignatureData { + signer_address: Address::from([1u8; 20]), + signature: vec![4, 5, 6], + proposal_hash, + is_mine: false, + }, + None, + |_, _| Some(Address::from([1u8; 20])), + ) + .unwrap(); + + assert_eq!(result, SignatureAddResult::Stale); + } +} diff --git a/crates/bridge/src/proposer.rs b/crates/bridge/src/proposer.rs new file mode 100644 index 000000000..e7146581c --- /dev/null +++ b/crates/bridge/src/proposer.rs @@ -0,0 +1,74 @@ +use nockchain_types::tx_engine::common::Hash as NockPkh; + +/// Replicate Hoon's active-proposer logic: sort nodes by nock-pkh (b58), rotate by height. +/// +/// This matches the Hoon logic in `++active-proposer` from types.hoon:593-611. +/// +/// # Arguments +/// * `height` - Current block height +/// * `node_pkhs` - List of nock public key hashes (in config order, not sorted) +/// +/// # Returns +/// Index of the proposer node (index into the SORTED node list) +pub fn hoon_proposer(height: u64, node_pkhs: &[NockPkh]) -> usize { + // Sort nodes by base58-encoded PKH (lexicographic string comparison) + let mut sorted_indices: Vec = (0..node_pkhs.len()).collect(); + sorted_indices.sort_by_key(|&i| node_pkhs[i].to_base58()); + + // Rotate by height mod num_nodes + let rotation_offset = (height as usize) % node_pkhs.len(); + sorted_indices[rotation_offset] +} + +#[cfg(test)] +mod tests { + + use super::*; + + fn sample_pkhs() -> Vec { + // Create 5 distinct PKHs (Tip5 hashes) with different b58 encodings + // Fake test PKHs (valid format placeholders, NOT real operator data) + vec![ + NockPkh::from_base58("2222222222222222222222222222222222222222222222222222").unwrap(), + NockPkh::from_base58("3333333333333333333333333333333333333333333333333333").unwrap(), + NockPkh::from_base58("4444444444444444444444444444444444444444444444444444").unwrap(), + NockPkh::from_base58("5555555555555555555555555555555555555555555555555555").unwrap(), + NockPkh::from_base58("6666666666666666666666666666666666666666666666666666").unwrap(), + ] + } + + #[test] + fn test_hoon_proposer_rotation() { + let pkhs = sample_pkhs(); + + // At height 0, should be first in sorted order + let proposer_0 = hoon_proposer(0, &pkhs); + + // At height 1, should be second in sorted order + let proposer_1 = hoon_proposer(1, &pkhs); + + // Should rotate through all nodes + assert_ne!(proposer_0, proposer_1); + + // At height 5 (one full rotation), should be same as height 0 + let proposer_5 = hoon_proposer(5, &pkhs); + assert_eq!(proposer_0, proposer_5); + } + + #[test] + fn test_hoon_proposer_sorts_by_b58() { + let pkhs = sample_pkhs(); + + // Get sorted indices + let mut sorted_indices: Vec = (0..pkhs.len()).collect(); + sorted_indices.sort_by_key(|&i| pkhs[i].to_base58()); + + // Proposer at height 0 should be first sorted index + let proposer = hoon_proposer(0, &pkhs); + assert_eq!(proposer, sorted_indices[0]); + + // Proposer at height 1 should be second sorted index + let proposer = hoon_proposer(1, &pkhs); + assert_eq!(proposer, sorted_indices[1]); + } +} diff --git a/crates/bridge/src/runtime.rs b/crates/bridge/src/runtime.rs new file mode 100644 index 000000000..d6ec190c0 --- /dev/null +++ b/crates/bridge/src/runtime.rs @@ -0,0 +1,2439 @@ +use std::collections::{HashSet, VecDeque}; +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use alloy::primitives::Address; +use hex::encode; +use nockapp::driver::{make_driver, NockAppHandle}; +use nockapp::nockapp::wire::WireRepr; +use nockapp::noun::slab::{NockJammer, NounSlab}; +use nockapp::one_punch::OnePunchWire; +use nockapp::wire::Wire; +use nockapp::Bytes; +use noun_serde::{NounDecode, NounEncode}; +use tokio::sync::mpsc::{Receiver, Sender}; +use tokio::sync::{mpsc, oneshot}; +use tracing::{debug, error, info, warn}; + +use crate::bridge_status::BridgeStatus; +use crate::config::NonceEpochConfig; +use crate::errors::BridgeError; +use crate::ethereum::BaseBridge; +use crate::health::PeerEndpoint; +use crate::metrics; +use crate::proposal_cache::ProposalCache; +use crate::signing::BridgeSigner; +use crate::types::{ + keccak256, BaseBlockRef, BaseDepositSettlementEntry, BaseEvent, BaseWithdrawalEntry, BoolPeek, + BridgeCause, BridgeCauseVariant, BridgeState, CountPeek, HeightPeek, HoldInfo, HoldPeek, + NockDepositRequestKernelData, NockDepositRequestsPeek, NockchainTxsMap, NodeConfig, + RawBaseBlockEntry, RawBaseBlocks, StopInfoPeek, StopLastBlocks, Tip5Hash, Tx, +}; + +const MAX_PENDING_EVENTS: usize = 1024; +const SUBMIT_DEPOSIT_TIMEOUT_SECS: u64 = 60; // prevent hung RPC from stalling queue + +fn format_ud_for_cord(value: u64) -> String { + let raw = value.to_string(); + let mut out = String::with_capacity(raw.len() + raw.len() / 3); + for (idx, ch) in raw.chars().rev().enumerate() { + if idx > 0 && idx.is_multiple_of(3) { + out.push('.'); + } + out.push(ch); + } + out.chars().rev().collect() +} + +fn format_source_tx_id(tx_id: &Tip5Hash) -> String { + tx_id.to_base58() +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EventId { + pub kind: BridgeEventKind, + pub timestamp_ms: u128, + pub digest: [u8; 32], +} + +impl EventId { + pub fn digest_excerpt(&self) -> String { + encode(&self.digest[..4]) + } +} + +pub struct EventEnvelope { + pub id: EventId, + pub payload: T, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BridgeEventKind { + ChainBase, + ChainNock, +} + +impl BridgeEventKind { + fn as_str(&self) -> &'static str { + match self { + BridgeEventKind::ChainBase => "chain-base", + BridgeEventKind::ChainNock => "chain-nock", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum BridgeEffectKind { + BaseContractCall, + NockchainTx, +} + +impl BridgeEffectKind { + fn as_str(&self) -> &'static str { + match self { + BridgeEffectKind::BaseContractCall => "base-contract-call", + BridgeEffectKind::NockchainTx => "nockchain-tx", + } + } +} + +#[derive(Clone, Debug)] +pub enum BridgeEvent { + Chain(Box), +} + +impl BridgeEvent { + fn kind(&self) -> BridgeEventKind { + match self { + BridgeEvent::Chain(ref chain) => match chain.as_ref() { + ChainEvent::Base(_) => BridgeEventKind::ChainBase, + ChainEvent::Nock(_) => BridgeEventKind::ChainNock, + }, + } + } + + fn identity_material(&self) -> Vec { + match self { + BridgeEvent::Chain(ref chain) => match chain.as_ref() { + ChainEvent::Base(batch) => batch.identity_material(), + ChainEvent::Nock(block) => block.identity_material(), + }, + } + } +} + +#[derive(Clone, Debug)] +pub enum BridgeEffect { + BaseContractCall(BaseContractCallEffect), + NockchainTx(NockchainTxEffect), +} + +impl BridgeEffect { + fn kind(&self) -> BridgeEffectKind { + match self { + BridgeEffect::BaseContractCall(_) => BridgeEffectKind::BaseContractCall, + BridgeEffect::NockchainTx(_) => BridgeEffectKind::NockchainTx, + } + } + + fn identity_material(&self) -> Vec { + match self { + BridgeEffect::BaseContractCall(effect) => effect.identity_material(), + BridgeEffect::NockchainTx(effect) => effect.identity_material(), + } + } +} + +#[derive(Clone, Debug)] +pub enum ChainEvent { + Base(BaseBlockBatch), + Nock(NockBlockEvent), +} + +#[derive(Clone, Debug)] +pub struct BaseBlockBatch { + pub version: u64, + pub first_height: u64, + pub last_height: u64, + pub blocks: Vec, + pub withdrawals: Vec, + pub deposit_settlements: Vec, + /// Events per block height for conversion to RawBaseBlocks + pub block_events: std::collections::HashMap>, + pub prev: Tip5Hash, +} + +impl BaseBlockBatch { + pub(crate) fn identity_material(&self) -> Vec { + let mut material = Vec::new(); + material.extend_from_slice(&self.version.to_be_bytes()); + material.extend_from_slice(&self.first_height.to_be_bytes()); + material.extend_from_slice(&self.last_height.to_be_bytes()); + material.extend_from_slice(&self.prev.to_be_bytes()); + for block in &self.blocks { + material.extend_from_slice(&block.height.to_be_bytes()); + material.extend_from_slice(&block.block_id.0); + } + for entry in &self.withdrawals { + material.extend_from_slice(&entry.base_tx_id.0); + material.extend_from_slice(&entry.withdrawal.raw_amount.to_be_bytes()); + if let Some(dest) = &entry.withdrawal.dest { + material.extend_from_slice(&dest.to_be_bytes()); + } + } + for entry in &self.deposit_settlements { + material.extend_from_slice(&entry.base_tx_id.0); + material.extend_from_slice(&entry.settlement.data.counterpart.to_be_bytes()); + material.extend_from_slice(&entry.settlement.data.as_of.to_be_bytes()); + material.extend_from_slice(&entry.settlement.data.dest.0); + material.extend_from_slice(&entry.settlement.data.settled_amount.to_be_bytes()); + material.extend_from_slice(&entry.settlement.data.bridge_fee.to_be_bytes()); + for fee in &entry.settlement.data.fees { + material.extend_from_slice(&fee.address.0); + material.extend_from_slice(&fee.amount.to_be_bytes()); + } + } + material + } +} + +impl From for RawBaseBlocks { + fn from(batch: BaseBlockBatch) -> Self { + batch + .blocks + .into_iter() + .map(|block_ref| RawBaseBlockEntry { + height: block_ref.height, + block_id: block_ref.block_id, + parent_block_id: block_ref.parent_block_id, + txs: batch + .block_events + .get(&block_ref.height) + .cloned() + .unwrap_or_default(), + }) + .collect() + } +} + +#[derive(Clone)] +pub struct NockBlockEvent { + pub block: nockchain_types::tx_engine::common::Page, + pub page_slab: nockapp::noun::slab::NounSlab, + pub page_noun: nockapp::Noun, + pub txs: Vec<(nockchain_types::tx_engine::common::TxId, Tx)>, +} + +impl std::fmt::Debug for NockBlockEvent { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("NockBlockEvent") + .field("block", &self.block) + .field("txs", &self.txs) + .finish() + } +} + +impl NockBlockEvent { + fn identity_material(&self) -> Vec { + let mut material = Vec::new(); + for limb in self.block.digest.0.iter() { + material.extend_from_slice(&limb.0.to_be_bytes()); + } + for limb in self.block.parent.0.iter() { + material.extend_from_slice(&limb.0.to_be_bytes()); + } + material.extend_from_slice(&self.block.height.to_be_bytes()); + for (tx_id, _raw_tx) in &self.txs { + for limb in tx_id.0.iter() { + material.extend_from_slice(&limb.0.to_be_bytes()); + } + } + material + } + + pub fn height(&self) -> u64 { + self.block.height + } + + pub fn block_hash(&self) -> [u8; 32] { + let mut raw = [0u8; 40]; + for (idx, limb) in self.block.digest.0.iter().enumerate() { + raw[idx * 8..(idx + 1) * 8].copy_from_slice(&limb.0.to_be_bytes()); + } + let mut out = [0u8; 32]; + out.copy_from_slice(&raw[8..]); + out + } + + pub fn parent_hash(&self) -> [u8; 32] { + let mut raw = [0u8; 40]; + for (idx, limb) in self.block.parent.0.iter().enumerate() { + raw[idx * 8..(idx + 1) * 8].copy_from_slice(&limb.0.to_be_bytes()); + } + let mut out = [0u8; 32]; + out.copy_from_slice(&raw[8..]); + out + } +} + +#[derive(Clone, Debug)] +pub struct BaseContractCallEffect { + pub submission: Vec, +} + +impl BaseContractCallEffect { + fn identity_material(&self) -> Vec { + self.submission.clone() + } +} + +#[derive(Clone, Debug)] +pub struct NockchainTxEffect { + pub transaction: Vec, +} + +impl NockchainTxEffect { + fn identity_material(&self) -> Vec { + self.transaction.clone() + } +} + +#[derive(Clone)] +pub struct BridgeRuntimeHandle { + inbound_tx: Sender>, + effect_tx: Sender>, + peek_tx: Sender, + poke_tx: Sender, + base_tip_hash: Arc>>, +} + +impl BridgeRuntimeHandle { + pub fn set_base_tip_hash(&self, tip_hash: String) { + if tip_hash.is_empty() { + return; + } + if let Ok(mut guard) = self.base_tip_hash.write() { + *guard = Some(tip_hash); + } + } + + pub fn get_base_tip_hash(&self) -> Option { + self.base_tip_hash + .read() + .ok() + .and_then(|guard| guard.clone()) + } + + pub async fn send_event(&self, event: BridgeEvent) -> Result { + let id = make_event_id(event.kind(), &event.identity_material()); + let envelope = EventEnvelope { id, payload: event }; + self.inbound_tx + .send(envelope) + .await + .map_err(|e| BridgeError::Runtime(format!("inbound channel closed: {}", e)))?; + Ok(id) + } + + pub async fn send_effect(&self, effect: BridgeEffect) -> Result { + let id = make_effect_id(effect.kind(), &effect.identity_material()); + let envelope = EventEnvelope { + id, + payload: effect, + }; + self.effect_tx + .send(envelope) + .await + .map_err(|e| BridgeError::Runtime(format!("effect channel closed: {}", e)))?; + Ok(id) + } + + pub async fn peek_base_next_height(&self) -> Result, BridgeError> { + let path = vec!["base-hashchain-next-height".to_string()]; + self.peek_height_path(path).await + } + + pub async fn peek_nock_next_height(&self) -> Result, BridgeError> { + let path = vec!["nock-hashchain-next-height".to_string()]; + self.peek_height_path(path).await + } + + /// Peek the current nock hashchain tip height derived from next height. + pub async fn nock_hashchain_tip(&self) -> Result, BridgeError> { + Ok(self + .peek_nock_next_height() + .await? + .map(|height| height.saturating_sub(1))) + } + + pub async fn peek_nock_last_deposit_height(&self) -> Result, BridgeError> { + let path = vec!["nock-last-deposit-height".to_string()]; + self.peek_height_path(path).await + } + + /// Peek the count of unsettled deposits (awaiting settlement on Base). + pub async fn peek_unsettled_deposit_count(&self) -> Result { + let path = vec!["unsettled-deposit-count".to_string()]; + self.peek_count_path(path).await + } + + /// Peek all unsettled deposits as a list of nonce-free nock deposit requests. + pub async fn peek_unsettled_deposits( + &self, + ) -> Result, BridgeError> { + let path = vec!["unsettled-deposits".to_string()]; + let peek = self + .peek_typed_path::(path) + .await?; + Ok(peek.and_then(|p| p.inner.flatten()).unwrap_or_default()) + } + + /// Peek all deposits in the nock hashchain as a list of nonce-free nock deposit requests. + /// + /// This is intended for deterministic backfill of the runtime deposit log during + /// nonce epoch activation. + pub async fn peek_nock_hashchain_deposits( + &self, + ) -> Result, BridgeError> { + let path = vec!["nock-hashchain-deposits".to_string()]; + let peek = self + .peek_typed_path::(path) + .await?; + Ok(peek.and_then(|p| p.inner.flatten()).unwrap_or_default()) + } + + /// Peek deposits in the nock hashchain with `block_height >= start_height`. + /// + /// This is intended for incremental backfill of the runtime deposit log. + pub async fn peek_nock_hashchain_deposits_since_height( + &self, + start_height: u64, + ) -> Result, BridgeError> { + let path = vec![ + "nock-hashchain-deposits-since-height".to_string(), + format_ud_for_cord(start_height), + ]; + let peek = self + .peek_typed_path::(path) + .await?; + let records = peek.and_then(|p| p.inner.flatten()).unwrap_or_default(); + let tx_ids: Vec = records + .iter() + .map(|req| { + let hex = encode(req.tx_id.to_be_limb_bytes()); + format!("{} ({})", req.tx_id.to_base58(), hex) + }) + .collect(); + info!( + target: "bridge.peek", + start_height, + count = records.len(), + tx_ids = ?tx_ids, + "peeked nock hashchain deposits since height" + ); + Ok(records) + } + + /// Peek the count of unsettled withdrawals (awaiting settlement on Nockchain). + pub async fn peek_unsettled_withdrawal_count(&self) -> Result { + let path = vec!["unsettled-withdrawal-count".to_string()]; + self.peek_count_path(path).await + } + + /// Peek whether base chain processing is held waiting for nock. + pub async fn peek_base_hold(&self) -> Result { + Ok(self.peek_base_hold_info().await?.is_some()) + } + + /// Peek the base hold info (hash + height), if present. + pub async fn peek_base_hold_info(&self) -> Result, BridgeError> { + let path = vec!["base-hold".to_string()]; + self.peek_hold_path(path).await + } + + /// Peek the nock height that releases a base hold. + pub async fn peek_base_hold_height(&self) -> Result, BridgeError> { + Ok(self.peek_base_hold_info().await?.map(|hold| hold.height)) + } + + /// Peek whether nock chain processing is held waiting for base. + pub async fn peek_nock_hold(&self) -> Result { + Ok(self.peek_nock_hold_info().await?.is_some()) + } + + /// Peek the nock hold info (hash + height), if present. + pub async fn peek_nock_hold_info(&self) -> Result, BridgeError> { + let path = vec!["nock-hold".to_string()]; + self.peek_hold_path(path).await + } + + /// Peek whether the kernel has latched a stop state. + pub async fn peek_stop_state(&self) -> Result { + let path = vec!["stop-state".to_string()]; + self.peek_bool_path(path).await + } + + /// Peek the base height that releases a nock hold. + pub async fn peek_nock_hold_height(&self) -> Result, BridgeError> { + Ok(self.peek_nock_hold_info().await?.map(|hold| hold.height)) + } + + /// Peek whether the bridge is running in fakenet mode. + /// + /// The Hoon kernel returns `true` if constants are NOT equal to the default + /// mainnet constants, meaning the bridge is in fakenet mode (constants were + /// overridden). Returns `false` for mainnet mode (using default constants). + pub async fn peek_is_fakenet(&self) -> Result { + let path = vec!["fakenet".to_string()]; + self.peek_bool_path(path).await + } + + /// Peek the kernel's computed `stop-info` (last known good tips + heights). + pub async fn peek_stop_info(&self) -> Result, BridgeError> { + let mut slab: NounSlab = NounSlab::new(); + let path = vec!["stop-info".to_string()]; + let path_noun = path.to_noun(&mut slab); + slab.set_root(path_noun); + + let bytes_opt = self.peek_slab(slab).await?; + let Some(bytes) = bytes_opt else { + return Ok(None); + }; + + let slab = cue_bytes(bytes)?; + let noun = unsafe { slab.root() }; + let peek = StopInfoPeek::from_noun(noun).map_err(|err| { + BridgeError::Runtime(format!("failed to decode peek stop-info: {}", err)) + })?; + Ok(peek.inner.flatten()) + } + + /// Fetch all kernel state counts in a single batch for TUI display. + /// Returns defaults (0/false) for any failed peeks rather than failing entirely. + pub async fn update_bridge_state(&self) -> BridgeState { + let metrics = metrics::init_metrics(); + let total_started = Instant::now(); + + let base_hold_info = { + let started = Instant::now(); + let info = self.peek_base_hold_info().await.ok().flatten(); + metrics + .bridge_state_peek_base_hold_info_time + .add_timing(&started.elapsed()); + info + }; + let nock_hold_info = { + let started = Instant::now(); + let info = self.peek_nock_hold_info().await.ok().flatten(); + metrics + .bridge_state_peek_nock_hold_info_time + .add_timing(&started.elapsed()); + info + }; + let unsettled_deposits = { + let started = Instant::now(); + let count = self.peek_unsettled_deposit_count().await.unwrap_or(0); + metrics + .bridge_state_peek_unsettled_deposits_time + .add_timing(&started.elapsed()); + count + }; + let unsettled_withdrawals = { + let started = Instant::now(); + let count = self.peek_unsettled_withdrawal_count().await.unwrap_or(0); + metrics + .bridge_state_peek_unsettled_withdrawals_time + .add_timing(&started.elapsed()); + count + }; + let base_next_height = { + let started = Instant::now(); + let height = self.peek_base_next_height().await.ok().flatten(); + metrics + .bridge_state_peek_base_next_height_time + .add_timing(&started.elapsed()); + height + }; + let nock_next_height = { + let started = Instant::now(); + let height = self.peek_nock_next_height().await.ok().flatten(); + metrics + .bridge_state_peek_nock_next_height_time + .add_timing(&started.elapsed()); + height + }; + let kernel_stopped = { + let started = Instant::now(); + let stopped = self.peek_stop_state().await.unwrap_or(false); + metrics + .bridge_state_peek_stop_state_time + .add_timing(&started.elapsed()); + stopped + }; + let is_fakenet = { + let started = Instant::now(); + let value = self.peek_is_fakenet().await.ok(); + metrics + .bridge_state_peek_is_fakenet_time + .add_timing(&started.elapsed()); + value + }; + + let state = BridgeState { + unsettled_deposits, + unsettled_withdrawals, + base_tip_hash: self.get_base_tip_hash(), + base_next_height, + nock_next_height, + base_hold: base_hold_info.is_some(), + nock_hold: nock_hold_info.is_some(), + kernel_stopped, + is_fakenet, + base_hold_height: base_hold_info.as_ref().map(|hold| hold.height), + nock_hold_height: nock_hold_info.as_ref().map(|hold| hold.height), + }; + + metrics + .bridge_state_snapshot_time + .add_timing(&total_started.elapsed()); + state + } + + async fn peek_count_path(&self, path: Vec) -> Result { + let mut slab: NounSlab = NounSlab::new(); + let path_noun = path.to_noun(&mut slab); + slab.set_root(path_noun); + + let bytes_opt = self.peek_slab(slab).await?; + let Some(bytes) = bytes_opt else { + return Ok(0); // absent = 0 count + }; + let slab = cue_bytes(bytes)?; + let noun = unsafe { slab.root() }; + let peek = CountPeek::from_noun(noun) + .map_err(|err| BridgeError::Runtime(format!("failed to decode peek count: {}", err)))?; + Ok(peek.inner.flatten().unwrap_or(0)) + } + + async fn peek_bool_path(&self, path: Vec) -> Result { + let mut slab: NounSlab = NounSlab::new(); + let path_noun = path.to_noun(&mut slab); + slab.set_root(path_noun); + + let bytes_opt = self.peek_slab(slab).await?; + let Some(bytes) = bytes_opt else { + return Ok(false); // absent = false + }; + let slab = cue_bytes(bytes)?; + let noun = unsafe { slab.root() }; + let peek = BoolPeek::from_noun(noun) + .map_err(|err| BridgeError::Runtime(format!("failed to decode peek bool: {}", err)))?; + Ok(peek.inner.flatten().unwrap_or(false)) + } + + async fn peek_height_path(&self, path: Vec) -> Result, BridgeError> { + let mut slab: NounSlab = NounSlab::new(); + let path_noun = path.to_noun(&mut slab); + slab.set_root(path_noun); + + let bytes_opt = self.peek_slab(slab).await?; + let Some(bytes) = bytes_opt else { + return Ok(None); + }; + let slab = cue_bytes(bytes)?; + let noun = unsafe { slab.root() }; + let peek = HeightPeek::from_noun(noun).map_err(|err| { + BridgeError::Runtime(format!("failed to decode peek height: {}", err)) + })?; + match peek.inner { + Some(Some(height)) => Ok(Some(height)), + _ => Ok(None), + } + } + + async fn peek_hold_path(&self, path: Vec) -> Result, BridgeError> { + let mut slab: NounSlab = NounSlab::new(); + let path_noun = path.to_noun(&mut slab); + slab.set_root(path_noun); + + let bytes_opt = self.peek_slab(slab).await?; + let Some(bytes) = bytes_opt else { + return Ok(None); + }; + let slab = cue_bytes(bytes)?; + let noun = unsafe { slab.root() }; + let peek = HoldPeek::from_noun(noun) + .map_err(|err| BridgeError::Runtime(format!("failed to decode peek hold: {}", err)))?; + Ok(peek.inner.flatten()) + } + + async fn peek_typed_path( + &self, + path: Vec, + ) -> Result, BridgeError> { + let mut slab: NounSlab = NounSlab::new(); + let path_noun = path.to_noun(&mut slab); + slab.set_root(path_noun); + + let bytes_opt = self.peek_slab(slab).await?; + let Some(bytes) = bytes_opt else { + return Ok(None); + }; + + let slab = cue_bytes(bytes)?; + let noun = unsafe { slab.root() }; + let decoded = T::from_noun(noun).map_err(|err| { + BridgeError::Runtime(format!("failed to decode typed peek response: {}", err)) + })?; + Ok(Some(decoded)) + } + + async fn peek_slab( + &self, + path_slab: NounSlab, + ) -> Result>, BridgeError> { + let (respond_to, response) = oneshot::channel(); + self.peek_tx + .send(PeekRequest { + path_slab, + respond_to, + }) + .await + .map_err(|e| BridgeError::Runtime(format!("peek channel closed: {}", e)))?; + response + .await + .map_err(|e| BridgeError::Runtime(format!("peek response dropped: {}", e)))? + } + + /// Send a poke directly to the kernel. + /// This is used by the ingress service to poke the kernel with proposed-base-call + /// when validating incoming proposals from peers. + pub async fn send_poke(&self, poke: BridgePoke) -> Result<(), BridgeError> { + self.poke_tx + .send(poke) + .await + .map_err(|e| BridgeError::Runtime(format!("poke channel closed: {}", e))) + } + + pub async fn send_stop(&self, last: StopLastBlocks) -> Result<(), BridgeError> { + let cause = BridgeCause::stop(last); + let mut slab: NounSlab = NounSlab::new(); + let noun = cause.to_noun(&mut slab); + slab.set_root(noun); + let wire = OnePunchWire::Poke.to_wire(); + self.send_poke(BridgePoke { wire, slab }).await + } + + pub async fn send_start(&self) -> Result<(), BridgeError> { + let cause = BridgeCause::start(); + let mut slab: NounSlab = NounSlab::new(); + let noun = cause.to_noun(&mut slab); + slab.set_root(noun); + let wire = OnePunchWire::Poke.to_wire(); + self.send_poke(BridgePoke { wire, slab }).await + } +} + +pub trait CauseBuilder: Send + Sync { + fn build_poke( + &self, + event: &EventEnvelope, + ) -> Result; +} + +pub enum CauseBuildOutcome { + Emit(BridgePoke), + Deferred(String), + Ignored(String), +} + +#[derive(Default)] +pub struct KernelCauseBuilder; + +impl CauseBuilder for KernelCauseBuilder { + fn build_poke( + &self, + event: &EventEnvelope, + ) -> Result { + let BridgeEvent::Chain(ref chain) = &event.payload; + match chain.as_ref() { + ChainEvent::Base(batch) => { + debug!( + target: "bridge.runtime.cause", + first_height=%batch.first_height, + last_height=%batch.last_height, + blocks_count=%batch.blocks.len(), + withdrawals_count=%batch.withdrawals.len(), + "building base-blocks cause from batch" + ); + let raw_base_blocks: RawBaseBlocks = batch.clone().into(); + debug!( + target: "bridge.runtime.cause", + entries_count=%raw_base_blocks.len(), + "RawBaseBlocks after conversion" + ); + let cause = BridgeCause(0, BridgeCauseVariant::BaseBlocks(raw_base_blocks)); + let mut slab: NounSlab = NounSlab::new(); + let noun = cause.to_noun(&mut slab); + debug!( + target: "bridge.runtime.cause", + noun_is_cell=%noun.is_cell(), + "encoded BridgeCause to noun" + ); + slab.set_root(noun); + let wire = OnePunchWire::Poke.to_wire(); + Ok(CauseBuildOutcome::Emit(BridgePoke { wire, slab })) + } + ChainEvent::Nock(nock_block) => { + debug!( + target: "bridge.runtime.cause", + height=%nock_block.height(), + digest_b58=%nock_block.block.digest.to_base58(), + parent_b58=%nock_block.block.parent.to_base58(), + txs_count=%nock_block.txs.len(), + "building nockchain-block cause from block" + ); + let mut poke_slab = NounSlab::new(); + let page_noun = poke_slab.copy_into(nock_block.page_noun); + let tag = String::from("nockchain-block").to_noun(&mut poke_slab); + let txs = NockchainTxsMap(nock_block.txs.clone()).to_noun(&mut poke_slab); + let cause = + nockvm::noun::T(&mut poke_slab, &[nockvm::noun::D(0), tag, page_noun, txs]); + debug!( + target: "bridge.runtime.cause", + noun_is_cell=%cause.is_cell(), + "encoded NockchainBlock BridgeCause to noun" + ); + poke_slab.set_root(cause); + let wire = OnePunchWire::Poke.to_wire(); + Ok(CauseBuildOutcome::Emit(BridgePoke { + wire, + slab: poke_slab, + })) + } + } + } +} + +#[derive(Clone)] +pub struct BridgePoke { + pub wire: WireRepr, + pub slab: NounSlab, +} + +struct PeekRequest { + /// Pre-built noun slab containing the path to peek + path_slab: NounSlab, + respond_to: oneshot::Sender>, BridgeError>>, +} + +pub struct BridgeRuntime { + cause_builder: Arc, + inbound_rx: Receiver>, + effect_rx: Receiver>, + poke_tx: Sender, + poke_rx: Option>, + peek_rx: Option>, + pending_events: VecDeque>, +} + +impl BridgeRuntime { + pub fn new(cause_builder: Arc) -> (Self, BridgeRuntimeHandle) { + let (inbound_tx, inbound_rx) = mpsc::channel(256); + let (effect_tx, effect_rx) = mpsc::channel(256); + let (poke_tx, poke_rx) = mpsc::channel(128); + let (peek_tx, peek_rx) = mpsc::channel(128); + let base_tip_hash = Arc::new(RwLock::new(None)); + let handle_poke_tx = poke_tx.clone(); + let runtime = BridgeRuntime { + cause_builder, + inbound_rx, + effect_rx, + poke_tx, + poke_rx: Some(poke_rx), + peek_rx: Some(peek_rx), + pending_events: VecDeque::new(), + }; + let handle = BridgeRuntimeHandle { + inbound_tx, + effect_tx, + peek_tx, + poke_tx: handle_poke_tx, + base_tip_hash, + }; + (runtime, handle) + } + + pub async fn install_driver( + &mut self, + app: &mut nockapp::NockApp, + ) -> Result<(), BridgeError> { + let poke_rx = self + .poke_rx + .take() + .ok_or_else(|| BridgeError::Runtime("driver already installed".into()))?; + let peek_rx = self + .peek_rx + .take() + .ok_or_else(|| BridgeError::Runtime("driver already installed".into()))?; + let driver = make_driver(move |handle: NockAppHandle| { + let mut poke_rx = poke_rx; + let mut peek_rx = peek_rx; + async move { + loop { + tokio::select! { + Some(poke) = poke_rx.recv() => { + if let Err(err) = handle.poke(poke.wire.clone(), poke.slab).await { + error!( + target: "bridge.runtime.driver", + error=%err, + "failed to poke kernel from runtime driver" + ); + } + } + Some(peek) = peek_rx.recv() => { + let result = handle + .peek(peek.path_slab) + .await + .map(|opt| opt.map(|s| s.jam().to_vec())) + .map_err(|e| BridgeError::Runtime(e.to_string())); + let _ = peek.respond_to.send(result); + } + else => break, + } + } + Ok(()) + } + }); + app.add_io_driver(driver).await; + Ok(()) + } + + pub async fn run(mut self) -> Result<(), BridgeError> { + loop { + tokio::select! { + // Use biased to prioritize channel messages over timer + biased; + + event = self.inbound_rx.recv() => { + match event { + Some(e) => self.process_event(e).await?, + None => break, // Channel closed, shutdown + } + } + effect = self.effect_rx.recv() => { + match effect { + Some(e) => self.process_effect(e).await?, + None => break, // Channel closed, shutdown + } + } + } + } + Ok(()) + } + + async fn process_event( + &mut self, + event: EventEnvelope, + ) -> Result<(), BridgeError> { + let outcome = self.cause_builder.build_poke(&event)?; + match outcome { + CauseBuildOutcome::Emit(poke) => { + self.poke_tx + .send(poke) + .await + .map_err(|e| BridgeError::Runtime(format!("failed to enqueue poke: {}", e)))?; + } + CauseBuildOutcome::Deferred(reason) => { + let kind = event.id.kind.as_str().to_string(); + let digest = event.id.digest_excerpt(); + self.enqueue_pending(event); + debug!( + target: "bridge.runtime", + kind=%kind, + digest=%digest, + reason=%reason, + pending=self.pending_events.len(), + "event deferred" + ); + } + CauseBuildOutcome::Ignored(reason) => { + debug!( + target: "bridge.runtime", + kind=%event.id.kind.as_str(), + digest=%event.id.digest_excerpt(), + reason=%reason, + "event ignored" + ); + } + } + Ok(()) + } + + async fn process_effect( + &mut self, + effect: EventEnvelope, + ) -> Result<(), BridgeError> { + let detail = match &effect.payload { + BridgeEffect::BaseContractCall(data) => { + format!("submission_bytes={}", data.submission.len()) + } + BridgeEffect::NockchainTx(data) => format!("tx_bytes={}", data.transaction.len()), + }; + info!( + target: "bridge.runtime.effects", + kind=%effect.id.kind.as_str(), + digest=%effect.id.digest_excerpt(), + detail=%detail, + "queued effect awaiting transport" + ); + Ok(()) + } + + fn enqueue_pending(&mut self, event: EventEnvelope) { + if self.pending_events.len() >= MAX_PENDING_EVENTS { + if let Some(oldest) = self.pending_events.pop_front() { + warn!( + target: "bridge.runtime", + kind=%oldest.id.kind.as_str(), + digest=%oldest.id.digest_excerpt(), + "dropping oldest pending event" + ); + } + } + self.pending_events.push_back(event); + } +} + +fn make_event_id(kind: BridgeEventKind, material: &[u8]) -> EventId { + let mut payload = Vec::new(); + payload.extend_from_slice(kind.as_str().as_bytes()); + payload.extend_from_slice(material); + let digest = keccak256(&payload); + let timestamp_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or_default(); + EventId { + kind, + timestamp_ms, + digest, + } +} + +fn make_effect_id(kind: BridgeEffectKind, material: &[u8]) -> EventId { + let mut payload = Vec::new(); + payload.extend_from_slice(kind.as_str().as_bytes()); + payload.extend_from_slice(material); + let digest = keccak256(&payload); + let timestamp_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or_default(); + EventId { + kind: match kind { + BridgeEffectKind::BaseContractCall => BridgeEventKind::ChainBase, + BridgeEffectKind::NockchainTx => BridgeEventKind::ChainNock, + }, + timestamp_ms, + digest, + } +} + +fn cue_bytes(bytes: Vec) -> Result, BridgeError> { + let mut slab: NounSlab = NounSlab::new(); + let noun = slab + .cue_into(Bytes::from(bytes)) + .map_err(|err| BridgeError::Runtime(err.to_string()))?; + slab.set_root(noun); + Ok(slab) +} + +#[derive(Clone, Copy, Debug)] +enum SignatureBroadcastReason { + Initial, + Regossip, +} + +impl SignatureBroadcastReason { + fn as_str(self) -> &'static str { + match self { + SignatureBroadcastReason::Initial => "initial", + SignatureBroadcastReason::Regossip => "regossip", + } + } +} + +/// Background loop that deterministically selects deposits to sign from shared history + chain tip. +/// +/// This decouples signing from `%commit-nock-deposits` effects so nodes can restart at different +/// nock heights and still converge on the same `lastDepositNonce + 1` deposit for signing. +#[allow(clippy::too_many_arguments)] +pub async fn run_signing_cursor_loop( + runtime: Arc, + base_bridge: Arc, + deposit_log: Arc, + nonce_epoch: &NonceEpochConfig, + proposal_cache: Arc, + signer: Arc, + valid_addresses: HashSet
, + peers: Vec, + self_node_id: u64, + bridge_status: BridgeStatus, + address_to_node_id: std::collections::HashMap, + stop_controller: crate::stop::StopController, + stop: crate::stop::StopHandle, +) { + use std::time::Instant; + + use tokio::time::{interval, MissedTickBehavior}; + use tracing::{debug, error, info, warn}; + + use crate::ingress::proto::bridge_ingress_client::BridgeIngressClient; + use crate::ingress::proto::SignatureBroadcast; + use crate::signing::verify_bridge_signature; + use crate::stop::trigger_local_stop; + use crate::types::{DepositId, NockDepositRequestData}; + + const POLL_INTERVAL: Duration = Duration::from_secs(15); + const PIPELINE_DEPTH: usize = 4; + const REGOSSIP_INTERVAL: Duration = Duration::from_secs(90); + + let my_eth_address = signer.address(); + + // Fire-and-forget broadcast of a signature to all peers. + fn spawn_signature_broadcast( + peers: &[PeerEndpoint], + msg: &SignatureBroadcast, + prop_id: &str, + reason: SignatureBroadcastReason, + ) { + for peer in peers { + let msg = msg.clone(); + let addr = peer.address.clone(); + let peer_id = peer.node_id; + let prop_id = prop_id.to_string(); + + tokio::spawn(async move { + match BridgeIngressClient::connect(addr.clone()).await { + Ok(mut client) => match client.broadcast_signature(msg).await { + Ok(_) => { + debug!( + target: "bridge.cursor", + peer_node_id=peer_id, + proposal_hash=%prop_id, + reason=reason.as_str(), + "broadcast signature to peer" + ); + } + Err(e) => { + warn!( + target: "bridge.cursor", + peer_node_id=peer_id, + error=%e, + reason=reason.as_str(), + "failed to broadcast signature to peer" + ); + } + }, + Err(e) => { + warn!( + target: "bridge.cursor", + peer_node_id=peer_id, + peer_address=%addr, + error=%e, + reason=reason.as_str(), + "failed to connect to peer for signature broadcast" + ); + } + } + }); + } + } + + info!( + target: "bridge.cursor", + poll_interval_secs=POLL_INTERVAL.as_secs(), + pipeline_depth=PIPELINE_DEPTH, + regossip_interval_secs=REGOSSIP_INTERVAL.as_secs(), + "starting signing cursor loop" + ); + + let mut ticker = interval(POLL_INTERVAL); + ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); + let mut logged_epoch_ready = false; + let mut last_regossip = Instant::now(); + + loop { + ticker.tick().await; + + if stop.is_stopped() { + continue; + } + + // Periodically re-gossip our own signatures for deposits still collecting. + if last_regossip.elapsed() >= REGOSSIP_INTERVAL { + match proposal_cache.collecting_with_my_sig() { + Ok(pending) => { + for (deposit_id, state) in pending { + let Some(sig) = state.my_signature.clone() else { + continue; + }; + + let broadcast_msg = SignatureBroadcast { + deposit_id: deposit_id.to_bytes(), + proposal_hash: state.proposal_hash.to_vec(), + signature: sig, + signer_address: my_eth_address.as_slice().to_vec(), + timestamp: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + }; + + let prop_id = hex::encode(state.proposal_hash); + spawn_signature_broadcast( + &peers, + &broadcast_msg, + &prop_id, + SignatureBroadcastReason::Regossip, + ); + } + } + Err(err) => { + warn!( + target: "bridge.cursor", + error=%err, + "failed to gather proposals for signature re-gossip" + ); + } + } + + last_regossip = Instant::now(); + } + + let tip_height = match runtime.nock_hashchain_tip().await { + Ok(height) => height, + Err(err) => { + warn!( + target: "bridge.cursor", + error=%err, + "failed to peek nock hashchain tip height" + ); + continue; + } + }; + let Some(tip_height) = tip_height else { + debug!( + target: "bridge.cursor", + "no nock hashchain tip yet, waiting before signing" + ); + continue; + }; + if tip_height < nonce_epoch.start_height { + debug!( + target: "bridge.cursor", + tip_height, + nonce_epoch_start_height = nonce_epoch.start_height, + "hashchain behind nonce epoch start height, waiting to sign" + ); + continue; + } + if !logged_epoch_ready { + logged_epoch_ready = true; + info!( + target: "bridge.cursor", + tip_height, + nonce_epoch_start_height = nonce_epoch.start_height, + "hashchain reached nonce epoch start height, signing enabled" + ); + } + + // Query the chain for the last confirmed deposit nonce. + // This is the source of truth for the signing cursor. + let last_chain_nonce = match base_bridge.get_last_deposit_nonce().await { + Ok(n) => n, + Err(e) => { + warn!( + target: "bridge.cursor", + error=%e, + "failed to query lastDepositNonce from chain" + ); + continue; + } + }; + bridge_status.update_last_deposit_nonce(last_chain_nonce); + let next_nonce = last_chain_nonce + 1; + + // Select the next deposit(s) to sign from the local epoch log. + let nonce_epoch_base = nonce_epoch.base; + if last_chain_nonce < nonce_epoch_base { + let reason = format!( + "nonce epoch mismatch: nonce_epoch_base ({nonce_epoch_base}) is greater than on-chain lastDepositNonce ({last_chain_nonce}); check config" + ); + trigger_local_stop( + runtime.clone(), + stop_controller.clone(), + bridge_status.clone(), + reason, + ) + .await; + continue; + } + + let first_epoch_nonce = nonce_epoch.first_epoch_nonce(); + let spent_epoch_nonces = if last_chain_nonce < first_epoch_nonce { + 0 + } else { + last_chain_nonce - first_epoch_nonce + 1 + }; + let log_len = match deposit_log.number_of_deposits_in_epoch(nonce_epoch).await { + Ok(v) => v, + Err(err) => { + warn!( + target: "bridge.cursor", + error=%err, + "failed to count deposits in sqlite" + ); + let reason = format!("failed to count deposits in sqlite: {err}"); + trigger_local_stop( + runtime.clone(), + stop_controller.clone(), + bridge_status.clone(), + reason, + ) + .await; + continue; + } + }; + if spent_epoch_nonces > log_len { + debug!( + target: "bridge.cursor", + log_len, + spent_epoch_nonces, + nonce_epoch_base, + "deposit log behind chain prefix, waiting for log to catch up" + ); + continue; + } + + let candidates = match deposit_log + .records_from_nonce(next_nonce, PIPELINE_DEPTH, nonce_epoch) + .await + { + Ok(v) => v, + Err(err) => { + warn!( + target: "bridge.cursor", + error=%err, + "failed to query candidate deposits from sqlite" + ); + continue; + } + }; + + if candidates.is_empty() { + continue; + } + + // Sign and gossip signatures for the tip candidate (and optional pipeline). + for (nonce, record) in candidates { + if stop.is_stopped() { + break; + } + + if nonce_epoch.is_before_start_key(record.block_height, &record.tx_id) { + let reason = format!( + "deposit log contains record below nonce_epoch start key (record_height={} < start_height={}); refusing to sign", + record.block_height, nonce_epoch.start_height + ); + trigger_local_stop( + runtime.clone(), + stop_controller.clone(), + bridge_status.clone(), + reason, + ) + .await; + break; + } + + let req = NockDepositRequestData { + tx_id: record.tx_id.clone(), + name: record.name.clone(), + recipient: record.recipient, + amount: record.amount_to_mint, + block_height: record.block_height, + as_of: record.as_of.clone(), + nonce, + }; + + let deposit_id = DepositId::from_effect_payload(&req); + + // Skip work if we've already signed this proposal. + if let Ok(Some(state)) = proposal_cache.get_state(&deposit_id) { + if state.status == crate::proposal_cache::ProposalStatus::Confirmed { + continue; + } + if state.my_signature.is_some() { + continue; + } + } + + // Optimization: skip signing if deposit is already processed on-chain. + // Do not block signing on transient Base RPC errors. + match base_bridge.is_deposit_processed(&req.tx_id).await { + Ok(true) => { + if nonce == next_nonce { + let reason = format!( + "epoch mismatch: tip candidate tx_id is already processed on-chain, but lastDepositNonce={last_chain_nonce} implies nonce {next_nonce} is still pending (check nonce_epoch_start_height/base)" + ); + trigger_local_stop( + runtime.clone(), + stop_controller.clone(), + bridge_status.clone(), + reason, + ) + .await; + break; + } + debug!( + target: "bridge.cursor", + nonce, + "deposit already processed on-chain, skipping signature" + ); + continue; + } + Ok(false) => {} + Err(e) => { + warn!( + target: "bridge.cursor", + nonce=req.nonce, + error=%e, + "failed to query processedDeposits, proceeding to sign anyway" + ); + } + } + + let proposal_hash = req.compute_proposal_hash(); + let proposal_id = hex::encode(proposal_hash); + + // Ensure the proposal is visible in the TUI even if no kernel `%commit-nock-deposits` + // effect fires on this node (e.g. after restart). + bridge_status.update_proposal(crate::tui::types::Proposal { + id: proposal_id.clone(), + proposal_type: "deposit".to_string(), + description: format!( + "Deposit {} wei to {} (nonce {})", + req.amount, + hex::encode(req.recipient.0), + req.nonce + ), + signatures_collected: 0, + signatures_required: crate::proposal_cache::SIGNATURE_THRESHOLD as u8, + signers: vec![], + created_at: SystemTime::now(), + status: crate::tui::types::ProposalStatus::Pending, + data_hash: proposal_id.clone(), + submitted_at_block: None, + submitted_at: None, + tx_hash: None, + time_to_submit_ms: None, + executed_at_block: None, + source_block: Some(req.block_height), + amount: Some(req.amount as u128), + recipient: Some(format!("0x{}", hex::encode(req.recipient.0))), + nonce: Some(req.nonce), + source_tx_id: Some(format_source_tx_id(&req.tx_id)), + current_proposer: None, + is_my_turn: false, + time_until_takeover: None, + }); + + // Step 1: Sign the proposal locally. + let signature = match signer.sign_hash(&proposal_hash).await { + Ok(sig) => sig.as_bytes().to_vec(), + Err(e) => { + error!( + target: "bridge.cursor", + error=%e, + proposal_hash=%proposal_id, + "failed to sign proposal" + ); + continue; + } + }; + + // Step 2: Add own signature to cache. + let add_result = proposal_cache.add_signature( + &deposit_id, + crate::proposal_cache::SignatureData { + signer_address: my_eth_address, + signature: signature.clone(), + proposal_hash, + is_mine: true, + }, + Some(req.clone()), + |hash, sig| verify_bridge_signature(hash, sig, &valid_addresses), + ); + + // Apply any pending signatures that arrived before we processed this deposit. + let valid_addrs = valid_addresses.clone(); + if let Ok(report) = proposal_cache.apply_pending_signatures(&deposit_id, |hash, sig| { + verify_bridge_signature(hash, sig, &valid_addrs) + }) { + if report.applied > 0 { + debug!( + target: "bridge.cursor", + proposal_hash=%proposal_id, + applied_count=report.applied, + "applied pending signatures from peers" + ); + } + if let Some(first) = report.mismatched.first() { + let deposit_id_hex = hex::encode(deposit_id.to_bytes()); + let expected_hex = hex::encode(first.expected_hash); + let received_hex = hex::encode(first.received_hash); + warn!( + target: "bridge.cursor", + deposit_id=%deposit_id_hex, + expected_hash=%expected_hex, + received_hash=%received_hex, + signer=%first.signer_address, + mismatch_count=report.mismatched.len(), + "peer signature proposal hash mismatch, possible nonce divergence" + ); + bridge_status.push_alert( + crate::tui::types::AlertSeverity::Error, + "Nonce Divergence Suspected".to_string(), + format!( + "Deposit {} has {} peer signature(s) for a different proposal hash. expected={}, received={}, signer={}", + deposit_id_hex, + report.mismatched.len(), + expected_hex, + received_hex, + first.signer_address + ), + "nonce-divergence".to_string(), + ); + } + } + + if let Ok(Some(state)) = proposal_cache.get_state(&deposit_id) { + bridge_status.sync_proposal_signatures_from_cache( + &proposal_id, &state, &address_to_node_id, self_node_id, + ); + } + + match add_result { + Ok(crate::proposal_cache::SignatureAddResult::Added) + | Ok(crate::proposal_cache::SignatureAddResult::ThresholdReached) => {} + Ok(crate::proposal_cache::SignatureAddResult::Duplicate) => { + debug!( + target: "bridge.cursor", + proposal_hash=%proposal_id, + "duplicate signature, skipping broadcast" + ); + continue; + } + Ok(crate::proposal_cache::SignatureAddResult::Stale) => { + info!( + target: "bridge.cursor", + proposal_hash=%proposal_id, + "proposal already confirmed, skipping broadcast" + ); + continue; + } + Ok(crate::proposal_cache::SignatureAddResult::Invalid(msg)) => { + warn!( + target: "bridge.cursor", + proposal_hash=%proposal_id, + error=%msg, + "own signature invalid, skipping broadcast" + ); + continue; + } + Err(e) => { + warn!( + target: "bridge.cursor", + proposal_hash=%proposal_id, + error=%e, + "failed to add own signature to cache, skipping broadcast" + ); + continue; + } + } + + // Step 3: Broadcast signature to all peers (fire-and-forget, concurrent). + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let broadcast_msg = SignatureBroadcast { + deposit_id: deposit_id.to_bytes(), + proposal_hash: proposal_hash.to_vec(), + signature: signature.clone(), + signer_address: my_eth_address.as_slice().to_vec(), + timestamp, + }; + + spawn_signature_broadcast( + &peers, + &broadcast_msg, + &proposal_id, + SignatureBroadcastReason::Initial, + ); + } + } +} + +fn update_proposal_cache_metrics(proposal_cache: &ProposalCache) { + let metrics = metrics::init_metrics(); + let snapshot = match proposal_cache.metrics_snapshot() { + Ok(snapshot) => snapshot, + Err(_) => { + metrics.proposal_cache_metrics_update_error.increment(); + return; + } + }; + + metrics.proposal_cache_total.swap(snapshot.proposal_total as f64); + metrics + .proposal_cache_collecting + .swap(snapshot.collecting as f64); + metrics.proposal_cache_ready.swap(snapshot.ready as f64); + metrics.proposal_cache_posting.swap(snapshot.posting as f64); + metrics + .proposal_cache_confirmed + .swap(snapshot.confirmed as f64); + metrics.proposal_cache_failed.swap(snapshot.failed as f64); + metrics + .proposal_cache_total_peer_signatures + .swap(snapshot.total_peer_signatures as f64); + metrics + .proposal_cache_max_peer_signatures_per_proposal + .swap(snapshot.max_peer_signatures_per_proposal as f64); + metrics + .proposal_cache_proposals_with_my_signature + .swap(snapshot.proposals_with_my_signature as f64); + metrics + .proposal_cache_pending_signature_deposit_count + .swap(snapshot.pending_signature_deposit_count as f64); + metrics + .proposal_cache_pending_signature_total + .swap(snapshot.pending_signature_total as f64); + metrics + .proposal_cache_oldest_age_secs + .swap(snapshot.oldest_age_secs as f64); + metrics + .proposal_cache_oldest_confirmed_age_secs + .swap(snapshot.oldest_confirmed_age_secs as f64); + metrics + .proposal_cache_oldest_failed_age_secs + .swap(snapshot.oldest_failed_age_secs as f64); + metrics + .proposal_cache_pending_oldest_age_secs + .swap(snapshot.pending_oldest_age_secs as f64); + metrics + .proposal_cache_approx_state_bytes + .swap(snapshot.approx_state_bytes as f64); + metrics + .proposal_cache_approx_peer_signature_bytes + .swap(snapshot.approx_peer_signature_bytes as f64); + metrics + .proposal_cache_approx_my_signature_bytes + .swap(snapshot.approx_my_signature_bytes as f64); + metrics + .proposal_cache_approx_pending_signature_bytes + .swap(snapshot.approx_pending_signature_bytes as f64); + metrics + .proposal_cache_approx_total_bytes + .swap(snapshot.approx_total_bytes as f64); +} + +/// Background loop that checks ProposalCache for ready proposals and posts to BASE. +/// +/// Runs continuously, checking every second for proposals with threshold signatures. +/// Posts to Base when: +/// 1. Threshold reached (status=Ready) +/// 2. I'm the proposer OR backoff expired (failover logic) +/// +pub async fn run_posting_loop( + proposal_cache: Arc, + base_bridge: Arc, + node_config: NodeConfig, + bridge_status: BridgeStatus, + stop: crate::stop::StopHandle, + status_state: crate::status::BridgeStatusState, +) { + use std::time::{SystemTime, UNIX_EPOCH}; + + use serde_bytes::ByteBuf; + use tracing::{debug, error, info, warn}; + + use crate::ingress::proto::bridge_ingress_client::BridgeIngressClient; + use crate::ingress::proto::ConfirmationBroadcast; + use crate::proposer::hoon_proposer; + use crate::status::LastSubmittedDeposit; + use crate::tui::types::{AlertSeverity, BatchStatus, ProposalStatus}; + use crate::types::DepositSubmission; + + const FAILOVER_BACKOFF_SECS: u64 = 120; // 2m per failover slot + + // Extract node PKHs for proposer calculation + let node_pkhs: Vec<_> = node_config + .nodes + .iter() + .map(|n| n.nock_pkh.clone()) + .collect(); + let num_nodes = node_pkhs.len(); + let my_node_id = node_config.node_id as usize; + + // Build peer addresses for confirmation broadcast (exclude self) + let peers: Vec<(u64, String)> = node_config + .nodes + .iter() + .enumerate() + .filter(|(idx, _)| *idx != my_node_id) + .map(|(idx, node)| (idx as u64, crate::health::normalize_endpoint(&node.ip))) + .collect(); + + info!("Starting proposal posting loop"); + + loop { + tokio::time::sleep(Duration::from_secs(1)).await; + update_proposal_cache_metrics(&proposal_cache); + if stop.is_stopped() { + continue; + } + + // Get all ready proposals + let ready_proposals = match proposal_cache.ready_proposals() { + Ok(proposals) => proposals, + Err(e) => { + error!(target: "bridge.posting", error=%e, "failed to fetch ready proposals"); + continue; + } + }; + + if ready_proposals.is_empty() { + continue; + } + + // Query the chain for the last confirmed deposit nonce. + // This is the source of truth - we only submit lastDepositNonce + 1. + let last_chain_nonce = match base_bridge.get_last_deposit_nonce().await { + Ok(n) => n, + Err(e) => { + error!(target: "bridge.posting", error=%e, "failed to query lastDepositNonce from chain"); + continue; + } + }; + bridge_status.update_last_deposit_nonce(last_chain_nonce); + let next_nonce = last_chain_nonce + 1; + + debug!( + target: "bridge.posting", + last_chain_nonce=last_chain_nonce, + next_nonce=next_nonce, + "queried chain for deposit nonce" + ); + + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + // NOTE: do not "skip" a stuck nonce. Under runtime-assigned epoch nonces, skipping + // strands deposits permanently because subsequent nonces cannot be posted until the + // contract advances. We only ever submit `lastDepositNonce + 1`. + + // Get current nockchain height for proposer calculation + // Use the block_height from the first proposal as a proxy + // (all proposals in a batch should be from the same height) + let current_height = ready_proposals + .first() + .map(|(_, state)| state.proposal.block_height) + .unwrap_or(0); + + let current_proposer = hoon_proposer(current_height, &node_pkhs); + + debug!( + target: "bridge.posting", + ready_count=ready_proposals.len(), + current_height=current_height, + current_proposer=current_proposer, + my_node_id=my_node_id, + "checking ready proposals" + ); + + for (deposit_id, state) in ready_proposals { + let proposal_hash = state.proposal_hash; + let proposal_id = hex::encode(proposal_hash); + + // Only submit the next expected nonce (lastDepositNonce + 1). + // If this proposal's nonce doesn't match, skip it. + if state.proposal.nonce < next_nonce { + // Already on chain - mark as confirmed and skip + debug!( + target: "bridge.posting", + proposal_hash=%proposal_id, + nonce=state.proposal.nonce, + last_chain_nonce=last_chain_nonce, + "proposal already confirmed on chain, marking confirmed" + ); + let _ = proposal_cache.mark_confirmed(&deposit_id); + continue; + } else if state.proposal.nonce > next_nonce { + // Not ready yet - waiting for earlier nonce + debug!( + target: "bridge.posting", + proposal_hash=%proposal_id, + nonce=state.proposal.nonce, + next_nonce=next_nonce, + "waiting for nonce {} to be ready before posting {}", + next_nonce, + state.proposal.nonce + ); + continue; + } + // state.proposal.nonce == next_nonce - this is the one to submit + + // Calculate if this node should post + let should_post = if my_node_id == current_proposer { + // I'm the proposer, post immediately + true + } else if let Some(ready_at) = state.ready_at { + // Calculate failover slot (position after proposer in rotation) + let failover_slot = if my_node_id > current_proposer { + my_node_id - current_proposer + } else { + num_nodes - current_proposer + my_node_id + }; + let required_wait = FAILOVER_BACKOFF_SECS * failover_slot as u64; + let elapsed = now.saturating_sub(ready_at); + elapsed >= required_wait + } else { + // No ready_at timestamp, shouldn't happen but skip + false + }; + + if !should_post { + debug!( + target: "bridge.posting", + proposal_hash=%proposal_id, + current_proposer=current_proposer, + my_node_id=my_node_id, + "not my turn to post, waiting for proposer or failover" + ); + continue; + } + + info!( + target: "bridge.posting", + proposal_hash=%proposal_id, + current_proposer=current_proposer, + my_node_id=my_node_id, + is_proposer=(my_node_id == current_proposer), + "posting proposal to BASE" + ); + + // Get signatures for posting BEFORE marking as posting + // (get_signatures_for_posting requires status == Ready) + let signatures = match proposal_cache.get_signatures_for_posting(&deposit_id) { + Ok(Some(sigs)) => sigs, + Ok(None) => { + warn!( + target: "bridge.posting", + proposal_hash=%proposal_id, + "proposal no longer ready for posting" + ); + continue; + } + Err(e) => { + error!( + target: "bridge.posting", + error=%e, + proposal_hash=%proposal_id, + "failed to get signatures for posting" + ); + let _ = proposal_cache.mark_failed(&deposit_id); + continue; + } + }; + + // Mark as posting to prevent duplicate submissions + if let Err(e) = proposal_cache.mark_posting(&deposit_id) { + error!( + target: "bridge.posting", + error=%e, + proposal_hash=%proposal_id, + "failed to mark proposal as posting" + ); + continue; + } + + // Update batch status to Submitting + bridge_status.update_batch_status(BatchStatus::Submitting { + batch_id: state.proposal.nonce, + }); + + info!( + target: "bridge.posting", + proposal_hash=%proposal_id, + "posting proposal to BASE" + ); + + // Prepare deposit submission + let req = &state.proposal; + let mut recipient_bytes = [0u8; 20]; + recipient_bytes.copy_from_slice(&req.recipient.0); + + let submission = DepositSubmission { + tx_id: req.tx_id.clone(), + name_first: req.name.first.clone(), + name_last: req.name.last.clone(), + recipient: recipient_bytes, + amount: req.amount as u128, + block_height: req.block_height, + as_of: req.as_of.clone(), + nonce: req.nonce, + signatures: crate::types::SignatureSet { + eth_signatures: signatures.into_iter().map(ByteBuf::from).collect(), + nock_signatures: vec![], // Not used for Base deposits + }, + }; + + // Update TUI to Submitted status with timestamp + let submit_time = std::time::SystemTime::now(); + if let Some(mut proposal) = bridge_status.find_proposal(&proposal_id) { + proposal.status = ProposalStatus::Submitted; + proposal.submitted_at = Some(submit_time); + if let Ok(duration) = submit_time.duration_since(proposal.created_at) { + proposal.time_to_submit_ms = Some(duration.as_millis() as u64); + } + bridge_status.update_proposal(proposal); + } + + // Submit to BASE with a timeout so a hung RPC can't stall the queue + match tokio::time::timeout( + Duration::from_secs(SUBMIT_DEPOSIT_TIMEOUT_SECS), + base_bridge.submit_deposit(submission), + ) + .await + { + Ok(Ok(result)) => { + info!( + target: "bridge.posting", + proposal_hash=%proposal_id, + tx_hash=%result.tx_hash, + block_number=%result.block_number, + "successfully posted deposit to BASE" + ); + + status_state.update_last_submitted_deposit(LastSubmittedDeposit { + deposit: state.proposal.clone(), + base_tx_hash: result.tx_hash.clone(), + base_block_number: result.block_number, + }); + + // Mark confirmed in cache + let _ = proposal_cache.mark_confirmed(&deposit_id); + + // Broadcast confirmation to all peers so they stop waiting + let deposit_id_bytes = deposit_id.to_bytes(); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let confirmation_msg = ConfirmationBroadcast { + deposit_id: deposit_id_bytes, + proposal_hash: proposal_hash.to_vec(), + tx_hash: result.tx_hash.as_bytes().to_vec(), + block_number: result.block_number, + timestamp, + }; + + for (peer_node_id, peer_address) in &peers { + let msg = confirmation_msg.clone(); + let addr = peer_address.clone(); + let peer_id = *peer_node_id; + let prop_id = proposal_id.clone(); + + tokio::spawn(async move { + match BridgeIngressClient::connect(addr.clone()).await { + Ok(mut client) => match client.broadcast_confirmation(msg).await { + Ok(_) => { + info!( + target: "bridge.posting", + peer_node_id=peer_id, + proposal_hash=%prop_id, + "broadcast confirmation to peer" + ); + } + Err(e) => { + warn!( + target: "bridge.posting", + peer_node_id=peer_id, + error=%e, + "failed to broadcast confirmation to peer" + ); + } + }, + Err(e) => { + warn!( + target: "bridge.posting", + peer_node_id=peer_id, + peer_address=%addr, + error=%e, + "failed to connect to peer for confirmation broadcast" + ); + } + } + }); + } + + // Update TUI to Executed status + if let Some(mut proposal) = bridge_status.find_proposal(&proposal_id) { + proposal.status = ProposalStatus::Executed; + proposal.tx_hash = Some(result.tx_hash); + proposal.submitted_at_block = Some(result.block_number); + proposal.executed_at_block = Some(result.block_number); + bridge_status.update_proposal(proposal); + } + + // Update batch status back to Idle after successful submission + bridge_status.update_batch_status(BatchStatus::Idle); + } + Ok(Err(e)) => { + error!( + target: "bridge.posting", + error=%e, + proposal_hash=%proposal_id, + "failed to post deposit to BASE" + ); + + // Mark failed in cache + let _ = proposal_cache.mark_failed(&deposit_id); + + // Update TUI to Failed status + if let Some(mut proposal) = bridge_status.find_proposal(&proposal_id) { + proposal.status = ProposalStatus::Failed { + reason: format!("BASE submission failed: {}", e), + }; + bridge_status.update_proposal(proposal); + } + + // Push alert for failure + bridge_status.push_alert( + AlertSeverity::Error, + "Proposal Failed".to_string(), + format!("Failed to post deposit {}: {}", proposal_id, e), + "posting-loop".to_string(), + ); + + // Update batch status back to Idle after failure + bridge_status.update_batch_status(BatchStatus::Idle); + } + Err(_) => { + error!( + target: "bridge.posting", + proposal_hash=%proposal_id, + timeout_secs=SUBMIT_DEPOSIT_TIMEOUT_SECS, + "posting to BASE timed out" + ); + + // Mark failed in cache + let _ = proposal_cache.mark_failed(&deposit_id); + + // Update TUI to Failed status + if let Some(mut proposal) = bridge_status.find_proposal(&proposal_id) { + proposal.status = ProposalStatus::Failed { + reason: format!( + "BASE submission timed out after {}s", + SUBMIT_DEPOSIT_TIMEOUT_SECS + ), + }; + bridge_status.update_proposal(proposal); + } + + // Push alert for failure + bridge_status.push_alert( + AlertSeverity::Error, + "Proposal Failed".to_string(), + format!( + "Failed to post deposit {}: timed out after {}s", + proposal_id, SUBMIT_DEPOSIT_TIMEOUT_SECS + ), + "posting-loop".to_string(), + ); + + // Update batch status back to Idle after timeout + bridge_status.update_batch_status(BatchStatus::Idle); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + + use tokio::time::{sleep, Duration}; + + use super::*; + use crate::types::{ + zero_tip5_hash, AtomBytes, BaseEventContent, EthAddress, Tip5Hash, Withdrawal, + }; + + #[test] + fn format_ud_for_cord_inserts_dots() { + assert_eq!(format_ud_for_cord(0), "0"); + assert_eq!(format_ud_for_cord(12), "12"); + assert_eq!(format_ud_for_cord(999), "999"); + assert_eq!(format_ud_for_cord(1_000), "1.000"); + assert_eq!(format_ud_for_cord(50_000), "50.000"); + assert_eq!(format_ud_for_cord(1_234_567), "1.234.567"); + assert_eq!(format_ud_for_cord(1_234_567_890), "1.234.567.890"); + } + + #[test] + fn format_source_tx_id_uses_base58() { + let tx_id = Tip5Hash::from_limbs(&[1, 2, 3, 4, 5]); + let expected = tx_id.to_base58(); + assert_eq!(format_source_tx_id(&tx_id), expected); + assert_ne!(format!("{:?}", tx_id), expected); + } + + struct RecordingEventBuilder { + events: Arc>>, + } + + impl CauseBuilder for RecordingEventBuilder { + fn build_poke( + &self, + event: &EventEnvelope, + ) -> Result { + self.events + .lock() + .expect("recording event builder mutex poisoned") + .push(event.payload.clone()); + Ok(CauseBuildOutcome::Deferred("test".into())) + } + } + + struct RecordingBuilder { + events: Arc>>, + } + + impl CauseBuilder for RecordingBuilder { + fn build_poke( + &self, + event: &EventEnvelope, + ) -> Result { + self.events + .lock() + .expect("Mutex poisoned in test - this should not happen") + .push(event.id); + Ok(CauseBuildOutcome::Deferred("test".into())) + } + } + + fn sample_base_batch() -> BaseBlockBatch { + BaseBlockBatch { + version: 0, + first_height: 7, + last_height: 7, + blocks: vec![BaseBlockRef { + height: 7, + block_id: AtomBytes(vec![0x01, 0x02]), + parent_block_id: AtomBytes(vec![0x00, 0x01]), + }], + withdrawals: Vec::new(), + deposit_settlements: Vec::new(), + block_events: HashMap::new(), + prev: zero_tip5_hash(), + } + } + + #[tokio::test] + async fn runtime_records_chain_events_via_cause_builder() -> Result<(), BridgeError> { + let records = Arc::new(Mutex::new(Vec::new())); + let builder = Arc::new(RecordingBuilder { + events: records.clone(), + }); + let (runtime, handle) = BridgeRuntime::new(builder); + let runtime_task = tokio::spawn(runtime.run()); + + let id = handle + .send_event(BridgeEvent::Chain(Box::new(ChainEvent::Base( + sample_base_batch(), + )))) + .await?; + assert!(matches!(id.kind, BridgeEventKind::ChainBase)); + + sleep(Duration::from_millis(20)).await; + drop(handle); + runtime_task + .await + .expect("Runtime task should complete successfully")?; + + let events = records + .lock() + .expect("Mutex poisoned in test - this should not happen"); + assert_eq!(events.len(), 1); + assert!(matches!(events[0].kind, BridgeEventKind::ChainBase)); + Ok(()) + } + + #[tokio::test] + async fn runtime_records_withdrawal_events() -> Result<(), BridgeError> { + let events = Arc::new(Mutex::new(Vec::new())); + let builder = Arc::new(RecordingEventBuilder { + events: events.clone(), + }); + let (runtime, handle) = BridgeRuntime::new(builder); + let runtime_task = tokio::spawn(runtime.run()); + + let withdrawal = BaseWithdrawalEntry { + base_tx_id: AtomBytes(vec![0x01]), + withdrawal: Withdrawal { + base_tx_id: AtomBytes(vec![0x01]), + dest: None, + raw_amount: 5, + }, + }; + let mut block_events = HashMap::new(); + block_events.insert( + 10, + vec![BaseEvent { + base_event_id: AtomBytes(vec![0x01]), + content: BaseEventContent::BurnForWithdrawal { + burner: EthAddress([0xde; 20]), + amount: 5, + lock_root: zero_tip5_hash(), + }, + }], + ); + let batch = BaseBlockBatch { + version: 0, + first_height: 10, + last_height: 10, + blocks: vec![BaseBlockRef { + height: 10, + block_id: AtomBytes(vec![0x06]), + parent_block_id: AtomBytes(vec![0x05]), + }], + withdrawals: vec![withdrawal.clone()], + deposit_settlements: Vec::new(), + block_events, + prev: zero_tip5_hash(), + }; + + handle + .send_event(BridgeEvent::Chain(Box::new(ChainEvent::Base(batch)))) + .await?; + + sleep(Duration::from_millis(20)).await; + drop(handle); + runtime_task + .await + .expect("Runtime task should complete successfully")?; + + let recorded = events.lock().expect("recording events mutex poisoned"); + assert_eq!(recorded.len(), 1); + match &recorded[0] { + BridgeEvent::Chain(ref chain) => { + if let ChainEvent::Base(recorded_batch) = chain.as_ref() { + assert_eq!(recorded_batch.withdrawals.len(), 1); + assert_eq!( + recorded_batch.withdrawals[0].withdrawal.raw_amount, + withdrawal.withdrawal.raw_amount + ); + } else { + panic!("expected base chain event"); + } + } + } + + Ok(()) + } + + #[test] + fn kernel_builder_emits_base_poke() -> Result<(), BridgeError> { + let builder = KernelCauseBuilder; + let event = EventEnvelope { + id: make_event_id(BridgeEventKind::ChainBase, &[]), + payload: BridgeEvent::Chain(Box::new(ChainEvent::Base(sample_base_batch()))), + }; + let outcome = builder.build_poke(&event)?; + assert!(matches!(outcome, CauseBuildOutcome::Emit(_))); + Ok(()) + } + + fn jam_height_peek(peek: HeightPeek) -> Vec { + let mut slab: NounSlab = NounSlab::new(); + let noun = peek.to_noun(&mut slab); + slab.set_root(noun); + slab.jam().to_vec() + } + + #[tokio::test] + async fn peek_base_height_returns_value() -> Result<(), BridgeError> { + let builder = Arc::new(RecordingBuilder { + events: Arc::new(Mutex::new(Vec::new())), + }); + let (mut runtime, handle) = BridgeRuntime::new(builder); + let mut peek_rx = runtime.peek_rx.take().expect("peek receiver missing"); + + let responder = tokio::spawn(async move { + if let Some(request) = peek_rx.recv().await { + // Note: path is now in path_slab as a NounSlab, not a Vec + let bytes = jam_height_peek(HeightPeek { + inner: Some(Some(42)), + }); + let _ = request.respond_to.send(Ok(Some(bytes))); + } + }); + + let height = handle.peek_base_next_height().await?; + assert_eq!(height, Some(42)); + + responder.await.expect("responder task failed"); + Ok(()) + } + + #[tokio::test] + async fn peek_nock_height_handles_absent() -> Result<(), BridgeError> { + let builder = Arc::new(RecordingBuilder { + events: Arc::new(Mutex::new(Vec::new())), + }); + let (mut runtime, handle) = BridgeRuntime::new(builder); + let mut peek_rx = runtime.peek_rx.take().expect("peek receiver missing"); + + let responder = tokio::spawn(async move { + if let Some(request) = peek_rx.recv().await { + // Note: path is now in path_slab as a NounSlab, not a Vec + let bytes = jam_height_peek(HeightPeek { inner: Some(None) }); + let _ = request.respond_to.send(Ok(Some(bytes))); + } + }); + + let height = handle.peek_nock_next_height().await?; + assert!(height.is_none()); + + responder.await.expect("responder task failed"); + Ok(()) + } + + fn jam_count_peek(peek: CountPeek) -> Vec { + let mut slab: NounSlab = NounSlab::new(); + let noun = peek.to_noun(&mut slab); + slab.set_root(noun); + slab.jam().to_vec() + } + + fn jam_hold_peek(peek: HoldPeek) -> Vec { + let mut slab: NounSlab = NounSlab::new(); + let noun = peek.to_noun(&mut slab); + slab.set_root(noun); + slab.jam().to_vec() + } + + #[tokio::test] + async fn peek_unsettled_deposit_count_returns_value() -> Result<(), BridgeError> { + let builder = Arc::new(RecordingBuilder { + events: Arc::new(Mutex::new(Vec::new())), + }); + let (mut runtime, handle) = BridgeRuntime::new(builder); + let mut peek_rx = runtime.peek_rx.take().expect("peek receiver missing"); + + let responder = tokio::spawn(async move { + if let Some(request) = peek_rx.recv().await { + let bytes = jam_count_peek(CountPeek { + inner: Some(Some(5)), + }); + let _ = request.respond_to.send(Ok(Some(bytes))); + } + }); + + let count = handle.peek_unsettled_deposit_count().await?; + assert_eq!(count, 5); + + responder.await.expect("responder task failed"); + Ok(()) + } + + #[tokio::test] + async fn peek_count_returns_zero_on_absent() -> Result<(), BridgeError> { + let builder = Arc::new(RecordingBuilder { + events: Arc::new(Mutex::new(Vec::new())), + }); + let (mut runtime, handle) = BridgeRuntime::new(builder); + let mut peek_rx = runtime.peek_rx.take().expect("peek receiver missing"); + + let responder = tokio::spawn(async move { + if let Some(request) = peek_rx.recv().await { + // Return None to simulate absent data + let _ = request.respond_to.send(Ok(None)); + } + }); + + let count = handle.peek_unsettled_withdrawal_count().await?; + assert_eq!(count, 0); + + responder.await.expect("responder task failed"); + Ok(()) + } + + #[tokio::test] + async fn peek_base_hold_returns_true() -> Result<(), BridgeError> { + let builder = Arc::new(RecordingBuilder { + events: Arc::new(Mutex::new(Vec::new())), + }); + let (mut runtime, handle) = BridgeRuntime::new(builder); + let mut peek_rx = runtime.peek_rx.take().expect("peek receiver missing"); + + let responder = tokio::spawn(async move { + if let Some(request) = peek_rx.recv().await { + let bytes = jam_hold_peek(HoldPeek { + inner: Some(Some(HoldInfo { + hash: crate::types::zero_tip5_hash(), + height: 42, + })), + }); + let _ = request.respond_to.send(Ok(Some(bytes))); + } + }); + + let hold = handle.peek_base_hold().await?; + assert!(hold); + + responder.await.expect("responder task failed"); + Ok(()) + } + + #[tokio::test] + async fn peek_hold_returns_none_on_absent() -> Result<(), BridgeError> { + let builder = Arc::new(RecordingBuilder { + events: Arc::new(Mutex::new(Vec::new())), + }); + let (mut runtime, handle) = BridgeRuntime::new(builder); + let mut peek_rx = runtime.peek_rx.take().expect("peek receiver missing"); + + let responder = tokio::spawn(async move { + if let Some(request) = peek_rx.recv().await { + // Return None to simulate absent data + let _ = request.respond_to.send(Ok(None)); + } + }); + + let hold = handle.peek_nock_hold().await?; + assert!(!hold); + + responder.await.expect("responder task failed"); + Ok(()) + } +} diff --git a/crates/bridge/src/schema.rs b/crates/bridge/src/schema.rs new file mode 100644 index 000000000..09a41720a --- /dev/null +++ b/crates/bridge/src/schema.rs @@ -0,0 +1,13 @@ +diesel::table! { + deposit_log (tx_id) { + tx_id -> Binary, + block_height -> BigInt, + as_of -> Binary, + name_first -> Binary, + name_last -> Binary, + recipient -> Binary, + amount_to_mint -> BigInt, + } +} + +diesel::allow_tables_to_appear_in_same_query!(deposit_log,); diff --git a/crates/bridge/src/signing.rs b/crates/bridge/src/signing.rs new file mode 100644 index 000000000..82e2dcbce --- /dev/null +++ b/crates/bridge/src/signing.rs @@ -0,0 +1,261 @@ +use std::collections::HashSet; +use std::str::FromStr; + +use alloy::primitives::{Address, Signature}; +use alloy::signers::local::PrivateKeySigner; +use alloy::signers::Signer; +use nockapp::nockapp::wire::{Wire, WireRepr}; +use nockvm::noun::Noun; +use noun_serde::NounDecode; +use tracing::info; + +use crate::errors::BridgeError; +use crate::types::{NodeConfig, NounDigest}; + +/// Ethereum signature handler for bridge operations +pub struct EthereumSigner { + wallet: PrivateKeySigner, +} + +impl EthereumSigner { + /// Create a new Ethereum signer from a private key + pub fn new(private_key: String) -> Result { + let key = private_key.strip_prefix("0x").unwrap_or(&private_key); + let wallet = PrivateKeySigner::from_str(key)?; + + Ok(Self { wallet }) + } + + /// Sign a proposal hash with Ethereum secp256k1 + /// + /// The proposal_hash is the hash of a bundle proposal that needs to be signed. + /// This uses EIP-191 message prefix for Ethereum compatibility. + pub async fn sign_proposal(&self, proposal_hash_noun: Noun) -> Result { + let proposal_hash = match proposal_hash_noun.as_atom() { + Ok(_) => Self::proposal_hash_from_noun(proposal_hash_noun)?, + Err(_) => { + let digest = NounDigest::from_noun(&proposal_hash_noun).map_err(|_| { + BridgeError::Config("proposal-hash noun was not an atom".into()) + })?; + Self::proposal_hash_from_limbs(digest) + } + }; + self.sign_hash(&proposal_hash).await + } + + /// Sign a 32-byte hash directly with Ethereum secp256k1. + /// This uses EIP-191 message prefix for Ethereum compatibility. + pub async fn sign_hash(&self, hash: &[u8; 32]) -> Result { + info!("Signing Ethereum proposal with hash: {:?}", hash); + + let signature = self + .wallet + .sign_message(hash) + .await + .map_err(|e| BridgeError::ContractInteraction(format!("Signing failed: {}", e)))?; + + info!("Generated Ethereum signature: {:?}", signature); + Ok(signature) + } + + /// Convert a cued noun representing a proposal hash into a 32-byte big-endian array + pub fn proposal_hash_from_noun(hash_noun: Noun) -> Result<[u8; 32], BridgeError> { + let atom = hash_noun + .as_atom() + .map_err(|_| BridgeError::Config("proposal-hash noun was not an atom".to_string()))?; + let mut b = atom.to_be_bytes(); + if b.len() > 32 { + b = b.split_off(b.len() - 32); + } else if b.len() < 32 { + let mut padded = vec![0u8; 32 - b.len()]; + padded.extend_from_slice(&b); + b = padded; + } + let mut arr = [0u8; 32]; + arr.copy_from_slice(&b); + Ok(arr) + } + + fn proposal_hash_from_limbs(digest: NounDigest) -> [u8; 32] { + // Concatenate five u64 limbs big-endian and take the last 32 bytes (drop top 8 bytes) + let mut bytes = [0u8; 40]; + for (i, limb) in digest.0.iter().enumerate() { + let be = limb.0.to_be_bytes(); + bytes[i * 8..(i + 1) * 8].copy_from_slice(&be); + } + let mut out = [0u8; 32]; + out.copy_from_slice(&bytes[8..]); + out + } + + /// Get the Ethereum address of this signer + pub fn address(&self) -> Address { + self.wallet.address() + } +} + +/// Bridge signer that only handles Ethereum signatures +/// Nockchain signatures are handled in Hoon +pub type BridgeSigner = EthereumSigner; + +/// Wire for signature results +#[allow(dead_code)] +pub enum SignatureWire { + EthSignatureResult { + proposal_hash: [u8; 32], + signature: Vec, + }, +} + +impl Wire for SignatureWire { + const VERSION: u64 = 1; + const SOURCE: &'static str = "signature"; + + fn to_wire(&self) -> WireRepr { + match self { + SignatureWire::EthSignatureResult { .. } => { + WireRepr::new(Self::SOURCE, Self::VERSION, vec!["poke".into()]) + } + } + } +} + +/// Verify that a signature is from an authorized bridge node. +/// Returns the recovered address if valid, or None if invalid. +pub fn verify_bridge_signature( + proposal_hash: &[u8; 32], + signature: &[u8], + valid_addresses: &HashSet
, +) -> Option
{ + use alloy::primitives::{Signature as AlloySignature, B256}; + + if signature.len() != 65 { + return None; + } + + let mut r = [0u8; 32]; + let mut s = [0u8; 32]; + r.copy_from_slice(&signature[0..32]); + s.copy_from_slice(&signature[32..64]); + let v = signature[64]; + + // v must be 27 or 28 for Ethereum signatures + if v != 27 && v != 28 { + return None; + } + + let y_parity = v == 28; + let sig = AlloySignature::new( + alloy::primitives::U256::from_be_bytes(r), + alloy::primitives::U256::from_be_bytes(s), + y_parity, + ); + + // EIP-191 recovery matches the Solidity contract's _verifySignatures + let hash = B256::from_slice(proposal_hash); + let recovered = match sig.recover_address_from_msg(hash.as_slice()) { + Ok(addr) => addr, + Err(e) => { + tracing::warn!( + target: "bridge.propose", + error=%e, + sig_r=%hex::encode(r), + sig_s=%hex::encode(s), + sig_v=v, + hash=%hex::encode(proposal_hash), + "failed to recover address from signature" + ); + return None; + } + }; + + tracing::debug!( + target: "bridge.propose", + recovered=%recovered, + valid_addresses=?valid_addresses.iter().map(|a| format!("{}", a)).collect::>(), + "checking if recovered address is in valid set" + ); + + if valid_addresses.contains(&recovered) { + Some(recovered) + } else { + tracing::warn!( + target: "bridge.propose", + recovered=%recovered, + valid_addresses=?valid_addresses.iter().map(|a| format!("{}", a)).collect::>(), + "recovered address not in valid set" + ); + None + } +} + +/// Extract valid bridge node Ethereum addresses from node config. +pub fn extract_valid_bridge_addresses(node_config: &NodeConfig) -> HashSet
{ + tracing::info!( + target: "bridge.propose", + node_count = node_config.nodes.len(), + "extracting valid bridge addresses from config" + ); + node_config + .nodes + .iter() + .filter_map(|node| { + tracing::debug!( + target: "bridge.propose", + ip = %node.ip, + eth_pubkey_len = node.eth_pubkey.0.len(), + eth_pubkey_hex = %hex::encode(&node.eth_pubkey.0), + "checking node eth_pubkey" + ); + if node.eth_pubkey.0.len() == 20 { + let addr = Address::from_slice(&node.eth_pubkey.0); + tracing::info!( + target: "bridge.propose", + ip = %node.ip, + address = %addr, + "added valid bridge address" + ); + Some(addr) + } else { + tracing::warn!( + "node eth_pubkey is not 20 bytes (got {}), skipping for signature verification", + node.eth_pubkey.0.len() + ); + None + } + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_ethereum_signing() { + // Use a test private key + let private_key = "0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318"; + let signer = EthereumSigner::new(private_key.to_string()) + .expect("Failed to create EthereumSigner with test private key"); + + let proposal_hash = [1u8; 32]; + let mut slab = nockapp::noun::slab::NounSlab::::new(); + let noun = unsafe { + let mut ia = + nockvm::noun::IndirectAtom::new_raw_bytes(&mut slab, 32, proposal_hash.as_ptr()); + ia.normalize_as_atom() + }; + let signature = signer + .sign_proposal(noun.as_noun()) + .await + .expect("Failed to sign proposal in test"); + + // Verify signature is valid + assert_ne!(signature.r(), alloy::primitives::U256::ZERO); + assert_ne!(signature.s(), alloy::primitives::U256::ZERO); + let recovery_id = signature.as_bytes()[64]; + assert!(recovery_id == 27 || recovery_id == 28); + } + + // Nockchain signing tests are now in Hoon +} diff --git a/crates/bridge/src/status.rs b/crates/bridge/src/status.rs new file mode 100644 index 000000000..e3a7210a4 --- /dev/null +++ b/crates/bridge/src/status.rs @@ -0,0 +1,188 @@ +use std::sync::{Arc, RwLock}; + +use hex::encode as hex_encode; +use tonic::{Request, Response, Status}; +use tracing::warn; + +use crate::bridge_status::BridgeStatus as BridgeStatusCache; +use crate::config::NonceEpochConfig; +use crate::deposit_log::DepositLog; +use crate::types::NockDepositRequestData; + +pub mod proto { + tonic::include_proto!("bridge.status.v1"); +} + +use proto::bridge_status_server::BridgeStatus; +use proto::{ + Base58Hash, EthAddress as EthAddressProto, GetStatusRequest, GetStatusResponse, LastDeposit, + RunningState, SuccessfulDeposit, +}; + +#[derive(Clone, Debug)] +pub struct LastSubmittedDeposit { + pub deposit: NockDepositRequestData, + pub base_tx_hash: String, + pub base_block_number: u64, +} + +#[derive(Clone, Debug, Default)] +pub struct BridgeStatusState { + last_submitted_deposit: Arc>>, +} + +impl BridgeStatusState { + pub fn new() -> Self { + Self::default() + } + + pub fn update_last_submitted_deposit(&self, deposit: LastSubmittedDeposit) { + if let Ok(mut guard) = self.last_submitted_deposit.write() { + *guard = Some(deposit); + } + } + + pub fn last_submitted_deposit(&self) -> Option { + self.last_submitted_deposit + .read() + .ok() + .and_then(|guard| guard.clone()) + } +} + +#[derive(Clone)] +pub struct StatusService { + state: BridgeStatusState, + deposit_log: Arc, + nonce_epoch: NonceEpochConfig, + bridge_status: BridgeStatusCache, +} + +impl StatusService { + pub fn new( + state: BridgeStatusState, + deposit_log: Arc, + nonce_epoch: NonceEpochConfig, + bridge_status: BridgeStatusCache, + ) -> Self { + Self { + state, + deposit_log, + nonce_epoch, + bridge_status, + } + } +} + +#[tonic::async_trait] +impl BridgeStatus for StatusService { + async fn get_status( + &self, + _request: Request, + ) -> Result, Status> { + let network = self.bridge_status.network(); + let base_height = if network.base.last_updated.is_some() { + Some(network.base.height) + } else { + None + }; + let nock_height = if network.nockchain.last_updated.is_some() { + Some(network.nockchain.height) + } else { + None + }; + + let running_state = if network.kernel_stopped { + RunningState::Stopped + } else { + RunningState::Running + }; + + let last_submitted_deposit = self + .state + .last_submitted_deposit() + .map(|entry| LastDeposit { + tx_id: Some(Base58Hash { + value: entry.deposit.tx_id.to_base58(), + }), + name_first: Some(Base58Hash { + value: entry.deposit.name.first.to_base58(), + }), + name_last: Some(Base58Hash { + value: entry.deposit.name.last.to_base58(), + }), + recipient: Some(EthAddressProto { + value: format!("0x{}", hex_encode(entry.deposit.recipient.0)), + }), + amount: entry.deposit.amount, + block_height: entry.deposit.block_height, + as_of: Some(Base58Hash { + value: entry.deposit.as_of.to_base58(), + }), + nonce: entry.deposit.nonce, + base_tx_hash: entry.base_tx_hash, + base_block_number: entry.base_block_number, + }); + + let last_deposit_nonce = self.bridge_status.last_deposit_nonce(); + let last_successful_deposit = match last_deposit_nonce { + Some(nonce) => match self + .deposit_log + .get_by_nonce(nonce, &self.nonce_epoch) + .await + { + Ok(Some(entry)) => Some(SuccessfulDeposit { + tx_id: Some(Base58Hash { + value: entry.tx_id.to_base58(), + }), + name_first: Some(Base58Hash { + value: entry.name.first.to_base58(), + }), + name_last: Some(Base58Hash { + value: entry.name.last.to_base58(), + }), + recipient: Some(EthAddressProto { + value: format!("0x{}", hex_encode(entry.recipient.0)), + }), + amount: entry.amount_to_mint, + block_height: entry.block_height, + as_of: Some(Base58Hash { + value: entry.as_of.to_base58(), + }), + nonce, + }), + Ok(None) => None, + Err(err) => { + warn!( + target: "bridge.status", + error=%err, + nonce, + "failed to load last successful deposit from log" + ); + None + } + }, + None => None, + }; + + Ok(Response::new(GetStatusResponse { + running_state: running_state as i32, + nock_hold: network.nock_hold, + base_hold: network.base_hold, + nock_hold_height: if network.nock_hold { + network.nock_hold_height + } else { + None + }, + base_hold_height: if network.base_hold { + network.base_hold_height + } else { + None + }, + nock_height, + base_height, + last_submitted_deposit, + last_successful_deposit, + })) + } +} diff --git a/crates/bridge/src/stop.rs b/crates/bridge/src/stop.rs new file mode 100644 index 000000000..f97bfd2d4 --- /dev/null +++ b/crates/bridge/src/stop.rs @@ -0,0 +1,205 @@ +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use tokio::sync::watch; + +use crate::bridge_status::BridgeStatus; +use crate::tui::types::AlertSeverity; +use crate::types::StopLastBlocks; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StopSource { + KernelEffect, + PeerBroadcast, + Local, +} + +#[derive(Clone, Debug)] +pub struct StopInfo { + pub reason: String, + pub last: Option, + pub source: StopSource, + pub at: SystemTime, +} + +#[derive(Clone)] +pub struct StopController { + tx: watch::Sender>, +} + +#[derive(Clone)] +pub struct StopHandle { + rx: watch::Receiver>, +} + +impl StopController { + pub fn new() -> (Self, StopHandle) { + let (tx, rx) = watch::channel(None); + (Self { tx }, StopHandle { rx }) + } + + pub fn handle(&self) -> StopHandle { + StopHandle { + rx: self.tx.subscribe(), + } + } + + /// Returns `true` if this call transitioned the controller into the stopped state. + pub fn trigger(&self, info: StopInfo) -> bool { + if self.tx.borrow().is_some() { + return false; + } + let _ = self.tx.send_replace(Some(info)); + true + } + + pub fn clear(&self) { + let _ = self.tx.send_replace(None); + } +} + +impl StopHandle { + pub fn is_stopped(&self) -> bool { + self.rx.borrow().is_some() + } + + pub fn info(&self) -> Option { + self.rx.borrow().clone() + } +} + +pub(crate) async fn trigger_local_stop( + runtime: Arc, + stop_controller: StopController, + bridge_status: BridgeStatus, + reason: String, +) { + use tracing::warn; + + let last = match runtime.peek_stop_info().await { + Ok(v) => v, + Err(err) => { + warn!( + target: "bridge.stop", + error=%err, + "failed to peek stop-info while triggering local stop" + ); + None + } + }; + + let info = StopInfo { + reason: reason.clone(), + last: last.clone(), + source: StopSource::Local, + at: SystemTime::now(), + }; + + if !stop_controller.trigger(info) { + return; + } + + bridge_status.push_alert( + AlertSeverity::Error, + "Bridge Stopped".to_string(), + reason.clone(), + "local-stop".to_string(), + ); + + if let Some(last) = last { + if let Err(err) = runtime.send_stop(last).await { + warn!( + target: "bridge.stop", + error=%err, + "failed to poke kernel with stop cause after local stop trigger" + ); + } + } +} + +/// Driver for stop effects, propagates stop state locally and to peers. +pub fn create_stop_driver( + runtime: Arc, + stop_controller: StopController, + bridge_status: BridgeStatus, + peers: Vec, + self_node_id: u64, +) -> nockapp::driver::IODriverFn { + use nockapp::driver::{make_driver, NockAppHandle}; + use noun_serde::NounDecode; + use tracing::warn; + + use crate::ingress::proto::StopBroadcast; + use crate::ingress::spawn_broadcast_stop_to_peers; + use crate::types::{BridgeEffect, BridgeEffectVariant}; + + make_driver(move |handle: NockAppHandle| { + let runtime = runtime.clone(); + let stop_controller = stop_controller.clone(); + let bridge_status = bridge_status.clone(); + let peers = peers.clone(); + + async move { + loop { + let effect = match handle.next_effect().await { + Ok(effect) => effect, + Err(_) => continue, + }; + + let root = unsafe { effect.root() }; + let bridge_effect = match BridgeEffect::from_noun(root) { + Ok(effect) => effect, + Err(_) => continue, + }; + + let BridgeEffectVariant::Stop(data) = bridge_effect.variant else { + continue; + }; + + let stop_info = StopInfo { + reason: data.reason.clone(), + last: Some(data.last.clone()), + source: StopSource::KernelEffect, + at: SystemTime::now(), + }; + + if !stop_controller.trigger(stop_info) { + continue; + } + + bridge_status.push_alert( + AlertSeverity::Error, + "Bridge Stopped".to_string(), + data.reason.clone(), + "kernel-stop".to_string(), + ); + // Note: poking the kernel with a %stop cause does not cause a %stop effect to get emitted. + // a %stop effect CAUSES a corresponding stop poke, which sets the kernel state machine to STOP status. + if let Err(err) = runtime.send_stop(data.last.clone()).await { + warn!( + target: "bridge.stop", + error=%err, + "failed to poke kernel with stop cause" + ); + } + + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let msg = StopBroadcast { + sender_node_id: self_node_id, + reason: data.reason.clone(), + last_base_hash: Some(data.last.base.base_hash.to_be_limb_bytes().to_vec()), + last_base_height: Some(data.last.base.height), + last_nock_hash: Some(data.last.nock.nock_hash.to_be_limb_bytes().to_vec()), + last_nock_height: Some(data.last.nock.height), + timestamp, + }; + + spawn_broadcast_stop_to_peers(&peers, msg, "bridge.stop"); + } + } + }) +} diff --git a/crates/bridge/src/tui/clipboard.rs b/crates/bridge/src/tui/clipboard.rs new file mode 100644 index 000000000..4d2b671c3 --- /dev/null +++ b/crates/bridge/src/tui/clipboard.rs @@ -0,0 +1,72 @@ +//! Clipboard helper utilities for the TUI. + +/// Copy text to clipboard using OSC 52 escape sequence. +/// +/// This is terminal-agnostic and works across SSH, tmux, etc. +/// The terminal must support OSC 52 for this to work. +pub fn copy_to_clipboard(text: &str) -> std::io::Result<()> { + use std::io::Write; + + let encoded = base64_encode(text.as_bytes()); + let escape_sequence = format!("\x1b]52;c;{}\x07", encoded); + + std::io::stdout().write_all(escape_sequence.as_bytes())?; + std::io::stdout().flush()?; + + Ok(()) +} + +/// Base64 encode bytes (simple implementation). +fn base64_encode(input: &[u8]) -> String { + const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut result = String::new(); + let mut i = 0; + + while i + 2 < input.len() { + let b1 = input[i]; + let b2 = input[i + 1]; + let b3 = input[i + 2]; + + result.push(CHARS[(b1 >> 2) as usize] as char); + result.push(CHARS[(((b1 & 0x03) << 4) | (b2 >> 4)) as usize] as char); + result.push(CHARS[(((b2 & 0x0f) << 2) | (b3 >> 6)) as usize] as char); + result.push(CHARS[(b3 & 0x3f) as usize] as char); + + i += 3; + } + + // Handle remaining bytes + match input.len() - i { + 1 => { + let b1 = input[i]; + result.push(CHARS[(b1 >> 2) as usize] as char); + result.push(CHARS[((b1 & 0x03) << 4) as usize] as char); + result.push('='); + result.push('='); + } + 2 => { + let b1 = input[i]; + let b2 = input[i + 1]; + result.push(CHARS[(b1 >> 2) as usize] as char); + result.push(CHARS[(((b1 & 0x03) << 4) | (b2 >> 4)) as usize] as char); + result.push(CHARS[((b2 & 0x0f) << 2) as usize] as char); + result.push('='); + } + _ => {} + } + + result +} + +#[cfg(test)] +mod tests { + use super::base64_encode; + + #[test] + fn test_base64_encode() { + assert_eq!(base64_encode(b"hello"), "aGVsbG8="); + assert_eq!(base64_encode(b"test"), "dGVzdA=="); + assert_eq!(base64_encode(b"a"), "YQ=="); + assert_eq!(base64_encode(b"ab"), "YWI="); + } +} diff --git a/crates/bridge/src/tui/mod.rs b/crates/bridge/src/tui/mod.rs new file mode 100644 index 000000000..503a7028c --- /dev/null +++ b/crates/bridge/src/tui/mod.rs @@ -0,0 +1,1076 @@ +//! Bridge TUI - Terminal User Interface for bridge operators. +//! +//! This module provides a comprehensive dashboard for monitoring and +//! managing bridge operations, including: +//! +//! - Peer health monitoring +//! - Network state (chain heights, sync status) +//! - Proposal management (multi-sig coordination) +//! - Transaction activity (deposits/withdrawals) +//! - Alerts and notifications +//! +//! # Module Structure +//! +//! - `types`: Shared data types for all panels +//! - `state`: State management (TuiStatus, LocalUiState) +//! - `panels`: Individual panel components +//! - `widgets`: Reusable widget components +//! - `app`: Main application logic + +mod clipboard; +pub mod panels; +pub mod state; +pub mod types; +pub mod widgets; + +// Re-export commonly used items +use std::cmp::min; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant, SystemTime}; + +use chrono::Local; +use clap::ColorChoice; +use clipboard::copy_to_clipboard; +use crossterm::cursor::Show; +use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; +use crossterm::terminal::{disable_raw_mode, LeaveAlternateScreen}; +use crossterm::ExecutableCommand; +use nockapp::kernel::boot::Cli as BootCli; +use nu_ansi_term::Color; +use panels::{ + AlertPanel, DepositLogPanel, HealthPanel, NetworkStatePanel, ProposalPanel, TransactionPanel, +}; +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, ListState, Paragraph, TableState}; +use ratatui::Frame; +pub use state::{new_log_buffer, BridgeStatus, LocalUiState, LogBuffer, TuiStatus, LOG_CAPACITY}; +use tracing::Subscriber; +use tracing_appender::non_blocking::WorkerGuard; +use tracing_appender::rolling::{RollingFileAppender, Rotation}; +use tracing_subscriber::fmt::format::Writer; +use tracing_subscriber::fmt::writer::BoxMakeWriter; +use tracing_subscriber::fmt::{FmtContext, FormatEvent, FormatFields}; +use tracing_subscriber::prelude::*; +use tracing_subscriber::{fmt, EnvFilter}; +pub use types::{FocusedPanel, UiMode}; +use widgets::{HelpOverlay, StatusBar}; + +use crate::errors::BridgeError; +use crate::stop::StopHandle; + +const TICK_RATE: Duration = Duration::from_millis(500); + +/// Clean up log files older than retention_days to prevent unbounded disk usage. +/// +/// Only removes files matching the pattern `bridge.log*` to avoid accidentally +/// deleting unrelated files in the log directory. +pub fn cleanup_old_logs(log_dir: &Path, retention_days: u64) { + let retention = Duration::from_secs(retention_days * 24 * 60 * 60); + let now = match SystemTime::now().duration_since(SystemTime::UNIX_EPOCH) { + Ok(_) => SystemTime::now(), + Err(_) => { + tracing::warn!("System clock error, skipping log cleanup"); + return; + } + }; + + let entries = match std::fs::read_dir(log_dir) { + Ok(entries) => entries, + Err(e) => { + tracing::warn!("Failed to read log directory for cleanup: {}", e); + return; + } + }; + + for entry in entries.flatten() { + let path = entry.path(); + + // Only process bridge log files (bridge.log.YYYY-MM-DD pattern) + let is_bridge_log = path + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.starts_with("bridge.log")) + .unwrap_or(false); + + if !is_bridge_log { + continue; + } + + // Check file age + if let Ok(metadata) = path.metadata() { + if let Ok(modified) = metadata.modified() { + if let Ok(age) = now.duration_since(modified) { + if age > retention { + match std::fs::remove_file(&path) { + Ok(()) => { + tracing::info!("Removed old log file: {}", path.display()); + } + Err(e) => { + tracing::warn!( + "Failed to remove old log file {}: {}", + path.display(), + e + ); + } + } + } + } + } + } + } +} + +pub fn init_bridge_tracing( + cli: &BootCli, + logs: Option, + log_dir: PathBuf, + log_retention_days: usize, +) -> Result { + // Create log directory if it doesn't exist + std::fs::create_dir_all(&log_dir).map_err(|e| { + BridgeError::Config(format!( + "Failed to create log directory {}: {}", + log_dir.display(), + e + )) + })?; + + // Use RUST_LOG env var, default to "info" + let filter = EnvFilter::new(std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string())); + + // File appender with daily rotation using builder pattern (returns Result, doesn't panic) + let file_appender = RollingFileAppender::builder() + .rotation(Rotation::DAILY) + .filename_prefix("bridge") + .filename_suffix("log") + .max_log_files(log_retention_days) + .build(&log_dir) + .map_err(|e| BridgeError::Config(format!("Failed to create log file appender: {}", e)))?; + let (non_blocking_file, guard) = tracing_appender::non_blocking(file_appender); + + // File layer (no ANSI colors, plain format for log aggregation) + // Use compact format with ISO timestamps for easy parsing + let file_layer = fmt::layer() + .with_ansi(false) + .with_target(true) + .with_level(true) + .with_thread_ids(false) + .with_thread_names(false) + .with_file(false) + .with_line_number(false) + .with_writer(non_blocking_file); + + // Console/TUI layer (existing logic with colors) + let ansi = matches!(cli.color, ColorChoice::Always | ColorChoice::Auto); + let console_base_layer = fmt::layer().with_ansi(ansi).event_format(TuiFormatter); + let console_layer = if let Some(buffer) = logs { + let writer = TuiLogWriter::new(buffer); + console_base_layer + .with_writer(BoxMakeWriter::new(move || writer.clone())) + .boxed() + } else { + console_base_layer + .with_writer(BoxMakeWriter::new(std::io::stdout)) + .boxed() + }; + + // Combine both layers + tracing_subscriber::registry() + .with(filter) + .with(file_layer) + .with(console_layer) + .try_init() + .map_err(|e| BridgeError::Runtime(format!("failed to init tracing: {}", e)))?; + + Ok(guard) +} + +#[derive(Clone)] +struct TuiLogWriter { + sink: Arc, +} + +struct LogSink { + buffer: Mutex>, + logs: LogBuffer, +} + +impl TuiLogWriter { + fn new(logs: LogBuffer) -> Self { + Self { + sink: Arc::new(LogSink { + buffer: Mutex::new(Vec::new()), + logs, + }), + } + } + + fn push_line(&self, line: &str) { + if line.is_empty() { + return; + } + if let Ok(mut guard) = self.sink.logs.write() { + if guard.len() >= LOG_CAPACITY { + guard.pop_front(); + } + guard.push_back(line.to_string()); + } + } +} + +impl Write for TuiLogWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + let mut pending = self + .sink + .buffer + .lock() + .expect("Mutex poisoned - this should not happen"); + pending.extend_from_slice(buf); + while let Some(pos) = pending.iter().position(|b| *b == b'\n') { + let line_bytes: Vec = pending.drain(..=pos).collect(); + let line = String::from_utf8_lossy(&line_bytes[..line_bytes.len().saturating_sub(1)]); + self.push_line(line.trim_end()); + } + Ok(buf.len()) + } + + fn flush(&mut self) -> io::Result<()> { + let mut pending = self + .sink + .buffer + .lock() + .expect("Mutex poisoned - this should not happen"); + if !pending.is_empty() { + let line = String::from_utf8_lossy(&pending); + self.push_line(line.trim_end()); + pending.clear(); + } + Ok(()) + } +} + +struct TuiFormatter; + +impl FormatEvent for TuiFormatter +where + S: Subscriber + for<'a> tracing_subscriber::registry::LookupSpan<'a>, + N: for<'a> FormatFields<'a> + 'static, +{ + fn format_event( + &self, + ctx: &FmtContext<'_, S, N>, + mut writer: Writer<'_>, + event: &tracing::Event<'_>, + ) -> std::fmt::Result { + let level = event.metadata().level(); + let use_ansi = writer.has_ansi_escapes(); + + // Format level with colors using nu_ansi_term (same as tracing-subscriber internally) + let level_str = match *level { + tracing::Level::TRACE => "TRACE", + tracing::Level::DEBUG => "DEBUG", + tracing::Level::INFO => " INFO", + tracing::Level::WARN => " WARN", + tracing::Level::ERROR => "ERROR", + }; + + let timestamp = Local::now().format("%H:%M:%S").to_string(); + + let target = event.metadata().target(); + let simplified = target + .rsplit("::") + .take(2) + .collect::>() + .into_iter() + .rev() + .collect::>() + .join("::"); + + if use_ansi { + let colored_timestamp = Color::Fixed(8).paint(×tamp); // dim gray + let colored_level = match *level { + tracing::Level::TRACE => Color::Purple.paint(level_str), + tracing::Level::DEBUG => Color::Blue.paint(level_str), + tracing::Level::INFO => Color::Green.paint(level_str), + tracing::Level::WARN => Color::Yellow.paint(level_str), + tracing::Level::ERROR => Color::Red.paint(level_str), + }; + let colored_target = Color::Cyan.paint(&simplified); + write!( + writer, + "{} {} {}: ", + colored_timestamp, colored_level, colored_target + )?; + } else { + write!(writer, "{} {} {}: ", timestamp, level_str, simplified)?; + } + + ctx.field_format().format_fields(writer.by_ref(), event)?; + writeln!(writer) + } +} + +/// Guard that restores terminal state on drop. +/// +/// This ensures the terminal is properly restored even if: +/// - The TUI task is cancelled (another task in tokio::select! completes) +/// - A panic occurs in the render loop +/// - An error causes early return +struct TerminalGuard; + +impl Drop for TerminalGuard { + fn drop(&mut self) { + let _ = disable_raw_mode(); + let _ = std::io::stdout().execute(LeaveAlternateScreen); + let _ = std::io::stdout().execute(Show); + } +} + +/// Install panic hook that restores terminal before printing panic message. +/// +/// This should be called BEFORE ratatui::init() to ensure panics in any +/// thread don't leave the terminal in a corrupted state. +fn install_panic_hook() { + let original_hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |panic_info| { + let _ = disable_raw_mode(); + let _ = std::io::stdout().execute(LeaveAlternateScreen); + let _ = std::io::stdout().execute(Show); + original_hook(panic_info); + })); +} + +/// Run the TUI with the shared state. +/// +/// The `bridge_status` must be the same instance that background tasks update, +/// otherwise the TUI won't see any data changes. +/// +/// The `shutdown` flag allows graceful shutdown when other tasks complete. +/// Set it to `true` to signal the TUI to exit cleanly. +pub async fn run_tui( + bridge_status: TuiStatus, + shutdown: Arc, + stop: StopHandle, +) -> Result<(), BridgeError> { + tokio::task::spawn_blocking(move || render_loop(bridge_status, shutdown, stop)) + .await + .map_err(|err| BridgeError::Runtime(format!("tui task join error: {}", err)))? +} + +fn render_loop( + state: TuiStatus, + shutdown: Arc, + stop: StopHandle, +) -> Result<(), BridgeError> { + install_panic_hook(); + let mut terminal = ratatui::init(); + let _guard = TerminalGuard; + + let mut app = BridgeTuiApp::new(state, stop); + let mut last_tick = Instant::now(); + + loop { + if shutdown.load(Ordering::Relaxed) { + break; + } + + terminal + .draw(|frame| app.draw(frame)) + .map_err(|err| BridgeError::Runtime(format!("tui draw error: {}", err)))?; + + let timeout = TICK_RATE + .checked_sub(last_tick.elapsed()) + .unwrap_or_default(); + if event::poll(timeout).map_err(|err| BridgeError::Runtime(err.to_string()))? { + if let Event::Key(key) = event::read() + .map_err(|err| BridgeError::Runtime(format!("tui key error: {}", err)))? + { + if key.kind == KeyEventKind::Press && app.handle_key(key) { + break; + } + } + } + if last_tick.elapsed() >= TICK_RATE { + app.tick(); + last_tick = Instant::now(); + } + } + + Ok(()) +} + +/// Main TUI application. +struct BridgeTuiApp { + /// Shared state from background tasks. + shared: TuiStatus, + /// Local UI state. + ui: LocalUiState, + /// Stop state handle. + stop: StopHandle, + /// Health table state. + health_table: TableState, + /// Transaction table state. + tx_table: TableState, + /// Proposal list state. + proposal_list: ListState, + /// Alert table state. + alert_table: TableState, + /// Deposit log table state. + deposit_log_table: TableState, + /// Transaction detail view (index of expanded transaction). + tx_detail_view: Option, + /// Proposal detail view (index of expanded proposal). + proposal_detail_view: Option, +} + +impl BridgeTuiApp { + fn new(shared: TuiStatus, stop: StopHandle) -> Self { + Self { + shared, + ui: LocalUiState::new(), + stop, + health_table: TableState::default(), + tx_table: TableState::default(), + proposal_list: ListState::default(), + alert_table: TableState::default(), + deposit_log_table: TableState::default(), + tx_detail_view: None, + proposal_detail_view: None, + } + } + + fn tick(&mut self) { + // Check and clear expired status messages + self.shared.check_and_clear_expired_status(); + + if self.ui.focused_panel != self.ui.last_focused_panel { + if matches!(self.ui.focused_panel, FocusedPanel::DepositLog) { + self.ui.deposit_log_jump_pending = true; + } + self.shared + .set_deposit_log_active(matches!(self.ui.focused_panel, FocusedPanel::DepositLog)); + self.ui.last_focused_panel = self.ui.focused_panel; + } + + // Update selection bounds for all panels + + // Health table + let health_len = self.shared.health_snapshots().len(); + if health_len == 0 { + self.health_table.select(None); + } else if self.health_table.selected().is_none() { + self.health_table.select(Some(0)); + } else if let Some(selected) = self.health_table.selected() { + self.health_table + .select(Some(min(selected, health_len.saturating_sub(1)))); + } + + // Transaction table + let tx_len = self.shared.transactions().transactions.len(); + if tx_len == 0 { + self.tx_table.select(None); + } else if self.tx_table.selected().is_none() { + self.tx_table.select(Some(0)); + } else if let Some(selected) = self.tx_table.selected() { + self.tx_table + .select(Some(min(selected, tx_len.saturating_sub(1)))); + } + + // Proposal list + let proposal_len = self.shared.proposals().history.len(); + if proposal_len == 0 { + self.proposal_list.select(None); + } else if self.proposal_list.selected().is_none() { + self.proposal_list.select(Some(0)); + } else if let Some(selected) = self.proposal_list.selected() { + self.proposal_list + .select(Some(min(selected, proposal_len.saturating_sub(1)))); + } + + // Alert table + let alert_len = self.shared.alerts().alerts.len(); + if alert_len == 0 { + self.alert_table.select(None); + } else if self.alert_table.selected().is_none() { + self.alert_table.select(Some(0)); + } else if let Some(selected) = self.alert_table.selected() { + self.alert_table + .select(Some(min(selected, alert_len.saturating_sub(1)))); + } + + // Deposit log table + let deposit_len = self.shared.deposit_log_snapshot().rows.len(); + if deposit_len == 0 { + self.deposit_log_table.select(None); + } else if self.deposit_log_table.selected().is_none() { + self.deposit_log_table.select(Some(0)); + } else if let Some(selected) = self.deposit_log_table.selected() { + self.deposit_log_table + .select(Some(min(selected, deposit_len.saturating_sub(1)))); + } + } + + fn draw(&mut self, frame: &mut Frame) { + // Layout: header, body, footer. + let constraints = [ + Constraint::Length(3), // Header + Constraint::Min(8), // Main body + Constraint::Length(3), // Footer/status bar + ]; + + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints(constraints) + .split(frame.area()); + + let header_area = chunks[0]; + let body_area = chunks[1]; + let footer_area = chunks[2]; + + // Force clear on layout changes + if self.ui.force_redraw { + frame.render_widget(Clear, frame.area()); + self.ui.force_redraw = false; + } + + // Draw components + self.draw_header(frame, header_area); + self.draw_body(frame, body_area); + self.draw_footer(frame, footer_area); + + // Draw overlays + if self.ui.mode == UiMode::Help { + HelpOverlay::draw(frame); + } + } + + fn draw_header(&self, frame: &mut Frame, area: Rect) { + let snapshots = self.shared.health_snapshots(); + let healthy = snapshots + .iter() + .filter(|s| matches!(s.status, crate::health::NodeHealthStatus::Healthy)) + .count(); + + let network = self.shared.network(); + let last_nonce = self.shared.last_deposit_nonce(); + let alerts = self.shared.alerts(); + let alert_count = alerts.alerts.len(); + + // Build title with alert indicator if there are alerts (no count). + let title = if alert_count > 0 { + let severity = alerts + .highest_severity() + .unwrap_or(crate::tui::types::AlertSeverity::Info); + let (indicator, style) = match severity { + crate::tui::types::AlertSeverity::Critical => { + ("◉", Style::new().light_red().bold()) + } + crate::tui::types::AlertSeverity::Error => ("●", Style::new().light_red()), + crate::tui::types::AlertSeverity::Warning => ("●", Style::new().light_yellow()), + crate::tui::types::AlertSeverity::Info => ("●", Style::new().light_blue()), + }; + Line::from(vec![ + Span::raw("bridge health "), + Span::styled(indicator, style), + ]) + } else { + Line::from("bridge health") + }; + + let block = Block::default().title(title).borders(Borders::ALL); + let inner = block.inner(area); + frame.render_widget(block, area); + + // Draw network mode indicator in top-right corner + self.draw_network_mode_indicator(frame, area, &network); + + // Check for degradation warning + let has_degradation = network.degradation_warning.is_some() || healthy < 4; + + // Determine the info area and optionally draw degradation banner + let info_area = if has_degradation { + // Split header: degradation banner (top), then health/network info (bottom) + let header_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // Degradation banner + Constraint::Min(1), // Health summary and network state + ]) + .split(inner); + + // Draw degradation banner + let warning_msg = network + .degradation_warning + .as_deref() + .unwrap_or("⚠ DEGRADED MODE: <4 healthy nodes - proposals may be delayed"); + let banner = Paragraph::new(Line::from(vec![Span::styled( + warning_msg, + Style::new().light_red().bold(), + )])); + frame.render_widget(banner, header_layout[0]); + + header_layout[1] + } else { + inner + }; + + // Split header into health summary and network state + let info_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(40), Constraint::Percentage(60)]) + .split(info_area); + + // Health summary + let last_nonce_text = last_nonce + .map(|n| n.to_string()) + .unwrap_or_else(|| "n/a".to_string()); + let health_line = Line::from(vec![ + Span::styled( + format!("nodes: {}", snapshots.len()), + Style::new().light_cyan(), + ), + Span::raw(" "), + Span::styled(format!("healthy: {}", healthy), Style::new().light_green()), + Span::raw(" "), + Span::styled( + format!("last nonce: {}", last_nonce_text), + Style::new().light_cyan(), + ), + ]); + frame.render_widget(Paragraph::new(health_line), info_chunks[0]); + + // Network state compact view + let is_stopped = self.stop.is_stopped() || network.kernel_stopped; + NetworkStatePanel::draw_compact(frame, info_chunks[1], &network, is_stopped); + } + + /// Draw network mode indicator (mainnet/fakenet) in top-right corner of header. + fn draw_network_mode_indicator( + &self, + frame: &mut Frame, + header_area: Rect, + network: &types::NetworkState, + ) { + let (label, bg_color) = match network.is_mainnet { + Some(true) => (" MAINNET ", ratatui::style::Color::Green), + Some(false) => (" FAKENET ", ratatui::style::Color::Rgb(255, 165, 0)), // Orange + None => (" ??? ", ratatui::style::Color::DarkGray), + }; + + let indicator_width = label.len() as u16; + // Position in top-right corner, inside the border (offset by 2 for border + padding) + let x = header_area + .x + .saturating_add(header_area.width) + .saturating_sub(indicator_width + 2); + let y = header_area.y; + + let indicator_area = Rect::new(x, y, indicator_width, 1); + + let indicator = Paragraph::new(Span::styled( + label, + Style::new().fg(ratatui::style::Color::Black).bg(bg_color), + )); + frame.render_widget(indicator, indicator_area); + } + + fn draw_body(&mut self, frame: &mut Frame, area: Rect) { + // Single panel view based on focused_panel + match self.ui.focused_panel { + FocusedPanel::Health => { + self.draw_health_and_network(frame, area, true); + } + FocusedPanel::Proposals => { + let proposal_state = self.shared.proposals(); + ProposalPanel::draw_with_detail( + frame, area, &proposal_state, &mut self.proposal_list, true, + self.proposal_detail_view, + ); + } + FocusedPanel::Transactions => { + let tx_state = self.shared.transactions(); + // Convert VecDeque to slice for drawing + let txs: Vec<_> = tx_state.transactions.iter().cloned().collect(); + TransactionPanel::draw( + frame, area, &txs, &mut self.tx_table, true, self.tx_detail_view, + ); + } + FocusedPanel::Alerts => { + let alerts = self.shared.alerts(); + AlertPanel::draw(frame, area, &alerts, &mut self.alert_table, true); + } + FocusedPanel::DepositLog => { + self.update_deposit_log_limit(area); + let snapshot = self.shared.deposit_log_snapshot(); + let last_nonce = self.shared.last_deposit_nonce(); + self.maybe_jump_to_last_nonce(&snapshot, last_nonce); + DepositLogPanel::draw( + frame, area, &snapshot, last_nonce, &mut self.deposit_log_table, true, + ); + } + } + } + + fn draw_health_and_network(&mut self, frame: &mut Frame, area: Rect, is_focused: bool) { + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(55), Constraint::Percentage(45)]) + .split(area); + + let snapshots = self.shared.health_snapshots(); + HealthPanel::draw( + frame, chunks[0], &snapshots, &mut self.health_table, is_focused, + ); + + let network = self.shared.network(); + let is_stopped = self.stop.is_stopped() || network.kernel_stopped; + NetworkStatePanel::draw(frame, chunks[1], &network, is_focused, is_stopped); + } + + fn draw_footer(&self, frame: &mut Frame, area: Rect) { + let status_msg = self.shared.status_message().map(|(text, _)| text); + StatusBar::draw_full_with_status( + frame, area, self.ui.mode, self.ui.focused_panel, status_msg, + ); + } + + fn handle_key(&mut self, key: KeyEvent) -> bool { + // Handle mode-specific keys first + if self.ui.mode == UiMode::Help { + // Any key closes help + self.ui.mode = UiMode::Normal; + self.ui.force_redraw = true; + return false; + } + + // Normal mode keys + match key.code { + KeyCode::Char('q') => true, + KeyCode::Esc => { + // Close detail view if open + if self.tx_detail_view.is_some() { + self.tx_detail_view = None; + } else if self.proposal_detail_view.is_some() { + self.proposal_detail_view = None; + } + false + } + KeyCode::Char('?') => { + self.ui.toggle_help(); + false + } + KeyCode::Char('r') => { + self.tick(); + false + } + // Panel shortcuts + KeyCode::Char('p') => { + self.ui.focused_panel = FocusedPanel::Proposals; + false + } + KeyCode::Char('t') => { + self.ui.focused_panel = FocusedPanel::Transactions; + false + } + KeyCode::Char('a') => { + self.ui.focused_panel = FocusedPanel::Alerts; + false + } + KeyCode::Char('d') => { + self.ui.focused_panel = FocusedPanel::DepositLog; + self.ui.deposit_log_jump_pending = true; + false + } + // Number shortcuts for direct panel access + KeyCode::Char('1') => { + self.ui.focused_panel = FocusedPanel::Health; + false + } + KeyCode::Char('2') => { + self.ui.focused_panel = FocusedPanel::DepositLog; + self.ui.deposit_log_jump_pending = true; + false + } + KeyCode::Char('3') => { + self.ui.focused_panel = FocusedPanel::Proposals; + false + } + KeyCode::Char('4') => { + self.ui.focused_panel = FocusedPanel::Transactions; + false + } + KeyCode::Char('5') => { + self.ui.focused_panel = FocusedPanel::Alerts; + false + } + // Panel-specific actions + KeyCode::Enter => { + if matches!(self.ui.focused_panel, FocusedPanel::Transactions) { + self.toggle_tx_detail(); + } else if matches!(self.ui.focused_panel, FocusedPanel::Proposals) { + self.toggle_proposal_detail(); + } + false + } + KeyCode::Char('y') => { + if matches!(self.ui.focused_panel, FocusedPanel::Transactions) { + self.copy_selected_tx_hash(); + } else if matches!(self.ui.focused_panel, FocusedPanel::DepositLog) { + self.copy_current_deposit_log_entry(); + } + false + } + // Navigation + KeyCode::Tab => { + self.ui.focus_next(); + false + } + KeyCode::BackTab => { + self.ui.focus_prev(); + false + } + KeyCode::Left | KeyCode::Char('h') => { + self.ui.focus_prev(); + false + } + KeyCode::Right | KeyCode::Char('l') => { + self.ui.focus_next(); + false + } + KeyCode::Down | KeyCode::Char('j') => { + self.handle_down(); + false + } + KeyCode::Up | KeyCode::Char('k') => { + self.handle_up(); + false + } + KeyCode::Char('J') => { + if matches!(self.ui.focused_panel, FocusedPanel::DepositLog) { + for _ in 0..5 { + self.move_deposit_log_selection(1); + } + } + false + } + KeyCode::Char('K') => { + if matches!(self.ui.focused_panel, FocusedPanel::DepositLog) { + for _ in 0..5 { + self.move_deposit_log_selection(-1); + } + } + false + } + _ => false, + } + } + + fn handle_down(&mut self) { + match self.ui.focused_panel { + FocusedPanel::Health => { + let len = self.shared.health_snapshots().len(); + if len == 0 { + self.health_table.select(None); + } else { + let next = self + .health_table + .selected() + .map(|idx| min(idx + 1, len.saturating_sub(1))) + .unwrap_or(0); + self.health_table.select(Some(next)); + } + } + FocusedPanel::Transactions => { + let len = self.shared.transactions().transactions.len(); + if len == 0 { + self.tx_table.select(None); + } else { + let next = self + .tx_table + .selected() + .map(|idx| min(idx + 1, len.saturating_sub(1))) + .unwrap_or(0); + self.tx_table.select(Some(next)); + } + } + FocusedPanel::Proposals => { + let len = self.shared.proposals().history.len(); + if len == 0 { + self.proposal_list.select(None); + } else { + let next = self + .proposal_list + .selected() + .map(|idx| min(idx + 1, len.saturating_sub(1))) + .unwrap_or(0); + self.proposal_list.select(Some(next)); + } + } + FocusedPanel::Alerts => { + let len = self.shared.alerts().alerts.len(); + if len == 0 { + self.alert_table.select(None); + } else { + let next = self + .alert_table + .selected() + .map(|idx| min(idx + 1, len.saturating_sub(1))) + .unwrap_or(0); + self.alert_table.select(Some(next)); + } + } + FocusedPanel::DepositLog => { + self.move_deposit_log_selection(1); + } + } + } + + fn handle_up(&mut self) { + match self.ui.focused_panel { + FocusedPanel::Health => { + if let Some(idx) = self.health_table.selected() { + self.health_table.select(Some(idx.saturating_sub(1))); + } + } + FocusedPanel::Transactions => { + if let Some(idx) = self.tx_table.selected() { + self.tx_table.select(Some(idx.saturating_sub(1))); + } + } + FocusedPanel::Proposals => { + if let Some(idx) = self.proposal_list.selected() { + self.proposal_list.select(Some(idx.saturating_sub(1))); + } + } + FocusedPanel::Alerts => { + if let Some(idx) = self.alert_table.selected() { + self.alert_table.select(Some(idx.saturating_sub(1))); + } + } + FocusedPanel::DepositLog => { + self.move_deposit_log_selection(-1); + } + } + } + + fn maybe_jump_to_last_nonce( + &mut self, + _snapshot: &crate::tui::types::DepositLogSnapshot, + _last_nonce: Option, + ) { + if self.ui.deposit_log_jump_pending { + self.ui.deposit_log_jump_pending = false; + } + } + + fn toggle_proposal_detail(&mut self) { + // Toggle detail view for selected proposal + if let Some(idx) = self.proposal_list.selected() { + if self.proposal_detail_view == Some(idx) { + // Close detail view if already viewing this proposal + self.proposal_detail_view = None; + } else { + // Open detail view for this proposal + self.proposal_detail_view = Some(idx); + } + } + } + + fn toggle_tx_detail(&mut self) { + // Toggle detail view for selected transaction + if let Some(idx) = self.tx_table.selected() { + if self.tx_detail_view == Some(idx) { + // Close detail view if already viewing this transaction + self.tx_detail_view = None; + } else { + // Open detail view for this transaction + self.tx_detail_view = Some(idx); + } + } + } + + fn copy_current_deposit_log_entry(&mut self) { + let snapshot = self.shared.deposit_log_snapshot(); + let Some(selected) = self.deposit_log_table.selected() else { + return; + }; + let Some(row) = snapshot.rows.get(selected) else { + return; + }; + let entry = format!( + "nonce={} height={} amount={} recipient={} tx_id={}", + row.nonce, row.block_height, row.amount, row.recipient_hex, row.tx_id_base58 + ); + if let Err(e) = copy_to_clipboard(&entry) { + tracing::warn!("Failed to copy deposit log entry: {}", e); + self.shared + .set_status_message("Failed to copy to clipboard".to_string(), 3); + } else { + tracing::debug!("Copied deposit log entry: {}", entry); + self.shared + .set_status_message("Copied to clipboard!".to_string(), 3); + } + } + + fn copy_selected_tx_hash(&mut self) { + // Copy selected transaction hash to clipboard using OSC 52 + if let Some(idx) = self.tx_table.selected() { + let txs = self.shared.transactions().transactions; + if let Some(tx) = txs.get(idx) { + // Use OSC 52 escape sequence (terminal-agnostic, works over SSH/tmux) + if let Err(e) = copy_to_clipboard(&tx.tx_hash) { + tracing::warn!("Failed to copy tx hash to clipboard: {}", e); + // Show error status message + self.shared + .set_status_message("Failed to copy to clipboard".to_string(), 3); + } else { + tracing::debug!("Copied tx hash to clipboard: {}", tx.tx_hash); + // Show success status message (3 seconds) + self.shared + .set_status_message("Copied to clipboard!".to_string(), 3); + } + } + } + } + + fn move_deposit_log_selection(&mut self, delta: i32) { + let snapshot = self.shared.deposit_log_snapshot(); + let row_count = snapshot.rows.len(); + if row_count == 0 { + self.deposit_log_table.select(None); + return; + } + + let selected = self.deposit_log_table.selected().unwrap_or(0); + if delta.is_positive() { + if selected + 1 < row_count { + self.deposit_log_table.select(Some(selected + 1)); + } else { + self.deposit_log_table + .select(Some(row_count.saturating_sub(1))); + } + } else if delta.is_negative() { + if selected > 0 { + self.deposit_log_table.select(Some(selected - 1)); + } else { + self.deposit_log_table.select(Some(0)); + } + } + } + + fn update_deposit_log_limit(&self, area: Rect) { + let inner_height = area.height.saturating_sub(2); + let limit = inner_height.saturating_sub(1) as usize; + let mut view = self.shared.deposit_log_view(); + if view.limit != limit || view.offset != 0 { + view.limit = limit; + view.offset = 0; + self.shared.set_deposit_log_view(view); + } + } +} diff --git a/crates/bridge/src/tui/panels/alerts.rs b/crates/bridge/src/tui/panels/alerts.rs new file mode 100644 index 000000000..24ee43c78 --- /dev/null +++ b/crates/bridge/src/tui/panels/alerts.rs @@ -0,0 +1,105 @@ +//! Alerts panel - displays recent alerts. +//! +//! Shows a list of alerts with severity indicators and timestamps. + +use ratatui::layout::{Constraint, Rect}; +use ratatui::style::{Style, Stylize}; +use ratatui::widgets::{Block, Borders, Cell, Row, Table, TableState}; +use ratatui::Frame; + +use crate::tui::types::{Alert, AlertSeverity, AlertState}; + +/// Alerts panel for displaying alerts. +pub struct AlertPanel; + +impl AlertPanel { + /// Draw the alerts panel showing alerts. + pub fn draw( + frame: &mut Frame, + area: Rect, + alert_state: &AlertState, + table_state: &mut TableState, + is_focused: bool, + ) { + let mut rows = Vec::new(); + + for alert in &alert_state.alerts { + rows.push(Self::make_row(alert)); + } + + let border_style = if is_focused { + Style::new().light_cyan() + } else { + Style::default() + }; + + let title = format!("alerts ({})", alert_state.alerts.len()); + + let table = Table::new( + rows, + [ + Constraint::Length(3), // severity icon + Constraint::Length(22), // title + Constraint::Min(28), // message + Constraint::Length(15), // timestamp + Constraint::Length(10), // source + ], + ) + .header(Row::new(vec!["", "title", "message", "time", "source"]).style(Style::new().bold())) + .block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(border_style), + ) + .row_highlight_style(Style::new().reversed()) + .highlight_symbol("➤ "); + + frame.render_stateful_widget(table, area, table_state); + } + + fn make_row(alert: &Alert) -> Row<'static> { + let severity_style = match alert.severity { + AlertSeverity::Critical => Style::new().light_red().bold(), + AlertSeverity::Error => Style::new().light_red(), + AlertSeverity::Warning => Style::new().light_yellow(), + AlertSeverity::Info => Style::new().light_cyan(), + }; + + let severity_cell = Cell::from(alert.severity.symbol()).style(severity_style); + + let title_cell = Cell::from(alert.title.clone()).style(severity_style); + + let message_cell = Cell::from(alert.message.clone()); + + let time_str = Self::format_timestamp(alert.timestamp); + let time_cell = Cell::from(time_str).style(Style::new().dark_gray()); + + let source_cell = Cell::from(alert.source.clone()).style(Style::new().dark_gray()); + + Row::new(vec![ + severity_cell, title_cell, message_cell, time_cell, source_cell, + ]) + .style(severity_style) + } + + fn format_timestamp(time: std::time::SystemTime) -> String { + match time.elapsed() { + Ok(duration) => { + let secs = duration.as_secs(); + if secs == 0 { + "just now".into() + } else if secs < 60 { + format!("{}s ago", secs) + } else if secs < 3600 { + format!("{}m ago", secs / 60) + } else if secs < 86400 { + format!("{}h ago", secs / 3600) + } else { + format!("{}d ago", secs / 86400) + } + } + Err(_) => "–".into(), + } + } +} diff --git a/crates/bridge/src/tui/panels/deposit_log.rs b/crates/bridge/src/tui/panels/deposit_log.rs new file mode 100644 index 000000000..f4a7a61c8 --- /dev/null +++ b/crates/bridge/src/tui/panels/deposit_log.rs @@ -0,0 +1,132 @@ +//! Deposit log panel - displays recent deposits from the SQLite log. + +use ratatui::layout::{Constraint, Rect}; +use ratatui::style::{Color, Style, Stylize}; +use ratatui::text::Line; +use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Table, TableState}; +use ratatui::Frame; + +use crate::tui::types::DepositLogSnapshot; + +/// Deposit log panel for displaying nonce-ordered deposits. +pub struct DepositLogPanel; + +impl DepositLogPanel { + /// Render the deposit log table for the current snapshot. + /// + /// The table is newest-first (offset handled by the snapshot builder) and highlights + /// the row matching `last_deposit_nonce` when available. + pub fn draw( + frame: &mut Frame, + area: Rect, + snapshot: &DepositLogSnapshot, + last_deposit_nonce: Option, + table_state: &mut TableState, + is_focused: bool, + ) { + // Use a highlight border when the panel is focused. + let border_style = if is_focused { + Style::new().light_cyan() + } else { + Style::default() + }; + + // Include paging metadata in the title when rows are available. + let title = if snapshot.total_count == 0 { + "deposit log".to_string() + } else { + let shown = snapshot.rows.len(); + if shown as u64 >= snapshot.total_count { + format!("deposit log (total {})", snapshot.total_count) + } else { + format!( + "deposit log (showing {} of {})", + shown, snapshot.total_count + ) + } + }; + + // Draw the outer block and derive the inner render area. + let block = Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(border_style); + let inner = block.inner(area); + frame.render_widget(block, area); + + // Show an empty-state hint if the log is empty. + if snapshot.total_count == 0 { + let empty = + Paragraph::new(Line::from("no deposits in log")).style(Style::new().dark_gray()); + frame.render_widget(empty, inner); + return; + } + + // Build table rows from the snapshot, truncating long hex fields. + let rows = snapshot.rows.iter().map(|row| { + let nonce = row.nonce.to_string(); + let height = row.block_height.to_string(); + let amount = row.amount.to_string(); + let recipient = truncate_middle(&row.recipient_hex, 18); + let tx_id = truncate_middle(&row.tx_id_base58, 28); + + let mut table_row = Row::new(vec![ + Cell::from(nonce), + Cell::from(height), + Cell::from(amount), + Cell::from(recipient), + Cell::from(tx_id), + ]); + + // Highlight the last confirmed nonce for quick visual scanning. + if Some(row.nonce) == last_deposit_nonce { + table_row = table_row.style(Style::new().fg(Color::Green).bold()); + } + + table_row + }); + + // Header labels keep the layout stable with narrow numeric columns. + let header = Row::new(vec![ + "nonce", "height", "amount", "recipient", "tx id (base58)", + ]) + .style(Style::new().bold()); + + // Render a compact table with a wider final column for tx ids. + let table = Table::new( + rows, + [ + Constraint::Length(8), + Constraint::Length(10), + Constraint::Length(12), + Constraint::Length(18), + Constraint::Min(20), + ], + ) + .header(header) + .column_spacing(1) + .row_highlight_style(Style::new().reversed()) + .highlight_symbol("> "); + + frame.render_stateful_widget(table, inner, table_state); + } +} + +/// Truncate a long string by keeping both ends with a middle ellipsis. +fn truncate_middle(value: &str, max_len: usize) -> String { + // Keep short values intact to avoid noisy truncation. + if value.len() <= max_len { + return value.to_string(); + } + // If the budget is tiny, just return the left slice. + if max_len <= 3 { + return value[..max_len].to_string(); + } + // Split the remaining space around a three-character ellipsis. + let keep = max_len - 3; + let left = keep / 2; + let right = keep - left; + let start = &value[..left]; + let end = &value[value.len().saturating_sub(right)..]; + format!("{}...{}", start, end) +} diff --git a/crates/bridge/src/tui/panels/health.rs b/crates/bridge/src/tui/panels/health.rs new file mode 100644 index 000000000..592f68998 --- /dev/null +++ b/crates/bridge/src/tui/panels/health.rs @@ -0,0 +1,104 @@ +//! Health panel - displays peer node health status. +//! +//! This is a migration of the existing health table from tui.rs +//! into the new modular panel structure. + +use ratatui::layout::Constraint; +use ratatui::style::{Color, Style, Stylize}; +use ratatui::widgets::{Block, Borders, Cell, Row, Table, TableState}; +use ratatui::Frame; + +use crate::health::{NodeHealthSnapshot, NodeHealthStatus}; + +/// Health panel for displaying peer status. +pub struct HealthPanel; + +impl HealthPanel { + /// Draw the health table. + pub fn draw( + frame: &mut Frame, + area: ratatui::layout::Rect, + snapshots: &[NodeHealthSnapshot], + table_state: &mut TableState, + is_focused: bool, + ) { + let rows = snapshots.iter().map(|snapshot| { + let status_cell = match &snapshot.status { + NodeHealthStatus::Healthy => { + Cell::from("healthy").style(Style::new().light_green()) + } + NodeHealthStatus::Unreachable { error } => { + Cell::from(error.clone()).style(Style::new().light_red()) + } + }; + let latency = snapshot + .latency_ms + .map(|ms| format!("{ms} ms")) + .unwrap_or_else(|| "—".into()); + let uptime = snapshot + .peer_uptime_ms + .map(|ms| format!("{:.1}s", ms as f64 / 1000.0)) + .unwrap_or_else(|| "—".into()); + let updated = format_relative(snapshot.last_updated); + + Row::new(vec![ + Cell::from(snapshot.node_id.to_string()), + Cell::from(snapshot.address.clone()), + status_cell, + Cell::from(latency), + Cell::from(uptime), + Cell::from(updated), + ]) + .style(row_style(&snapshot.status)) + }); + + let border_style = if is_focused { + Style::new().light_cyan() + } else { + Style::default() + }; + + let table = Table::new( + rows, + [ + Constraint::Length(8), + Constraint::Length(22), + Constraint::Length(18), + Constraint::Length(12), + Constraint::Length(12), + Constraint::Min(10), + ], + ) + .header( + Row::new(vec![ + "node", "address", "status", "latency", "uptime", "updated", + ]) + .style(Style::new().bold()), + ) + .block( + Block::default() + .title("peer status") + .borders(Borders::ALL) + .border_style(border_style), + ) + .row_highlight_style(Style::new().reversed()) + .highlight_symbol("➤ "); + + frame.render_stateful_widget(table, area, table_state); + } +} + +fn row_style(status: &NodeHealthStatus) -> Style { + match status { + NodeHealthStatus::Healthy => Style::new().fg(Color::Green), + NodeHealthStatus::Unreachable { .. } => Style::new().fg(Color::Red), + } +} + +fn format_relative(time: std::time::SystemTime) -> String { + match time.elapsed() { + Ok(duration) if duration.as_secs() > 0 => format!("{}s ago", duration.as_secs()), + Ok(_) => "just now".into(), + Err(_) => "–".into(), + } +} diff --git a/crates/bridge/src/tui/panels/mod.rs b/crates/bridge/src/tui/panels/mod.rs new file mode 100644 index 000000000..e771252af --- /dev/null +++ b/crates/bridge/src/tui/panels/mod.rs @@ -0,0 +1,18 @@ +//! TUI panel components. +//! +//! Each panel is a self-contained widget that renders a specific +//! aspect of the bridge state. + +pub mod alerts; +pub mod deposit_log; +pub mod health; +pub mod network_state; +pub mod proposals; +pub mod transactions; + +pub use alerts::AlertPanel; +pub use deposit_log::DepositLogPanel; +pub use health::HealthPanel; +pub use network_state::NetworkStatePanel; +pub use proposals::ProposalPanel; +pub use transactions::TransactionPanel; diff --git a/crates/bridge/src/tui/panels/network_state.rs b/crates/bridge/src/tui/panels/network_state.rs new file mode 100644 index 000000000..d26749723 --- /dev/null +++ b/crates/bridge/src/tui/panels/network_state.rs @@ -0,0 +1,492 @@ +//! Network state panel - displays chain heights and sync status. +//! +//! Shows Base chain height, Nockchain height, pending operations, +//! and batch processing status. + +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::Frame; + +use crate::tui::types::{BatchStatus, NetworkState, NockchainApiStatus}; + +/// Network state panel for displaying chain sync status. +pub struct NetworkStatePanel; + +impl NetworkStatePanel { + /// Draw the network state panel. + pub fn draw( + frame: &mut Frame, + area: Rect, + state: &NetworkState, + is_focused: bool, + is_stopped: bool, + ) { + let border_style = if is_focused { + Style::new().light_cyan() + } else { + Style::default() + }; + + let block = Block::default() + .title("network state") + .borders(Borders::ALL) + .border_style(border_style); + + let inner = block.inner(area); + frame.render_widget(block, area); + + // Split into two columns: chains and operations + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(inner); + + Self::draw_chains(frame, columns[0], state); + Self::draw_operations(frame, columns[1], state, is_stopped); + } + + fn draw_chains(frame: &mut Frame, area: Rect, state: &NetworkState) { + let mut lines = Vec::new(); + + // Base chain + lines.push(Line::from(vec![Span::styled( + "Base Chain", + Style::new().bold(), + )])); + lines.push(Line::from(vec![ + Span::raw(" height: "), + Span::styled(format!("{}", state.base.height), Style::new().light_green()), + if state.base.is_syncing { + Span::styled(" (syncing)", Style::new().light_yellow()) + } else { + Span::raw("") + }, + ])); + if !state.base.tip_hash.is_empty() { + let truncated = truncate_hash(&state.base.tip_hash, 16); + lines.push(Line::from(vec![ + Span::raw(" tip: "), + Span::styled(truncated, Style::new().light_cyan()), + ])); + } + if state.base.confirmations > 0 { + lines.push(Line::from(vec![ + Span::raw(" confirmations: "), + Span::styled( + format!("{}", state.base.confirmations), + Style::new().light_cyan(), + ), + ])); + } + if let Some(updated) = state.base.last_updated { + lines.push(Line::from(vec![ + Span::raw(" updated: "), + Span::styled(format_relative(updated), Style::new().dark_gray()), + ])); + } + + lines.push(Line::from("")); + + // Nockchain + lines.push(Line::from(vec![Span::styled( + "Nockchain", + Style::new().bold(), + )])); + lines.push(Line::from(vec![ + Span::raw(" height: "), + Span::styled( + format!("{}", state.nockchain.height), + Style::new().light_green(), + ), + if state.nockchain.is_syncing { + Span::styled(" (syncing)", Style::new().light_yellow()) + } else { + Span::raw("") + }, + ])); + if !state.nockchain.tip_hash.is_empty() { + let truncated = truncate_hash(&state.nockchain.tip_hash, 16); + lines.push(Line::from(vec![ + Span::raw(" tip: "), + Span::styled(truncated, Style::new().light_cyan()), + ])); + } + if let Some(updated) = state.nockchain.last_updated { + lines.push(Line::from(vec![ + Span::raw(" updated: "), + Span::styled(format_relative(updated), Style::new().dark_gray()), + ])); + } + + // Nockchain API status + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "Nockchain API", + Style::new().bold(), + )])); + + let (status_indicator, status_text, status_style) = match &state.nockchain_api_status { + NockchainApiStatus::Connected { .. } => ("●", "connected", Style::new().light_green()), + NockchainApiStatus::Connecting { attempt, .. } if *attempt == 0 => { + ("◐", "connecting...", Style::new().light_yellow()) + } + NockchainApiStatus::Connecting { attempt, .. } => ( + "◐", + &format!("reconnecting (attempt {})", attempt) as &str, + Style::new().light_yellow(), + ), + NockchainApiStatus::Disconnected { .. } => { + ("○", "disconnected", Style::new().light_red()) + } + }; + + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(status_indicator, status_style), + Span::raw(" "), + Span::styled(status_text.to_string(), status_style), + ])); + + // Show duration for connected/disconnected states + let duration = state.nockchain_api_status.duration(); + if duration.as_secs() > 0 { + let duration_text = if duration.as_secs() >= 3600 { + format!( + "{}h {}m", + duration.as_secs() / 3600, + (duration.as_secs() % 3600) / 60 + ) + } else if duration.as_secs() >= 60 { + format!("{}m {}s", duration.as_secs() / 60, duration.as_secs() % 60) + } else { + format!("{}s", duration.as_secs()) + }; + lines.push(Line::from(vec![ + Span::raw(" for "), + Span::styled(duration_text, Style::new().dark_gray()), + ])); + } + + // Show error if available + if let Some(error) = state.nockchain_api_status.last_error() { + let truncated = if error.len() > 30 { + &error[..30] + } else { + error + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(truncated.to_string(), Style::new().light_red().italic()), + ])); + } + + let paragraph = Paragraph::new(lines); + frame.render_widget(paragraph, area); + } + + fn draw_operations(frame: &mut Frame, area: Rect, state: &NetworkState, is_stopped: bool) { + let mut lines = Vec::new(); + + // Status indicator (stopped/hold/running) + let status = status_kind(state, is_stopped); + let hold_details = hold_status_details(state); + let (status_label, status_style, status_detail) = match status { + BridgeStatus::Stopped => ( + "STOPPED".to_string(), + Style::new().light_red().bold(), + if hold_details.is_empty() { + None + } else { + Some(hold_details.join(", ")) + }, + ), + BridgeStatus::BaseHold => { + let height = hold_height_label(state.base_hold_height); + ( + "BASE HOLD".to_string(), + Style::new().light_red().bold(), + Some(format!("nock@{height}")), + ) + } + BridgeStatus::NockHold => { + let height = hold_height_label(state.nock_hold_height); + ( + "NOCK HOLD".to_string(), + Style::new().light_red().bold(), + Some(format!("base@{height}")), + ) + } + BridgeStatus::Running => ( + "RUNNING".to_string(), + Style::new().light_green().bold(), + None, + ), + }; + + lines.push(Line::from(vec![Span::styled( + "Status", + Style::new().bold(), + )])); + let mut status_spans = vec![Span::raw(" "), Span::styled(status_label, status_style)]; + if let Some(detail) = status_detail { + let separator = if status == BridgeStatus::Stopped { + ", " + } else { + " " + }; + status_spans.push(Span::raw(separator)); + status_spans.push(Span::styled(detail, Style::new().dark_gray())); + } + lines.push(Line::from(status_spans)); + lines.push(Line::from("")); + + // Kernel state counts (detailed breakdown) + lines.push(Line::from(vec![Span::styled( + "Kernel State", + Style::new().bold(), + )])); + + // Deposits breakdown + let unsettled_dep_style = if state.unsettled_deposit_count > 0 { + Style::new().light_yellow() + } else { + Style::new().dark_gray() + }; + lines.push(Line::from(vec![ + Span::raw(" deposits: "), + Span::styled( + format!("{}", state.unsettled_deposit_count), + unsettled_dep_style, + ), + Span::styled(" unsettled", Style::new().dark_gray()), + ])); + + // Withdrawals breakdown + let unsettled_wd_style = if state.unsettled_withdrawal_count > 0 { + Style::new().light_yellow() + } else { + Style::new().dark_gray() + }; + lines.push(Line::from(vec![ + Span::raw(" withdrawals: "), + Span::styled( + format!("{}", state.unsettled_withdrawal_count), + unsettled_wd_style, + ), + Span::styled(" unsettled", Style::new().dark_gray()), + ])); + + lines.push(Line::from("")); + + // Batch status + lines.push(Line::from(vec![Span::styled( + "Batch Status", + Style::new().bold(), + )])); + + let (status_text, status_style) = match &state.batch_status { + BatchStatus::Idle => ("idle".to_string(), Style::new().dark_gray()), + BatchStatus::Processing { + batch_id, + progress_pct, + } => ( + format!("processing #{} ({}%)", batch_id, progress_pct), + Style::new().light_cyan(), + ), + BatchStatus::AwaitingSignatures { + batch_id, + collected, + required, + } => ( + format!("#{}: {}/{} signatures", batch_id, collected, required), + Style::new().light_yellow(), + ), + BatchStatus::Submitting { batch_id } => ( + format!("submitting #{}", batch_id), + Style::new().light_green(), + ), + }; + + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(status_text, status_style), + ])); + + // Progress bar for signature collection + if let BatchStatus::AwaitingSignatures { + collected, + required, + .. + } = &state.batch_status + { + let progress = *collected as f64 / *required as f64; + let bar_width = (area.width as usize).saturating_sub(4); + let filled = (progress * bar_width as f64) as usize; + let empty = bar_width.saturating_sub(filled); + + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("█".repeat(filled), Style::new().light_green()), + Span::styled("░".repeat(empty), Style::new().dark_gray()), + ])); + } + + let paragraph = Paragraph::new(lines); + frame.render_widget(paragraph, area); + } + + /// Draw a compact version for the header area. + pub fn draw_compact(frame: &mut Frame, area: Rect, state: &NetworkState, is_stopped: bool) { + let mut spans = vec![ + Span::styled("base: ", Style::new().dark_gray()), + Span::styled(format!("{}", state.base.height), Style::new().light_cyan()), + ]; + + // Show hold indicator for base + if state.base_hold { + spans.push(Span::styled(" ⏸", Style::new().light_red())); + } + + spans.push(Span::raw(" ")); + spans.push(Span::styled("nock: ", Style::new().dark_gray())); + spans.push(Span::styled( + format!("{}", state.nockchain.height), + Style::new().light_cyan(), + )); + + // Show hold indicator for nock + if state.nock_hold { + spans.push(Span::styled(" ⏸", Style::new().light_red())); + } + + // Show nockchain API status indicator + spans.push(Span::raw(" ")); + let (api_indicator, api_style) = match &state.nockchain_api_status { + NockchainApiStatus::Connected { .. } => ("●", Style::new().light_green()), + NockchainApiStatus::Connecting { .. } => ("◐", Style::new().light_yellow()), + NockchainApiStatus::Disconnected { .. } => ("○", Style::new().light_red()), + }; + spans.push(Span::styled(api_indicator, api_style)); + + spans.push(Span::raw(" ")); + spans.push(Span::styled("pending: ", Style::new().dark_gray())); + spans.push(Span::styled( + format!("{}↓ {}↑", state.pending_deposits, state.pending_withdrawals), + if state.pending_deposits > 0 || state.pending_withdrawals > 0 { + Style::new().light_yellow() + } else { + Style::new().dark_gray() + }, + )); + spans.push(Span::raw(" ")); + spans.push(Span::styled( + state.batch_status.display(), + batch_status_style(&state.batch_status), + )); + spans.push(Span::raw(" ")); + + let status = status_kind(state, is_stopped); + let hold_details = hold_status_details(state); + let (status_text, status_style) = match status { + BridgeStatus::Stopped => { + let text = if hold_details.is_empty() { + "STOPPED".to_string() + } else { + format!("STOPPED, {}", hold_details.join(", ")) + }; + (text, Style::new().light_red().bold()) + } + BridgeStatus::BaseHold => { + let height = hold_height_label(state.base_hold_height); + ( + format!("BASE HOLD nock@{height}"), + Style::new().light_red().bold(), + ) + } + BridgeStatus::NockHold => { + let height = hold_height_label(state.nock_hold_height); + ( + format!("NOCK HOLD base@{height}"), + Style::new().light_red().bold(), + ) + } + BridgeStatus::Running => ("RUNNING".to_string(), Style::new().light_green().bold()), + }; + spans.push(Span::styled(format!("status: {status_text}"), status_style)); + + let line = Line::from(spans); + let paragraph = Paragraph::new(line); + frame.render_widget(paragraph, area); + } +} + +fn truncate_hash(hash: &str, max_len: usize) -> String { + if hash.len() <= max_len { + return hash.to_string(); + } + let prefix_len = max_len / 2 - 2; + let suffix_len = max_len / 2 - 1; + format!( + "{}...{}", + &hash[..prefix_len], + &hash[hash.len() - suffix_len..] + ) +} + +fn format_relative(time: std::time::SystemTime) -> String { + match time.elapsed() { + Ok(duration) if duration.as_secs() > 0 => format!("{}s ago", duration.as_secs()), + Ok(_) => "just now".into(), + Err(_) => "–".into(), + } +} + +fn batch_status_style(status: &BatchStatus) -> Style { + match status { + BatchStatus::Idle => Style::new().dark_gray(), + BatchStatus::Processing { .. } => Style::new().light_cyan(), + BatchStatus::AwaitingSignatures { .. } => Style::new().light_yellow(), + BatchStatus::Submitting { .. } => Style::new().light_green(), + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum BridgeStatus { + Stopped, + BaseHold, + NockHold, + Running, +} + +fn status_kind(state: &NetworkState, is_stopped: bool) -> BridgeStatus { + if is_stopped { + BridgeStatus::Stopped + } else if state.base_hold { + BridgeStatus::BaseHold + } else if state.nock_hold { + BridgeStatus::NockHold + } else { + BridgeStatus::Running + } +} + +fn hold_status_details(state: &NetworkState) -> Vec { + let mut details = Vec::new(); + if state.base_hold { + let height = hold_height_label(state.base_hold_height); + details.push(format!("BASE HOLD nock@{height}")); + } + if state.nock_hold { + let height = hold_height_label(state.nock_hold_height); + details.push(format!("NOCK HOLD base@{height}")); + } + details +} + +fn hold_height_label(height: Option) -> String { + height + .map(|value| value.to_string()) + .unwrap_or_else(|| "unknown".to_string()) +} diff --git a/crates/bridge/src/tui/panels/proposals.rs b/crates/bridge/src/tui/panels/proposals.rs new file mode 100644 index 000000000..6447dda2b --- /dev/null +++ b/crates/bridge/src/tui/panels/proposals.rs @@ -0,0 +1,893 @@ +//! Proposal management panel. + +use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::style::{Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Gauge, List, ListItem, ListState, Paragraph}; +use ratatui::Frame; + +use crate::tui::types::{format_nock_from_nicks, Proposal, ProposalState, ProposalStatus}; + +pub struct ProposalPanel; + +impl ProposalPanel { + pub fn draw( + frame: &mut Frame, + area: Rect, + state: &ProposalState, + list_state: &mut ListState, + is_focused: bool, + ) { + Self::draw_with_detail(frame, area, state, list_state, is_focused, None); + } + + pub fn draw_with_detail( + frame: &mut Frame, + area: Rect, + state: &ProposalState, + list_state: &mut ListState, + is_focused: bool, + detail_index: Option, + ) { + // If detail view is active, show detail instead of normal view + if let Some(idx) = detail_index { + if let Some(proposal) = state.history.get(idx) { + Self::draw_detail_view(frame, area, proposal, is_focused); + return; + } + } + + // Normal view (original draw logic) + let border_style = if is_focused { + Style::new().light_cyan() + } else { + Style::default() + }; + + let block = Block::default() + .title("proposals") + .borders(Borders::ALL) + .border_style(border_style); + + let inner = block.inner(area); + frame.render_widget(block, area); + + // Split layout: top section for active proposals, bottom for history + let layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Min(12), // Active proposals section + Constraint::Percentage(50), // History section + ]) + .split(inner); + + Self::draw_active(frame, layout[0], state); + Self::draw_history(frame, layout[1], state, list_state, is_focused); + } + + fn draw_active(frame: &mut Frame, area: Rect, state: &ProposalState) { + // Split into two sections: last submitted and pending inbound + let sections = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(6), // Last submitted + Constraint::Min(6), // Pending inbound + ]) + .split(area); + + Self::draw_last_submitted(frame, sections[0], &state.last_submitted); + Self::draw_pending_inbound(frame, sections[1], &state.pending_inbound); + } + + fn draw_last_submitted(frame: &mut Frame, area: Rect, proposal: &Option) { + let mut lines = Vec::new(); + + lines.push(Line::from(vec![Span::styled( + "Last Submitted", + Style::new().bold().underlined(), + )])); + + if let Some(p) = proposal { + // Proposal ID and status + lines.push(Line::from(vec![ + Span::styled(" id: ", Style::new().dark_gray()), + Span::styled(truncate_hash(&p.id, 16), Style::new().light_cyan()), + Span::raw(" "), + Span::styled(p.status.display(), status_style(&p.status)), + ])); + + // Deposit details: amount, recipient, source block + if p.amount.is_some() || p.recipient.is_some() { + let mut deposit_line = vec![Span::raw(" ")]; + + if let Some(amount) = p.amount { + deposit_line.push(Span::styled("amt: ", Style::new().dark_gray())); + deposit_line.push(Span::styled( + format_nock_amount(amount), + Style::new().light_green(), + )); + } + + if let Some(ref recipient) = p.recipient { + deposit_line.push(Span::raw(" ")); + deposit_line.push(Span::styled("to: ", Style::new().dark_gray())); + deposit_line.push(Span::styled( + truncate_hash(recipient, 14), + Style::new().light_yellow(), + )); + } + + if let Some(nonce) = p.nonce { + deposit_line.push(Span::raw(" ")); + deposit_line.push(Span::styled("nonce: ", Style::new().dark_gray())); + deposit_line.push(Span::styled(format!("{}", nonce), Style::new().white())); + } + + lines.push(Line::from(deposit_line)); + } + + // Source block (where deposit was detected) + if let Some(source_block) = p.source_block { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("src block: ", Style::new().dark_gray()), + Span::styled(format!("{}", source_block), Style::new().light_blue()), + ])); + } + + // Signature progress and timing info + let mut sig_line = vec![ + Span::styled(" sigs: ", Style::new().dark_gray()), + Span::styled( + format!("{}/{}", p.signatures_collected, p.signatures_required), + if p.is_ready() { + Style::new().light_green() + } else { + Style::new().light_yellow() + }, + ), + Span::raw(" "), + Span::styled("signers: ", Style::new().dark_gray()), + Span::styled(format_signers(&p.signers), Style::new().light_cyan()), + ]; + + // Add time-to-submit if available + if let Some(ms) = p.time_to_submit_ms { + sig_line.push(Span::raw(" ")); + sig_line.push(Span::styled( + format!("latency: {}ms", ms), + Style::new().light_magenta(), + )); + } + + lines.push(Line::from(sig_line)); + + // Turn state: show proposer and turn status + if let Some(proposer) = p.current_proposer { + let mut turn_line = vec![ + Span::styled(" proposer: ", Style::new().dark_gray()), + Span::styled(format!("#{}", proposer), Style::new().light_cyan()), + ]; + + if p.is_my_turn { + turn_line.push(Span::raw(" ")); + turn_line.push(Span::styled( + "⚡ YOUR TURN TO POST", + Style::new().light_green().bold(), + )); + } else { + turn_line.push(Span::raw(" ")); + turn_line.push(Span::styled("waiting", Style::new().dark_gray())); + } + + // Show time until takeover if applicable + if let Some(duration) = p.time_until_takeover { + let secs = duration.as_secs(); + turn_line.push(Span::raw(" ")); + turn_line.push(Span::styled("takeover: ", Style::new().dark_gray())); + turn_line.push(Span::styled( + format_duration(duration), + if secs < 30 { + Style::new().light_yellow().bold() + } else { + Style::new().light_blue() + }, + )); + } + + lines.push(Line::from(turn_line)); + } + + // Submission block and tx hash (if submitted/executed) + if p.submitted_at_block.is_some() || p.tx_hash.is_some() { + let mut block_line = vec![Span::raw(" ")]; + + if let Some(block) = p.submitted_at_block { + block_line.push(Span::styled("submit block: ", Style::new().dark_gray())); + block_line.push(Span::styled( + format!("{}", block), + Style::new().light_green(), + )); + } + + if let Some(ref tx_hash) = p.tx_hash { + if p.submitted_at_block.is_some() { + block_line.push(Span::raw(" ")); + } + block_line.push(Span::styled("tx: ", Style::new().dark_gray())); + block_line.push(Span::styled( + truncate_hash(tx_hash, 18), + Style::new().light_cyan(), + )); + } + + lines.push(Line::from(block_line)); + } + + // Draw progress bar + let progress = p.signature_progress(); + let gauge = Gauge::default() + .gauge_style(gauge_style_for_status(&p.status)) + .ratio(progress) + .label(format!( + "{}/{} {}", + p.signatures_collected, + p.signatures_required, + p.status.display() + )); + + // Adjust gauge position based on whether we have block info + let gauge_y_offset = if p.submitted_at_block.is_some() || p.tx_hash.is_some() { + 5 + } else { + 4 + }; + + let gauge_area = Rect { + x: area.x + 2, + y: area.y + gauge_y_offset, + width: area.width.saturating_sub(4), + height: 1, + }; + + let paragraph = Paragraph::new(lines); + frame.render_widget(paragraph, area); + frame.render_widget(gauge, gauge_area); + } else { + lines.push(Line::from(vec![Span::styled( + " (none)", + Style::new().dark_gray(), + )])); + let paragraph = Paragraph::new(lines); + frame.render_widget(paragraph, area); + } + } + + fn draw_pending_inbound(frame: &mut Frame, area: Rect, proposals: &[Proposal]) { + let mut lines = Vec::new(); + + lines.push(Line::from(vec![ + Span::styled("Pending Inbound", Style::new().bold().underlined()), + Span::raw(" "), + Span::styled( + format!("({})", proposals.len()), + if proposals.is_empty() { + Style::new().dark_gray() + } else { + Style::new().light_yellow() + }, + ), + ])); + + if proposals.is_empty() { + lines.push(Line::from(vec![Span::styled( + " (none)", + Style::new().dark_gray(), + )])); + } else { + // Show up to 3 pending proposals + for (idx, p) in proposals.iter().take(3).enumerate() { + if idx > 0 { + lines.push(Line::from("")); + } + + // Line 1: index, truncated ID + lines.push(Line::from(vec![ + Span::styled(format!(" [{}] ", idx + 1), Style::new().dark_gray()), + Span::styled(truncate_hash(&p.id, 16), Style::new().light_cyan()), + ])); + + // Line 2: deposit details (amount, recipient, source block) + if p.amount.is_some() || p.source_block.is_some() { + let mut deposit_parts = vec![Span::raw(" ")]; + + if let Some(amount) = p.amount { + deposit_parts.push(Span::styled( + format_nock_amount(amount), + Style::new().light_green(), + )); + } + + if let Some(ref recipient) = p.recipient { + deposit_parts.push(Span::styled(" → ", Style::new().dark_gray())); + deposit_parts.push(Span::styled( + truncate_hash(recipient, 14), + Style::new().light_yellow(), + )); + } + + if let Some(source_block) = p.source_block { + deposit_parts.push(Span::raw(" ")); + deposit_parts.push(Span::styled("src: ", Style::new().dark_gray())); + deposit_parts.push(Span::styled( + format!("{}", source_block), + Style::new().light_blue(), + )); + } + + lines.push(Line::from(deposit_parts)); + } + + // Line 3: signature progress + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled("sigs: ", Style::new().dark_gray()), + Span::styled( + format!("{}/{}", p.signatures_collected, p.signatures_required), + if p.is_ready() { + Style::new().light_green() + } else { + Style::new().light_yellow() + }, + ), + Span::raw(" "), + Span::styled("signers: ", Style::new().dark_gray()), + Span::styled(format_signers(&p.signers), Style::new().light_cyan()), + ])); + + // Line 4: Turn state (if ready and proposer assigned) + if p.is_ready() { + if let Some(proposer) = p.current_proposer { + let mut turn_parts = vec![ + Span::raw(" "), + Span::styled("proposer: ", Style::new().dark_gray()), + Span::styled(format!("#{}", proposer), Style::new().light_cyan()), + ]; + + if p.is_my_turn { + turn_parts.push(Span::raw(" ")); + turn_parts.push(Span::styled( + "⚡ POST NOW", + Style::new().light_green().bold(), + )); + } + + if let Some(duration) = p.time_until_takeover { + turn_parts.push(Span::raw(" ")); + turn_parts.push(Span::styled( + format!("takeover: {}", format_duration(duration)), + Style::new().light_blue(), + )); + } + + lines.push(Line::from(turn_parts)); + } + } + } + + if proposals.len() > 3 { + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + format!(" ... and {} more", proposals.len() - 3), + Style::new().dark_gray().italic(), + )])); + } + } + + let paragraph = Paragraph::new(lines); + frame.render_widget(paragraph, area); + } + + fn draw_history( + frame: &mut Frame, + area: Rect, + state: &ProposalState, + list_state: &mut ListState, + is_focused: bool, + ) { + let block = Block::default() + .title("history") + .borders(Borders::TOP) + .border_style(if is_focused { + Style::new().light_cyan() + } else { + Style::default() + }); + + if state.history.is_empty() { + let paragraph = Paragraph::new(vec![Line::from(vec![Span::styled( + " (no history)", + Style::new().dark_gray(), + )])]) + .block(block); + frame.render_widget(paragraph, area); + return; + } + + let items: Vec = state + .history + .iter() + .map(|p| { + let status_style = status_style(&p.status); + let status_indicator = status_indicator(&p.status); + + // Line 1: status indicator, truncated ID, status + let line1 = Line::from(vec![ + Span::styled(status_indicator, status_style), + Span::raw(" "), + Span::styled(truncate_hash(&p.id, 16), Style::new().light_cyan()), + Span::raw(" "), + Span::styled(p.status.display(), status_style), + Span::raw(" "), + Span::styled(format_relative(p.created_at), Style::new().dark_gray()), + ]); + + let mut lines = vec![line1]; + + // Line 2: deposit details (amount, recipient, nonce, source block) + if p.amount.is_some() || p.source_block.is_some() { + let mut deposit_parts = vec![Span::raw(" ")]; + + if let Some(amount) = p.amount { + deposit_parts.push(Span::styled( + format_nock_amount(amount), + Style::new().light_green(), + )); + } + + if let Some(ref recipient) = p.recipient { + deposit_parts.push(Span::styled(" → ", Style::new().dark_gray())); + deposit_parts.push(Span::styled( + truncate_hash(recipient, 14), + Style::new().light_yellow(), + )); + } + + if let Some(source_block) = p.source_block { + deposit_parts.push(Span::raw(" ")); + deposit_parts.push(Span::styled("src: ", Style::new().dark_gray())); + deposit_parts.push(Span::styled( + format!("{}", source_block), + Style::new().light_blue(), + )); + } + + if let Some(nonce) = p.nonce { + deposit_parts.push(Span::raw(" ")); + deposit_parts.push(Span::styled("n:", Style::new().dark_gray())); + deposit_parts + .push(Span::styled(format!("{}", nonce), Style::new().white())); + } + + lines.push(Line::from(deposit_parts)); + } + + // Line 3: signature info + let mut sig_parts = vec![ + Span::raw(" "), + Span::styled( + format!("{}/{} sigs", p.signatures_collected, p.signatures_required), + if p.is_ready() { + Style::new().light_green() + } else { + Style::new().light_yellow() + }, + ), + Span::raw(" "), + Span::styled("signers: ", Style::new().dark_gray()), + Span::styled(format_signers(&p.signers), Style::new().light_cyan()), + ]; + + // Add latency if available + if let Some(ms) = p.time_to_submit_ms { + sig_parts.push(Span::raw(" ")); + sig_parts.push(Span::styled( + format!("{}ms", ms), + Style::new().light_magenta(), + )); + } + + lines.push(Line::from(sig_parts)); + + // Line 4: submission block and tx hash (if submitted/executed) + if p.submitted_at_block.is_some() || p.tx_hash.is_some() { + let mut submit_parts = vec![Span::raw(" ")]; + + if let Some(block) = p.submitted_at_block { + submit_parts.push(Span::styled("submit: ", Style::new().dark_gray())); + submit_parts.push(Span::styled( + format!("{}", block), + Style::new().light_green(), + )); + } + + if let Some(ref tx_hash) = p.tx_hash { + if p.submitted_at_block.is_some() { + submit_parts.push(Span::raw(" ")); + } + submit_parts.push(Span::styled("tx: ", Style::new().dark_gray())); + submit_parts.push(Span::styled( + truncate_hash(tx_hash, 18), + Style::new().light_cyan(), + )); + } + + lines.push(Line::from(submit_parts)); + } + + ListItem::new(lines) + }) + .collect(); + + let list = List::new(items) + .block(block) + .highlight_style(Style::new().reversed()) + .highlight_symbol("➤ "); + + frame.render_stateful_widget(list, area, list_state); + } + + /// Draw detailed view of a single proposal. + #[allow(clippy::vec_init_then_push)] + fn draw_detail_view(frame: &mut Frame, area: Rect, proposal: &Proposal, is_focused: bool) { + let border_style = if is_focused { + Style::new().light_cyan() + } else { + Style::default() + }; + + let block = Block::default() + .title("proposal details (press Enter to close)") + .borders(Borders::ALL) + .border_style(border_style); + + let inner = block.inner(area); + frame.render_widget(block, area); + + let mut lines = Vec::new(); + + // Header: ID and status + lines.push(Line::from(vec![ + Span::styled("Proposal ID: ", Style::new().bold()), + Span::styled(&proposal.id, Style::new().light_cyan()), + ])); + lines.push(Line::from(vec![ + Span::styled("Status: ", Style::new().bold()), + Span::styled(proposal.status.display(), status_style(&proposal.status)), + ])); + lines.push(Line::from("")); + + // Type and description + lines.push(Line::from(vec![ + Span::styled("Type: ", Style::new().bold()), + Span::styled(&proposal.proposal_type, Style::new().light_yellow()), + ])); + lines.push(Line::from(vec![ + Span::styled("Description: ", Style::new().bold()), + Span::raw(&proposal.description), + ])); + lines.push(Line::from("")); + + // Deposit details (if applicable) + if proposal.amount.is_some() + || proposal.recipient.is_some() + || proposal.source_block.is_some() + { + lines.push(Line::from(Span::styled( + "Deposit Details:", + Style::new().bold().underlined(), + ))); + + if let Some(amount) = proposal.amount { + lines.push(Line::from(vec![ + Span::raw(" Amount: "), + Span::styled(format_nock_amount(amount), Style::new().light_green()), + ])); + } + + if let Some(ref recipient) = proposal.recipient { + lines.push(Line::from(vec![ + Span::raw(" Recipient: "), + Span::styled(recipient, Style::new().light_yellow()), + ])); + } + + if let Some(nonce) = proposal.nonce { + lines.push(Line::from(vec![ + Span::raw(" Nonce: "), + Span::styled(format!("{}", nonce), Style::new().white()), + ])); + } + + if let Some(source_block) = proposal.source_block { + lines.push(Line::from(vec![ + Span::raw(" Source Block: "), + Span::styled(format!("{}", source_block), Style::new().light_blue()), + ])); + } + + if let Some(ref source_tx_id) = proposal.source_tx_id { + lines.push(Line::from(vec![ + Span::raw(" Source Tx ID: "), + Span::styled(source_tx_id, Style::new().light_cyan()), + ])); + } + + lines.push(Line::from("")); + } + + // Signature information + lines.push(Line::from(Span::styled( + "Signature Information:", + Style::new().bold().underlined(), + ))); + lines.push(Line::from(vec![ + Span::raw(" Collected: "), + Span::styled( + format!("{}", proposal.signatures_collected), + if proposal.is_ready() { + Style::new().light_green() + } else { + Style::new().light_yellow() + }, + ), + Span::raw(" / "), + Span::styled( + format!("{}", proposal.signatures_required), + Style::new().white(), + ), + ])); + + lines.push(Line::from(vec![ + Span::raw(" Signers: "), + Span::styled(format_signers(&proposal.signers), Style::new().light_cyan()), + ])); + + // Detailed signer list if we have them + if !proposal.signers.is_empty() { + lines.push(Line::from(" Signer IDs:")); + for signer in &proposal.signers { + lines.push(Line::from(vec![ + Span::raw(" • Node #"), + Span::styled(format!("{}", signer), Style::new().light_cyan()), + ])); + } + } + + lines.push(Line::from("")); + + // Turn-based proposal state + if proposal.current_proposer.is_some() || proposal.is_my_turn { + lines.push(Line::from(Span::styled( + "Turn State:", + Style::new().bold().underlined(), + ))); + + if let Some(proposer) = proposal.current_proposer { + lines.push(Line::from(vec![ + Span::raw(" Current Proposer: Node #"), + Span::styled(format!("{}", proposer), Style::new().light_cyan()), + ])); + } + + if proposal.is_my_turn { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + "⚡ YOUR TURN TO POST THIS PROPOSAL", + Style::new().light_green().bold(), + ), + ])); + } + + if let Some(duration) = proposal.time_until_takeover { + lines.push(Line::from(vec![ + Span::raw(" Time Until Takeover: "), + Span::styled( + format_duration(duration), + if duration.as_secs() < 30 { + Style::new().light_yellow().bold() + } else { + Style::new().light_blue() + }, + ), + ])); + } + + lines.push(Line::from("")); + } + + // Submission details + if proposal.submitted_at_block.is_some() + || proposal.tx_hash.is_some() + || proposal.executed_at_block.is_some() + { + lines.push(Line::from(Span::styled( + "Submission Details:", + Style::new().bold().underlined(), + ))); + + if let Some(block) = proposal.submitted_at_block { + lines.push(Line::from(vec![ + Span::raw(" Submitted at Block: "), + Span::styled(format!("{}", block), Style::new().light_green()), + ])); + } + + if let Some(ref tx_hash) = proposal.tx_hash { + lines.push(Line::from(vec![ + Span::raw(" Transaction Hash: "), + Span::styled(tx_hash, Style::new().light_cyan()), + ])); + } + + if let Some(block) = proposal.executed_at_block { + lines.push(Line::from(vec![ + Span::raw(" Executed at Block: "), + Span::styled(format!("{}", block), Style::new().light_green()), + ])); + } + + lines.push(Line::from("")); + } + + // Timing information + lines.push(Line::from(Span::styled( + "Timing:", + Style::new().bold().underlined(), + ))); + lines.push(Line::from(vec![ + Span::raw(" Created: "), + Span::styled( + format_relative(proposal.created_at), + Style::new().dark_gray(), + ), + ])); + + if let Some(ref submitted_at) = proposal.submitted_at { + lines.push(Line::from(vec![ + Span::raw(" Submitted: "), + Span::styled(format_relative(*submitted_at), Style::new().dark_gray()), + ])); + } + + if let Some(ms) = proposal.time_to_submit_ms { + lines.push(Line::from(vec![ + Span::raw(" Latency: "), + Span::styled(format!("{}ms", ms), Style::new().light_magenta()), + ])); + } + + lines.push(Line::from("")); + + // Data hash + lines.push(Line::from(Span::styled( + "Data Hash:", + Style::new().bold().underlined(), + ))); + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(&proposal.data_hash, Style::new().light_cyan()), + ])); + + // Render the paragraph + let paragraph = Paragraph::new(lines); + frame.render_widget(paragraph, inner); + } +} + +fn gauge_style_for_status(status: &ProposalStatus) -> Style { + match status { + ProposalStatus::Pending => Style::new().light_yellow(), + ProposalStatus::Ready => Style::new().light_green(), + ProposalStatus::Submitted => Style::new().light_cyan(), + ProposalStatus::Executed => Style::new().light_green(), + ProposalStatus::Expired => Style::new().dark_gray(), + ProposalStatus::Failed { .. } => Style::new().light_red(), + } +} + +fn status_style(status: &ProposalStatus) -> Style { + match status { + ProposalStatus::Pending => Style::new().light_yellow(), + ProposalStatus::Ready => Style::new().light_green(), + ProposalStatus::Submitted => Style::new().light_cyan(), + ProposalStatus::Executed => Style::new().light_green(), + ProposalStatus::Expired => Style::new().dark_gray(), + ProposalStatus::Failed { .. } => Style::new().light_red(), + } +} + +fn status_indicator(status: &ProposalStatus) -> &'static str { + match status { + ProposalStatus::Pending => "◌", + ProposalStatus::Ready => "◉", + ProposalStatus::Submitted => "⏳", + ProposalStatus::Executed => "✓", + ProposalStatus::Expired => "⊗", + ProposalStatus::Failed { .. } => "✗", + } +} + +fn format_signers(signers: &[u64]) -> String { + if signers.is_empty() { + return "none".to_string(); + } + if signers.len() <= 3 { + signers + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(", ") + } else { + format!( + "{}, ... +{}", + signers[..3] + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(", "), + signers.len() - 3 + ) + } +} + +fn format_relative(time: std::time::SystemTime) -> String { + match time.elapsed() { + Ok(duration) => { + let secs = duration.as_secs(); + if secs == 0 { + "just now".into() + } else if secs < 60 { + format!("{}s ago", secs) + } else if secs < 3600 { + format!("{}m ago", secs / 60) + } else if secs < 86400 { + format!("{}h ago", secs / 3600) + } else { + format!("{}d ago", secs / 86400) + } + } + Err(_) => "–".into(), + } +} + +fn truncate_hash(hash: &str, max_len: usize) -> String { + if hash.len() <= max_len { + return hash.to_string(); + } + let prefix_len = (max_len - 3) / 2; + let suffix_len = max_len - 3 - prefix_len; + format!( + "{}...{}", + &hash[..prefix_len], + &hash[hash.len() - suffix_len..] + ) +} + +fn format_nock_amount(amount: u128) -> String { + format!("{} NOCK", format_nock_from_nicks(amount)) +} + +fn format_duration(duration: std::time::Duration) -> String { + let secs = duration.as_secs(); + if secs == 0 { + "now".into() + } else if secs < 60 { + format!("{}s", secs) + } else if secs < 3600 { + format!("{}m {}s", secs / 60, secs % 60) + } else { + format!("{}h {}m", secs / 3600, (secs % 3600) / 60) + } +} diff --git a/crates/bridge/src/tui/panels/transactions.rs b/crates/bridge/src/tui/panels/transactions.rs new file mode 100644 index 000000000..df75e0311 --- /dev/null +++ b/crates/bridge/src/tui/panels/transactions.rs @@ -0,0 +1,317 @@ +//! Transaction activity panel - displays recent deposits and withdrawals. + +use ratatui::layout::Constraint; +use ratatui::style::{Color, Style, Stylize}; +use ratatui::widgets::{Block, Borders, Cell, Row, Table, TableState}; +use ratatui::Frame; + +use crate::tui::types::{BridgeTx, TxDirection, TxStatus}; + +/// Transaction panel for displaying bridge activity. +pub struct TransactionPanel; + +impl TransactionPanel { + /// Draw the transaction activity table. + pub fn draw( + frame: &mut Frame, + area: ratatui::layout::Rect, + transactions: &[BridgeTx], + table_state: &mut TableState, + is_focused: bool, + detail_view: Option, + ) { + let rows = transactions.iter().map(|tx| { + let icon = match tx.direction { + TxDirection::Deposit => "↓", + TxDirection::Withdrawal => "↑", + }; + + let amount = tx.format_amount(); + let from = truncate_address(&tx.from); + let to = truncate_address(&tx.to); + let status = tx.status.display(); + let time = format_relative(tx.timestamp); + + Row::new(vec![ + Cell::from(icon), + Cell::from(amount), + Cell::from(from), + Cell::from(to), + Cell::from(status), + Cell::from(time), + ]) + .style(row_style(&tx.status)) + }); + + let border_style = if is_focused { + Style::new().light_cyan() + } else { + Style::default() + }; + + // Check if we should show detail view + if let Some(detail_idx) = detail_view { + if let Some(tx) = transactions.get(detail_idx) { + // Split area: table on left, details on right + use ratatui::layout::{Constraint, Direction, Layout}; + let chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) + .split(area); + + // Draw table in left half + let table = Table::new( + rows, + [ + Constraint::Length(3), // Icon + Constraint::Length(8), // Amount (narrower) + Constraint::Length(8), // From (narrower) + Constraint::Length(8), // To (narrower) + Constraint::Length(12), // Status (narrower) + Constraint::Min(6), // Time (narrower) + ], + ) + .header( + Row::new(vec!["", "nocks", "from", "to", "status", "time"]) + .style(Style::new().bold()), + ) + .block( + Block::default() + .title("transaction activity") + .borders(Borders::ALL) + .border_style(border_style), + ) + .row_highlight_style(Style::new().reversed()) + .highlight_symbol("➤ "); + + frame.render_stateful_widget(table, chunks[0], table_state); + + // Draw detail panel in right half + draw_detail_panel(frame, chunks[1], tx, is_focused); + } else { + // Invalid detail index, draw normal table + let table = Table::new( + rows, + [ + Constraint::Length(3), // Icon + Constraint::Length(12), // Amount + Constraint::Length(12), // From + Constraint::Length(12), // To + Constraint::Length(18), // Status + Constraint::Min(10), // Time + ], + ) + .header( + Row::new(vec!["", "nocks", "from", "to", "status", "time"]) + .style(Style::new().bold()), + ) + .block( + Block::default() + .title("transaction activity") + .borders(Borders::ALL) + .border_style(border_style), + ) + .row_highlight_style(Style::new().reversed()) + .highlight_symbol("➤ "); + + frame.render_stateful_widget(table, area, table_state); + } + } else { + // No detail view, draw normal table + let table = Table::new( + rows, + [ + Constraint::Length(3), // Icon + Constraint::Length(12), // Amount + Constraint::Length(12), // From + Constraint::Length(12), // To + Constraint::Length(18), // Status + Constraint::Min(10), // Time + ], + ) + .header( + Row::new(vec!["", "nocks", "from", "to", "status", "time"]) + .style(Style::new().bold()), + ) + .block( + Block::default() + .title("transaction activity") + .borders(Borders::ALL) + .border_style(border_style), + ) + .row_highlight_style(Style::new().reversed()) + .highlight_symbol("➤ "); + + frame.render_stateful_widget(table, area, table_state); + } + } +} + +/// Draw the transaction detail panel. +fn draw_detail_panel( + frame: &mut Frame, + area: ratatui::layout::Rect, + tx: &BridgeTx, + is_focused: bool, +) { + use ratatui::text::{Line, Span}; + use ratatui::widgets::Paragraph; + + let border_style = if is_focused { + Style::new().light_cyan() + } else { + Style::default() + }; + + let block = Block::default() + .title("transaction details") + .borders(Borders::ALL) + .border_style(border_style); + + let inner = block.inner(area); + frame.render_widget(block, area); + + // Build detail lines + let mut lines = Vec::new(); + + // Transaction hash + lines.push(Line::from(vec![ + Span::styled("Hash: ", Style::new().bold()), + Span::raw(&tx.tx_hash), + ])); + + // Direction + let direction_str = match tx.direction { + TxDirection::Deposit => "Deposit ↓", + TxDirection::Withdrawal => "Withdrawal ↑", + }; + lines.push(Line::from(vec![ + Span::styled("Direction: ", Style::new().bold()), + Span::raw(direction_str), + ])); + + // Amount + lines.push(Line::from(vec![ + Span::styled("Amount: ", Style::new().bold()), + Span::raw(format!("{} NOCK", tx.format_amount())), + ])); + + // From address + lines.push(Line::from(vec![ + Span::styled("From: ", Style::new().bold()), + Span::raw(&tx.from), + ])); + + // To address + lines.push(Line::from(vec![ + Span::styled("To: ", Style::new().bold()), + Span::raw(&tx.to), + ])); + + // Status + lines.push(Line::from(vec![ + Span::styled("Status: ", Style::new().bold()), + Span::styled(tx.status.display(), row_style(&tx.status)), + ])); + + // Timestamp + lines.push(Line::from(vec![ + Span::styled("Time: ", Style::new().bold()), + Span::raw(format_relative(tx.timestamp)), + ])); + + // Block numbers (if available) + if let Some(block) = tx.base_block { + lines.push(Line::from(vec![ + Span::styled("Base Block: ", Style::new().bold()), + Span::raw(format!("{}", block)), + ])); + } + + if let Some(height) = tx.nock_height { + lines.push(Line::from(vec![ + Span::styled("Nock Height: ", Style::new().bold()), + Span::raw(format!("{}", height)), + ])); + } + + // Basescan link (for deposits) + if let Some(url) = tx.basescan_url() { + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "View on Basescan:", + Style::new().bold(), + )])); + lines.push(Line::from(vec![Span::styled( + url, + Style::new().light_blue(), + )])); + } + + // Instructions + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + "Press Enter to close | Press 'y' to copy hash", + Style::new().dark_gray(), + )])); + + let paragraph = Paragraph::new(lines).wrap(ratatui::widgets::Wrap { trim: false }); + frame.render_widget(paragraph, inner); +} + +/// Truncate an address for display (0x1234...5678). +fn truncate_address(addr: &str) -> String { + if addr.len() > 12 { + let prefix = &addr[..6]; + let suffix = &addr[addr.len() - 4..]; + format!("{}...{}", prefix, suffix) + } else { + addr.to_string() + } +} + +/// Get row style based on transaction status. +fn row_style(status: &TxStatus) -> Style { + match status { + TxStatus::Completed => Style::new().fg(Color::Green), + TxStatus::Pending | TxStatus::Confirming { .. } | TxStatus::Processing => { + Style::new().fg(Color::Yellow) + } + TxStatus::Failed { .. } => Style::new().fg(Color::Red), + } +} + +/// Format a timestamp as relative time. +fn format_relative(time: std::time::SystemTime) -> String { + match time.elapsed() { + Ok(duration) => { + let secs = duration.as_secs(); + if secs == 0 { + "just now".into() + } else if secs < 60 { + format!("{}s ago", secs) + } else if secs < 3600 { + format!("{}m ago", secs / 60) + } else if secs < 86400 { + format!("{}h ago", secs / 3600) + } else { + format!("{}d ago", secs / 86400) + } + } + Err(_) => "–".into(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_truncate_address() { + let addr = "0x1234567890abcdef1234567890abcdef12345678"; + assert_eq!(truncate_address(addr), "0x1234...5678"); + + let short_addr = "0x1234"; + assert_eq!(truncate_address(short_addr), "0x1234"); + } +} diff --git a/crates/bridge/src/tui/state.rs b/crates/bridge/src/tui/state.rs new file mode 100644 index 000000000..38a49c6bf --- /dev/null +++ b/crates/bridge/src/tui/state.rs @@ -0,0 +1,338 @@ +//! TUI state management. +//! +//! Local UI state lives here; shared BridgeStatus lives in bridge_status and is wrapped by TuiStatus. + +use std::collections::VecDeque; +use std::ops::Deref; +use std::sync::{Arc, RwLock}; +use std::time::Instant; + +use tokio::sync::Notify; + +pub use crate::bridge_status::BridgeStatus; +use crate::tui::types::{DepositLogSnapshot, DepositLogView, FocusedPanel, UiMode}; + +/// Capacity for log buffer. +/// Set to 100,000 to retain many hours of logs (~20MB memory at 200 bytes/line average). +/// At typical activity levels (1-2 logs/sec), this covers 14-28 hours of history. +pub const LOG_CAPACITY: usize = 100_000; + +/// Log buffer type alias. +pub type LogBuffer = Arc>>; + +/// Create a new log buffer. +pub fn new_log_buffer() -> LogBuffer { + Arc::new(RwLock::new(VecDeque::with_capacity(LOG_CAPACITY))) +} + +/// TUI wrapper around BridgeStatus holding TUI-only state. +#[derive(Clone, Debug)] +pub struct TuiStatus { + inner: BridgeStatus, + /// Deposit log snapshot (newest-first). + deposit_log: Arc>, + /// Deposit log view (offset/limit). + deposit_log_view: Arc>, + /// Whether the deposit log panel is currently focused. + deposit_log_active: Arc>, + /// Notifies the TUI deposit log poller of view/activity/data changes. + deposit_log_notify: Arc, + /// Log buffer. + logs: LogBuffer, + /// Temporary status message (text, expiry time, duration in seconds). + status_message: Arc>>, +} + +impl TuiStatus { + /// Wrap an existing BridgeStatus with TUI-specific state. + pub fn new(inner: BridgeStatus, logs: LogBuffer) -> Self { + Self { + inner, + deposit_log: Arc::new(RwLock::new(DepositLogSnapshot::default())), + deposit_log_view: Arc::new(RwLock::new(DepositLogView::default())), + deposit_log_active: Arc::new(RwLock::new(false)), + deposit_log_notify: Arc::new(Notify::new()), + logs, + status_message: Arc::new(RwLock::new(None)), + } + } + + /// Clone out the wrapped BridgeStatus for non-TUI helpers that take it by value. + pub fn bridge_status(&self) -> BridgeStatus { + self.inner.clone() + } + + /// Get deposit log snapshot. + pub fn deposit_log_snapshot(&self) -> DepositLogSnapshot { + self.deposit_log + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + /// Update deposit log snapshot. + pub fn update_deposit_log_snapshot(&self, snapshot: DepositLogSnapshot) { + if let Ok(mut guard) = self.deposit_log.write() { + *guard = snapshot; + } + } + + /// Get deposit log view (offset/limit). + pub fn deposit_log_view(&self) -> DepositLogView { + self.deposit_log_view + .read() + .map(|guard| *guard) + .unwrap_or_default() + } + + /// Update deposit log view (offset/limit). + pub fn set_deposit_log_view(&self, view: DepositLogView) { + let mut changed = false; + if let Ok(mut guard) = self.deposit_log_view.write() { + if *guard != view { + *guard = view; + changed = true; + } + } + if changed { + self.notify_deposit_log_refresh(); + } + } + + /// Check whether the deposit log panel is focused. + pub fn deposit_log_active(&self) -> bool { + self.deposit_log_active + .read() + .map(|guard| *guard) + .unwrap_or(false) + } + + /// Update whether the deposit log panel is focused. + pub fn set_deposit_log_active(&self, active: bool) { + let mut should_notify = false; + if let Ok(mut guard) = self.deposit_log_active.write() { + if *guard != active { + *guard = active; + should_notify = active; + } + } + if should_notify { + self.notify_deposit_log_refresh(); + } + } + + /// Access the deposit log notifier for async refresh triggers. + pub fn deposit_log_notifier(&self) -> Arc { + self.deposit_log_notify.clone() + } + + /// Notify listeners that the deposit log snapshot should refresh. + pub fn notify_deposit_log_refresh(&self) { + self.deposit_log_notify.notify_one(); + } + + /// Get log lines. + pub fn log_lines(&self) -> VecDeque { + self.logs + .read() + .map(|guard| guard.clone()) + .unwrap_or_default() + } + + /// Push a log line. Silently drops if lock is poisoned (background thread panicked). + pub fn push_log(&self, line: String) { + if let Ok(mut guard) = self.logs.write() { + if guard.len() >= LOG_CAPACITY { + guard.pop_front(); + } + guard.push_back(line); + } + } + + // --- Status message methods --- + + /// Set a temporary status message that will auto-expire. + /// + /// The message will be displayed in the status bar until `duration_secs` seconds + /// have elapsed since it was set. The render loop should check expiry and clear + /// expired messages. + /// + /// # Arguments + /// * `msg` - The status message text + /// * `duration_secs` - How long the message should be displayed (in seconds) + pub fn set_status_message(&self, msg: String, duration_secs: u64) { + if let Ok(mut guard) = self.status_message.write() { + *guard = Some((msg, Instant::now(), duration_secs)); + } + } + + /// Get the current status message if present and not expired. + /// + /// Returns None if: + /// - No message is set + /// - The message has expired (elapsed time > duration) + /// + /// Returns Some((message, instant)) if message is still valid. + pub fn status_message(&self) -> Option<(String, Instant)> { + self.status_message + .read() + .ok() + .and_then(|guard| guard.clone()) + .map(|(text, instant, _duration)| (text, instant)) + } + + /// Check if the current status message has expired and clear it if so. + /// + /// Should be called from the render loop on each tick. + /// Returns true if a message was cleared. + pub fn check_and_clear_expired_status(&self) -> bool { + if let Ok(mut guard) = self.status_message.write() { + if let Some((_, instant, duration_secs)) = guard.as_ref() { + if instant.elapsed().as_secs() >= *duration_secs { + *guard = None; + return true; + } + } + } + false + } + + /// Clear the current status message immediately. + pub fn clear_status_message(&self) { + if let Ok(mut guard) = self.status_message.write() { + *guard = None; + } + } +} + +impl Deref for TuiStatus { + type Target = BridgeStatus; + + fn deref(&self) -> &Self::Target { + &self.inner + } +} + +#[cfg(test)] +mod tui_status_tests { + use super::*; + + fn make_status() -> TuiStatus { + let health = Arc::new(RwLock::new(Vec::new())); + let core = BridgeStatus::new(health); + TuiStatus::new(core, new_log_buffer()) + } + + #[test] + fn test_set_status_message() { + let state = make_status(); + + // Initially no status message + assert!(state.status_message().is_none()); + + // Set a status message + state.set_status_message("Copied to clipboard!".to_string(), 3); + + // Should have a message now + let msg = state.status_message(); + assert!(msg.is_some()); + let (text, _instant) = msg.unwrap(); + assert_eq!(text, "Copied to clipboard!"); + } + + #[test] + fn test_status_message_expiry_check() { + let state = make_status(); + + // Set a message that expires in 0 seconds (immediately expired) + state.set_status_message("Test message".to_string(), 0); + + // check_and_clear_expired_status should return true (cleared an expired message) + let cleared = state.check_and_clear_expired_status(); + assert!(cleared); + + // After clearing, should be None + assert!(state.status_message().is_none()); + } + + #[test] + fn test_status_message_overwrite() { + let state = make_status(); + + // Set first message + state.set_status_message("First message".to_string(), 5); + let msg = state.status_message(); + assert_eq!(msg.unwrap().0, "First message"); + + // Set second message (should overwrite) + state.set_status_message("Second message".to_string(), 5); + let msg = state.status_message(); + assert_eq!(msg.unwrap().0, "Second message"); + } + + #[test] + fn test_clear_status_message() { + let state = make_status(); + + // Set a message + state.set_status_message("Test".to_string(), 5); + assert!(state.status_message().is_some()); + + // Clear it + state.clear_status_message(); + assert!(state.status_message().is_none()); + } +} + +/// Local UI state (not shared, owned by the TUI app). +#[derive(Clone, Debug, Default)] +pub struct LocalUiState { + /// Current UI mode. + pub mode: UiMode, + /// Currently focused panel. + pub focused_panel: FocusedPanel, + /// Deposit log scroll offset (newest-first). + pub deposit_log_offset: usize, + /// Health table selection index. + pub health_selection: Option, + /// Proposal list selection index. + pub proposal_selection: Option, + /// Alert list selection index. + pub alert_selection: Option, + /// Whether to force a full redraw. + pub force_redraw: bool, + /// Track last focused panel to detect panel changes. + pub last_focused_panel: FocusedPanel, + /// Auto-jump to last deposit nonce on open. + pub deposit_log_jump_pending: bool, +} + +impl LocalUiState { + pub fn new() -> Self { + Self { + force_redraw: true, + last_focused_panel: FocusedPanel::Health, + ..Default::default() + } + } + + /// Toggle help overlay. + pub fn toggle_help(&mut self) { + self.mode = if self.mode == UiMode::Help { + UiMode::Normal + } else { + UiMode::Help + }; + self.force_redraw = true; + } + + /// Move focus to next panel. + pub fn focus_next(&mut self) { + self.focused_panel = self.focused_panel.next(); + } + + /// Move focus to previous panel. + pub fn focus_prev(&mut self) { + self.focused_panel = self.focused_panel.prev(); + } +} diff --git a/crates/bridge/src/tui/types.rs b/crates/bridge/src/tui/types.rs new file mode 100644 index 000000000..92baebbb0 --- /dev/null +++ b/crates/bridge/src/tui/types.rs @@ -0,0 +1,864 @@ +//! Shared types for the Bridge TUI. +//! +//! This module defines the data structures used across all TUI panels +//! for displaying bridge state, proposals, transactions, and alerts. + +use std::collections::VecDeque; +use std::time::{Duration, SystemTime}; + +/// Chain synchronization state for a single blockchain. +#[derive(Clone, Debug, Default)] +pub struct ChainState { + /// Current block height. + pub height: u64, + /// Block hash of the current tip (hex-encoded). + pub tip_hash: String, + /// Number of confirmations for the latest relevant transaction. + pub confirmations: u64, + /// Whether the chain is currently syncing. + pub is_syncing: bool, + /// Last time this state was updated. + pub last_updated: Option, +} + +/// Connection status for the nockchain API endpoint. +/// +/// Tracks whether the bridge is connected to the nockchain gRPC API, +/// providing visibility into connection health and reconnection attempts. +#[derive(Clone, Debug)] +pub enum NockchainApiStatus { + /// Successfully connected to the nockchain API. + Connected { + /// When the connection was established. + since: SystemTime, + }, + /// Currently attempting to connect/reconnect. + Connecting { + /// Current reconnection attempt number (1-based). + attempt: u32, + /// Error from the last failed connection attempt, if any. + last_error: Option, + /// When reconnection started. + since: SystemTime, + }, + /// Disconnected from the nockchain API. + Disconnected { + /// When the disconnection occurred. + since: SystemTime, + /// The error that caused the disconnection. + error: String, + }, +} + +impl Default for NockchainApiStatus { + fn default() -> Self { + // Start in Connecting state since we haven't established a connection yet + NockchainApiStatus::Connecting { + attempt: 0, + last_error: None, + since: SystemTime::now(), + } + } +} + +impl NockchainApiStatus { + /// Create a new Connected status. + pub fn connected() -> Self { + NockchainApiStatus::Connected { + since: SystemTime::now(), + } + } + + /// Create a new Connecting status. + pub fn connecting(attempt: u32, last_error: Option) -> Self { + NockchainApiStatus::Connecting { + attempt, + last_error, + since: SystemTime::now(), + } + } + + /// Create a new Disconnected status. + pub fn disconnected(error: String) -> Self { + NockchainApiStatus::Disconnected { + since: SystemTime::now(), + error, + } + } + + /// Get the duration since this status was set. + pub fn duration(&self) -> Duration { + let since = match self { + NockchainApiStatus::Connected { since } => since, + NockchainApiStatus::Connecting { since, .. } => since, + NockchainApiStatus::Disconnected { since, .. } => since, + }; + since.elapsed().unwrap_or(Duration::ZERO) + } + + /// Get the last error message if available. + pub fn last_error(&self) -> Option<&str> { + match self { + NockchainApiStatus::Connecting { last_error, .. } => last_error.as_deref(), + NockchainApiStatus::Disconnected { error, .. } => Some(error.as_str()), + _ => None, + } + } +} + +/// Overall network state combining both chains. +#[derive(Clone, Debug, Default)] +pub struct NetworkState { + /// Base chain (Ethereum L2) state. + pub base: ChainState, + /// Nockchain state. + pub nockchain: ChainState, + /// Next base hashchain height expected by the kernel. + pub base_next_height: Option, + /// Next nock hashchain height expected by the kernel. + pub nock_next_height: Option, + /// Nockchain API connection status. + pub nockchain_api_status: NockchainApiStatus, + /// Number of pending deposit operations. + pub pending_deposits: u64, + /// Number of pending withdrawal operations. + pub pending_withdrawals: u64, + /// Current batch processing status. + pub batch_status: BatchStatus, + /// Degradation warning (when < 4 nodes healthy). + pub degradation_warning: Option, + + // --- Kernel state counts (from peeks) --- + /// Unsettled deposits waiting to be processed. + pub unsettled_deposit_count: u64, + /// Unsettled withdrawals waiting to be processed. + pub unsettled_withdrawal_count: u64, + + // --- Hold status (circuit breakers) --- + /// Whether Base chain processing is on hold. + pub base_hold: bool, + /// Whether Nockchain processing is on hold. + pub nock_hold: bool, + /// Whether the kernel has latched a stop state. + pub kernel_stopped: bool, + /// Counterparty nock height that releases the base hold. + pub base_hold_height: Option, + /// Counterparty base height that releases the nock hold. + pub nock_hold_height: Option, + + // --- Network mode --- + /// Whether the bridge is running in mainnet mode (true) or fakenet mode (false). + /// None indicates the status hasn't been fetched yet. + pub is_mainnet: Option, +} + +/// Batch processing status. +#[derive(Clone, Debug, Default)] +pub enum BatchStatus { + #[default] + Idle, + /// Processing a batch with the given ID. + Processing { batch_id: u64, progress_pct: u8 }, + /// Waiting for signatures. + AwaitingSignatures { + batch_id: u64, + collected: u8, + required: u8, + }, + /// Submitting to chain. + Submitting { batch_id: u64 }, +} + +impl BatchStatus { + pub fn display(&self) -> String { + match self { + BatchStatus::Idle => "idle".into(), + BatchStatus::Processing { + batch_id, + progress_pct, + } => { + format!("processing #{} ({}%)", batch_id, progress_pct) + } + BatchStatus::AwaitingSignatures { + batch_id, + collected, + required, + } => { + format!("batch #{}: {}/{} sigs", batch_id, collected, required) + } + BatchStatus::Submitting { batch_id } => { + format!("submitting #{}", batch_id) + } + } + } +} + +/// Direction of a bridge transaction. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TxDirection { + Deposit, + Withdrawal, +} + +/// Status of a bridge transaction. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum TxStatus { + Pending, + Confirming { confirmations: u64, required: u64 }, + Processing, + Completed, + Failed { reason: String }, +} + +impl TxStatus { + pub fn display(&self) -> String { + match self { + TxStatus::Pending => "pending".into(), + TxStatus::Confirming { + confirmations, + required, + } => { + format!("{}/{} conf", confirmations, required) + } + TxStatus::Processing => "processing".into(), + TxStatus::Completed => "completed".into(), + TxStatus::Failed { reason } => format!("failed: {}", reason), + } + } + + pub fn is_terminal(&self) -> bool { + matches!(self, TxStatus::Completed | TxStatus::Failed { .. }) + } +} + +/// A bridge transaction (deposit or withdrawal). +#[derive(Clone, Debug)] +pub struct BridgeTx { + /// Transaction hash on the source chain. + pub tx_hash: String, + /// Direction of the transfer. + pub direction: TxDirection, + /// Sender address. + pub from: String, + /// Recipient address. + pub to: String, + /// Amount in nicks (1 NOCK = 65,536 nicks). + pub amount: u128, + /// Current status. + pub status: TxStatus, + /// Timestamp when first seen. + pub timestamp: SystemTime, + /// Base chain block number (for deposits). + pub base_block: Option, + /// Nockchain block height (for withdrawals). + pub nock_height: Option, +} + +impl BridgeTx { + /// Generate basescan URL for this transaction. + pub fn basescan_url(&self) -> Option { + if self.direction == TxDirection::Deposit { + Some(format!("https://basescan.org/tx/{}", self.tx_hash)) + } else { + None + } + } + + /// Format amount for display (amount is in nicks, 1 NOCK = 65,536 nicks). + pub fn format_amount(&self) -> String { + format_nock_from_nicks(self.amount) + } +} + +/// Base unit for NOCK (10^16) to match on-chain decimals. +pub const NOCK_BASE_UNIT: u128 = 10_000_000_000_000_000; + +/// Nicks per NOCK on Nockchain (2^16). +pub const NICKS_PER_NOCK: u128 = 65_536; + +/// NOCK base units per nick. +pub const NOCK_BASE_PER_NICK: u128 = NOCK_BASE_UNIT / NICKS_PER_NOCK; + +/// Format a nicks amount as a NOCK string with fractional precision. +/// 1 NOCK = 10^16 base units, 1 NOCK = 65,536 nicks. +pub fn format_nock_from_nicks(nicks: u128) -> String { + if nicks == 0 { + return "0".to_string(); + } + + let whole = nicks / NICKS_PER_NOCK; + let frac = nicks % NICKS_PER_NOCK; + + if frac == 0 { + return whole.to_string(); + } + + let frac_scaled = frac * NOCK_BASE_PER_NICK; + let mut frac_str = format!("{:016}", frac_scaled); + while frac_str.ends_with('0') { + frac_str.pop(); + } + + format!("{}.{}", whole, frac_str) +} + +/// Transaction activity state. +#[derive(Clone, Debug, Default)] +pub struct TransactionState { + /// Recent transactions (newest first). + pub transactions: VecDeque, + /// Maximum transactions to keep in memory. + pub max_transactions: usize, +} + +impl TransactionState { + pub fn new(capacity: usize) -> Self { + Self { + transactions: VecDeque::with_capacity(capacity), + max_transactions: capacity, + } + } + + pub fn push(&mut self, tx: BridgeTx) { + if self.transactions.len() >= self.max_transactions { + self.transactions.pop_back(); + } + self.transactions.push_front(tx); + } + + pub fn deposits(&self) -> impl Iterator { + self.transactions + .iter() + .filter(|tx| tx.direction == TxDirection::Deposit) + } + + pub fn withdrawals(&self) -> impl Iterator { + self.transactions + .iter() + .filter(|tx| tx.direction == TxDirection::Withdrawal) + } +} + +/// Deposit log view entry for the TUI. +#[derive(Clone, Debug)] +pub struct DepositLogRow { + pub nonce: u64, + pub block_height: u64, + pub tx_id_base58: String, + pub recipient_hex: String, + pub amount: u64, +} + +/// Deposit log snapshot for the TUI. +#[derive(Clone, Debug, Default)] +pub struct DepositLogSnapshot { + pub total_count: u64, + pub first_epoch_nonce: u64, + pub rows: Vec, +} + +/// Deposit log query view (newest-first offset). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DepositLogView { + pub offset: usize, + pub limit: usize, +} + +pub const DEPOSIT_LOG_PAGE_SIZE: usize = 200; + +impl Default for DepositLogView { + fn default() -> Self { + Self { + offset: 0, + limit: DEPOSIT_LOG_PAGE_SIZE, + } + } +} + +/// A multi-sig proposal. +#[derive(Clone, Debug)] +pub struct Proposal { + /// Unique proposal identifier. + pub id: String, + /// Type of proposal (e.g., "deposit_batch", "withdrawal"). + pub proposal_type: String, + /// Human-readable description. + pub description: String, + /// Number of signatures collected. + pub signatures_collected: u8, + /// Number of signatures required. + pub signatures_required: u8, + /// Which node IDs have signed. + pub signers: Vec, + /// When the proposal was created. + pub created_at: SystemTime, + /// Current status. + pub status: ProposalStatus, + /// Associated data hash. + pub data_hash: String, + /// Base chain block height when submitted (if submitted). + pub submitted_at_block: Option, + /// Timestamp when submitted to chain. + pub submitted_at: Option, + /// Transaction hash on Base (if submitted). + pub tx_hash: Option, + /// Time from creation to submission (for metrics). + pub time_to_submit_ms: Option, + /// Base chain block height when executed/confirmed. + pub executed_at_block: Option, + + // --- Deposit details (source transaction info) --- + /// Block height where the deposit was detected. + pub source_block: Option, + /// Amount in nicks (1 NOCK = 65,536 nicks). + pub amount: Option, + /// Recipient address (hex). + pub recipient: Option, + /// Deposit nonce. + pub nonce: Option, + /// Source transaction ID (Tip5 hash, base58). + pub source_tx_id: Option, + + // --- Turn-based proposal state --- + /// Node ID of the current proposer (who should post). + pub current_proposer: Option, + /// Whether it's this node's turn to post. + pub is_my_turn: bool, + /// Time remaining until proposer takeover (if applicable). + pub time_until_takeover: Option, +} + +impl Proposal { + pub fn signature_progress(&self) -> f64 { + if self.signatures_required == 0 { + return 1.0; + } + self.signatures_collected as f64 / self.signatures_required as f64 + } + + pub fn is_ready(&self) -> bool { + self.signatures_collected >= self.signatures_required + } +} + +/// Status of a proposal. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ProposalStatus { + /// Collecting signatures. + Pending, + /// Has enough signatures, ready to execute. + Ready, + /// Submitted to chain. + Submitted, + /// Successfully executed. + Executed, + /// Expired or cancelled. + Expired, + /// Failed execution. + Failed { reason: String }, +} + +impl ProposalStatus { + pub fn display(&self) -> &'static str { + match self { + ProposalStatus::Pending => "pending", + ProposalStatus::Ready => "ready", + ProposalStatus::Submitted => "submitted", + ProposalStatus::Executed => "executed", + ProposalStatus::Expired => "expired", + ProposalStatus::Failed { .. } => "failed", + } + } +} + +/// Proposal management state. +#[derive(Clone, Debug, Default)] +pub struct ProposalState { + /// Last proposal we submitted. + pub last_submitted: Option, + /// Pending proposals awaiting our signature. + pub pending_inbound: Vec, + /// Proposal history (most recent first). + pub history: VecDeque, + /// Maximum history entries to keep. + pub max_history: usize, +} + +impl ProposalState { + pub fn new(max_history: usize) -> Self { + Self { + last_submitted: None, + pending_inbound: Vec::new(), + history: VecDeque::with_capacity(max_history), + max_history, + } + } + + /// Find a proposal by ID in all collections. + pub fn find_by_id(&self, id: &str) -> Option<&Proposal> { + // Check last submitted + if let Some(ref p) = self.last_submitted { + if p.id == id { + return Some(p); + } + } + // Check pending inbound + if let Some(p) = self.pending_inbound.iter().find(|p| p.id == id) { + return Some(p); + } + // Check history + self.history.iter().find(|p| p.id == id) + } + + /// Update or insert a proposal. Handles placement based on status. + pub fn update_or_insert(&mut self, proposal: Proposal) { + let id = proposal.id.clone(); + + // Remove from all collections first + if let Some(ref p) = self.last_submitted { + if p.id == id { + self.last_submitted = None; + } + } + self.pending_inbound.retain(|p| p.id != id); + self.history.retain(|p| p.id != id); + + // Place in appropriate collection based on status + match proposal.status { + ProposalStatus::Pending => { + self.pending_inbound.push(proposal); + } + ProposalStatus::Ready + | ProposalStatus::Submitted + | ProposalStatus::Executed + | ProposalStatus::Expired + | ProposalStatus::Failed { .. } => { + if self.history.len() >= self.max_history { + self.history.pop_back(); + } + self.history.push_front(proposal); + } + } + } + + /// Add a signature to a proposal, returning true if signature was added. + /// + /// When a proposal reaches the signature threshold, its status transitions + /// from `Pending` to `Ready` and it moves from `pending_inbound` to `history`. + pub fn add_signature(&mut self, id: &str, node_id: u64) -> bool { + // Check last submitted + if let Some(ref mut p) = self.last_submitted { + if p.id == id && !p.signers.contains(&node_id) { + p.signers.push(node_id); + p.signatures_collected = p.signers.len() as u8; + if p.is_ready() && p.status == ProposalStatus::Pending { + p.status = ProposalStatus::Ready; + } + return true; + } + } + + // Check pending inbound - need to handle status transition specially + if let Some(idx) = self.pending_inbound.iter().position(|p| p.id == id) { + let p = &mut self.pending_inbound[idx]; + if p.signers.contains(&node_id) { + return false; // Duplicate signature + } + p.signers.push(node_id); + p.signatures_collected = p.signers.len() as u8; + + // If threshold reached, transition to Ready and move to history + if p.is_ready() && p.status == ProposalStatus::Pending { + let mut proposal = self.pending_inbound.remove(idx); + proposal.status = ProposalStatus::Ready; + if self.history.len() >= self.max_history { + self.history.pop_back(); + } + self.history.push_front(proposal); + } + return true; + } + + // Check history - proposals here are already past Pending state + if let Some(p) = self.history.iter_mut().find(|p| p.id == id) { + if !p.signers.contains(&node_id) { + p.signers.push(node_id); + p.signatures_collected = p.signers.len() as u8; + return true; + } + } + + false + } +} + +/// Maximum hourly transaction counts to keep (24 hours). +pub const HOURLY_TX_CAPACITY: usize = 24; + +/// Bridge metrics for analytics. +#[derive(Clone, Debug, Default)] +pub struct MetricsState { + /// Total value deposited (lifetime). + pub total_deposited: u128, + /// Total value withdrawn (lifetime). + pub total_withdrawn: u128, + /// Transaction counts per hour (last 24 hours, bounded). + pub hourly_tx_counts: VecDeque, + /// Average transaction latency in seconds. + pub avg_latency_secs: f64, + /// Success rate (0.0 - 1.0). + pub success_rate: f64, + /// Total fees collected. + pub total_fees: u128, + + // --- Internal tracking fields for running averages --- + /// Total number of transactions recorded (for success rate calculation). + pub(crate) tx_count: u64, + /// Sum of all latencies in milliseconds (for average calculation). + pub(crate) latency_sum_ms: u64, + /// Count of latency samples (for average calculation). + pub(crate) latency_count: u64, +} + +impl MetricsState { + /// Push an hourly transaction count, enforcing capacity limit. + pub fn push_hourly_count(&mut self, count: u64) { + if self.hourly_tx_counts.len() >= HOURLY_TX_CAPACITY { + self.hourly_tx_counts.pop_front(); + } + self.hourly_tx_counts.push_back(count); + } + + /// Get sparkline data for transaction volume. + pub fn volume_sparkline(&self) -> Vec { + self.hourly_tx_counts.iter().copied().collect() + } + + /// Format total bridged value for display (Nock token has 16 decimals). + pub fn format_total_bridged(&self) -> String { + let total = self.total_deposited.saturating_add(self.total_withdrawn); + let whole = total / 10_000_000_000_000_000; + format!("{} NOCK", whole) + } +} + +/// Alert severity levels. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum AlertSeverity { + Info, + Warning, + Error, + Critical, +} + +impl AlertSeverity { + pub fn symbol(&self) -> &'static str { + match self { + AlertSeverity::Info => "ℹ", + AlertSeverity::Warning => "⚠", + AlertSeverity::Error => "✗", + AlertSeverity::Critical => "🔥", + } + } +} + +/// An alert/notification. +#[derive(Clone, Debug)] +pub struct Alert { + /// Unique alert ID. + pub id: u64, + /// Severity level. + pub severity: AlertSeverity, + /// Alert title. + pub title: String, + /// Detailed message. + pub message: String, + /// When the alert was triggered. + pub timestamp: SystemTime, + /// Source of the alert (e.g., "health", "tx", "reorg"). + pub source: String, +} + +/// Alert state management. +#[derive(Clone, Debug, Default)] +pub struct AlertState { + /// Alerts (most recent first). + pub alerts: VecDeque, + /// Next alert ID. + next_id: u64, + /// Maximum history size. + pub max_history: usize, +} + +impl AlertState { + pub fn new(max_history: usize) -> Self { + Self { + alerts: VecDeque::with_capacity(max_history), + next_id: 1, + max_history, + } + } + + pub fn refresh_next_id_from_alerts(&mut self) { + let max_id = self.alerts.iter().map(|alert| alert.id).max().unwrap_or(0); + self.next_id = max_id.saturating_add(1); + } + + pub fn push( + &mut self, + severity: AlertSeverity, + title: String, + message: String, + source: String, + ) { + let alert = Alert { + id: self.next_id, + severity, + title, + message, + timestamp: SystemTime::now(), + source, + }; + self.next_id += 1; + self.alerts.push_front(alert); + while self.alerts.len() > self.max_history { + self.alerts.pop_back(); + } + } + + pub fn highest_severity(&self) -> Option { + self.alerts.iter().map(|a| a.severity).max() + } +} + +/// UI mode for the TUI. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum UiMode { + #[default] + Normal, + /// Help overlay is shown. + Help, +} + +/// Which panel is currently focused. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum FocusedPanel { + #[default] + Health, + DepositLog, + Proposals, + Transactions, + Alerts, +} + +impl FocusedPanel { + pub fn display(&self) -> &'static str { + match self { + FocusedPanel::Health => "health", + FocusedPanel::DepositLog => "deposit log", + FocusedPanel::Proposals => "proposals", + FocusedPanel::Transactions => "transactions", + FocusedPanel::Alerts => "alerts", + } + } + + pub fn next(&self) -> Self { + match self { + FocusedPanel::Health => FocusedPanel::DepositLog, + FocusedPanel::DepositLog => FocusedPanel::Proposals, + FocusedPanel::Proposals => FocusedPanel::Transactions, + FocusedPanel::Transactions => FocusedPanel::Alerts, + FocusedPanel::Alerts => FocusedPanel::Health, + } + } + + pub fn prev(&self) -> Self { + match self { + FocusedPanel::Health => FocusedPanel::Alerts, + FocusedPanel::DepositLog => FocusedPanel::Health, + FocusedPanel::Proposals => FocusedPanel::DepositLog, + FocusedPanel::Transactions => FocusedPanel::Proposals, + FocusedPanel::Alerts => FocusedPanel::Transactions, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_nockchain_api_status_connected() { + let status = NockchainApiStatus::connected(); + assert!(matches!(status, NockchainApiStatus::Connected { .. })); + assert!(status.last_error().is_none()); + } + + #[test] + fn test_nockchain_api_status_connecting() { + let status = NockchainApiStatus::connecting(3, Some("connection refused".to_string())); + match &status { + NockchainApiStatus::Connecting { + attempt, + last_error, + .. + } => { + assert_eq!(*attempt, 3); + assert_eq!(last_error.as_deref(), Some("connection refused")); + } + _ => panic!("expected Connecting"), + } + assert_eq!(status.last_error(), Some("connection refused")); + } + + #[test] + fn test_nockchain_api_status_connecting_no_error() { + let status = NockchainApiStatus::connecting(1, None); + assert!(status.last_error().is_none()); + } + + #[test] + fn test_nockchain_api_status_disconnected() { + let status = NockchainApiStatus::disconnected("timeout".to_string()); + match &status { + NockchainApiStatus::Disconnected { error, .. } => { + assert_eq!(error, "timeout"); + } + _ => panic!("expected Disconnected"), + } + assert_eq!(status.last_error(), Some("timeout")); + } + + #[test] + fn test_nockchain_api_status_default() { + let status = NockchainApiStatus::default(); + assert!(matches!( + status, + NockchainApiStatus::Connecting { + attempt: 0, + last_error: None, + .. + } + )); + } + + #[test] + fn test_nockchain_api_status_duration() { + let status = NockchainApiStatus::connected(); + // Duration should be very small (just created) + assert!(status.duration() < Duration::from_secs(1)); + } + + #[test] + fn test_format_nock_from_nicks() { + assert_eq!(format_nock_from_nicks(0), "0"); + assert_eq!(format_nock_from_nicks(NICKS_PER_NOCK), "1"); + assert_eq!(format_nock_from_nicks(NICKS_PER_NOCK * 2), "2"); + assert_eq!( + format_nock_from_nicks(NICKS_PER_NOCK + (NICKS_PER_NOCK / 2)), + "1.5" + ); + assert_eq!(format_nock_from_nicks(1), "0.0000152587890625"); + assert_eq!(format_nock_from_nicks(653_410_000_000), "9970245.361328125"); + } +} diff --git a/crates/bridge/src/tui/widgets/help_overlay.rs b/crates/bridge/src/tui/widgets/help_overlay.rs new file mode 100644 index 000000000..8b61c0e2b --- /dev/null +++ b/crates/bridge/src/tui/widgets/help_overlay.rs @@ -0,0 +1,112 @@ +//! Help overlay widget - shows keybindings. + +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Rect}; +use ratatui::style::{Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Clear, Paragraph, Wrap}; +use ratatui::Frame; + +/// Help overlay showing all keybindings. +pub struct HelpOverlay; + +impl HelpOverlay { + /// Draw the help overlay centered on screen. + pub fn draw(frame: &mut Frame) { + let area = frame.area(); + + // Calculate centered overlay area (60% width, 70% height) + let overlay_width = (area.width as f32 * 0.6) as u16; + let overlay_height = (area.height as f32 * 0.7) as u16; + + let overlay_area = centered_rect(overlay_width, overlay_height, area); + + // Clear the background + frame.render_widget(Clear, overlay_area); + + let block = Block::default() + .title(" Help (press ? or Esc to close) ") + .borders(Borders::ALL) + .border_style(Style::new().light_cyan()); + + let inner = block.inner(overlay_area); + frame.render_widget(block, overlay_area); + + let sections = vec![ + ( + "Navigation", + vec![ + ("h / ←", "Previous panel"), + ("l / →", "Next panel"), + ("j / ↓", "Move down / scroll"), + ("k / ↑", "Move up / scroll"), + ("Tab", "Cycle panels"), + ("1-5", "Jump to panel"), + ], + ), + ( + "Panels", + vec![ + ("d", "Deposit log"), + ("p", "Proposals"), + ("t", "Transactions"), + ("a", "Alerts"), + ], + ), + ( + "Actions", + vec![ + ("r", "Refresh"), + ("y", "Copy selected item"), + ("Enter", "Expand details"), + ("q", "Quit"), + ], + ), + ]; + + let mut lines = Vec::new(); + + for (section_name, bindings) in sections { + lines.push(Line::from(vec![Span::styled( + section_name, + Style::new().bold().light_yellow(), + )])); + lines.push(Line::from("")); + + for (key, desc) in bindings { + lines.push(Line::from(vec![ + Span::styled(format!(" {:12}", key), Style::new().light_cyan()), + Span::raw(desc), + ])); + } + + lines.push(Line::from("")); + } + + let paragraph = Paragraph::new(lines) + .wrap(Wrap { trim: true }) + .alignment(Alignment::Left); + + frame.render_widget(paragraph, inner); + } +} + +/// Create a centered rectangle. +fn centered_rect(width: u16, height: u16, area: Rect) -> Rect { + let vertical = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length((area.height.saturating_sub(height)) / 2), + Constraint::Length(height), + Constraint::Min(0), + ]) + .split(area); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length((area.width.saturating_sub(width)) / 2), + Constraint::Length(width), + Constraint::Min(0), + ]) + .split(vertical[1])[1] +} diff --git a/crates/bridge/src/tui/widgets/mod.rs b/crates/bridge/src/tui/widgets/mod.rs new file mode 100644 index 000000000..a932e194d --- /dev/null +++ b/crates/bridge/src/tui/widgets/mod.rs @@ -0,0 +1,9 @@ +//! Reusable TUI widgets. +//! +//! Small, composable widgets used across multiple panels. + +pub mod help_overlay; +pub mod status_bar; + +pub use help_overlay::HelpOverlay; +pub use status_bar::StatusBar; diff --git a/crates/bridge/src/tui/widgets/status_bar.rs b/crates/bridge/src/tui/widgets/status_bar.rs new file mode 100644 index 000000000..f297750ed --- /dev/null +++ b/crates/bridge/src/tui/widgets/status_bar.rs @@ -0,0 +1,158 @@ +//! Status bar widget - shows mode and panel indicators. + +use ratatui::layout::Rect; +use ratatui::style::{Style, Stylize}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph, Wrap}; +use ratatui::Frame; + +use crate::tui::types::{FocusedPanel, UiMode}; + +/// Status bar showing current mode and panel. +pub struct StatusBar; + +impl StatusBar { + /// Draw the status bar. + pub fn draw(frame: &mut Frame, area: Rect, mode: UiMode, focused_panel: FocusedPanel) { + let mut spans = Vec::new(); + + // Mode indicator + let (mode_text, mode_style) = match mode { + UiMode::Normal => ("NORMAL", Style::new().light_green()), + UiMode::Help => ("HELP", Style::new().light_cyan()), + }; + spans.push(Span::styled( + format!(" {} ", mode_text), + mode_style.reversed(), + )); + spans.push(Span::raw(" ")); + + // Panel indicator + spans.push(Span::styled("panel: ", Style::new().dark_gray())); + spans.push(Span::styled( + focused_panel.display(), + Style::new().light_cyan(), + )); + spans.push(Span::raw(" ")); + + // Help hint + spans.push(Span::styled("? help", Style::new().dark_gray())); + + let line = Line::from(spans); + let paragraph = Paragraph::new(line) + .wrap(Wrap { trim: true }) + .block(Block::default().borders(Borders::ALL)); + + frame.render_widget(paragraph, area); + } + + /// Draw combined status bar with mode indicator, panel, shortcuts, AND optional status message. + /// + /// If `status_message` is provided, it replaces the shortcuts section with the message + /// displayed prominently. This is used for temporary feedback like "Copied to clipboard!". + pub fn draw_full(frame: &mut Frame, area: Rect, mode: UiMode, focused_panel: FocusedPanel) { + Self::draw_full_with_status(frame, area, mode, focused_panel, None); + } + + /// Draw combined status bar with optional status message. + /// + /// When `status_message` is Some, it replaces the shortcuts with the message. + pub fn draw_full_with_status( + frame: &mut Frame, + area: Rect, + mode: UiMode, + focused_panel: FocusedPanel, + status_message: Option, + ) { + let mut spans = Vec::new(); + + // Mode indicator + let (mode_text, mode_style) = match mode { + UiMode::Normal => ("NORMAL", Style::new().light_green()), + UiMode::Help => ("HELP", Style::new().light_cyan()), + }; + spans.push(Span::styled( + format!(" {} ", mode_text), + mode_style.reversed(), + )); + spans.push(Span::raw(" ")); + + // Panel indicator + spans.push(Span::styled( + focused_panel.display(), + Style::new().light_cyan(), + )); + spans.push(Span::raw(" ")); + + // Separator + spans.push(Span::styled("│", Style::new().dark_gray())); + spans.push(Span::raw(" ")); + + // Status message OR shortcuts + if let Some(msg) = status_message { + // Show status message prominently + spans.push(Span::styled(msg, Style::new().light_green().bold())); + } else { + // Show shortcuts for current panel + let shortcuts = Self::shortcuts_for_panel(focused_panel); + for (idx, (key, desc)) in shortcuts.iter().enumerate() { + if idx > 0 { + spans.push(Span::raw(" ")); + } + spans.push(Span::styled(*key, Style::new().light_yellow())); + spans.push(Span::raw(" ")); + spans.push(Span::styled(*desc, Style::new().dark_gray())); + } + } + + let line = Line::from(spans); + let paragraph = Paragraph::new(line) + .wrap(Wrap { trim: true }) + .block(Block::default().borders(Borders::ALL)); + + frame.render_widget(paragraph, area); + } + + /// Get shortcuts for a panel. + fn shortcuts_for_panel(focused_panel: FocusedPanel) -> Vec<(&'static str, &'static str)> { + match focused_panel { + FocusedPanel::Health => { + vec![("↑↓", "nav"), ("r", "refresh"), ("?", "help")] + } + FocusedPanel::Proposals => { + vec![("↑↓", "nav"), ("Enter", "details"), ("?", "help")] + } + FocusedPanel::Transactions => { + vec![("↑↓", "nav"), ("y", "copy"), ("Enter", "details"), ("?", "help")] + } + FocusedPanel::Alerts => { + vec![("↑↓", "nav"), ("?", "help")] + } + FocusedPanel::DepositLog => { + vec![("↑↓", "scroll"), ("J/K", "scroll"), ("y", "copy"), ("?", "help")] + } + } + } + + /// Draw keyboard shortcuts for the current panel (legacy, use draw_full instead). + pub fn draw_shortcuts(frame: &mut Frame, area: Rect, focused_panel: FocusedPanel) { + let shortcuts = Self::shortcuts_for_panel(focused_panel); + + let mut spans = Vec::new(); + for (idx, (key, desc)) in shortcuts.iter().enumerate() { + if idx > 0 { + spans.push(Span::raw(" ")); + } + spans.push(Span::styled(*key, Style::new().light_yellow())); + spans.push(Span::raw(" ")); + spans.push(Span::styled(*desc, Style::new().dark_gray())); + } + + let line = Line::from(spans); + let paragraph = Paragraph::new(line) + .wrap(Wrap { trim: true }) + .block(Block::default().borders(Borders::ALL)); + + frame.render_widget(paragraph, area); + } +} diff --git a/crates/bridge/src/tui_api.rs b/crates/bridge/src/tui_api.rs new file mode 100644 index 000000000..6734834a3 --- /dev/null +++ b/crates/bridge/src/tui_api.rs @@ -0,0 +1,924 @@ +use std::sync::{Arc, RwLock}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use hex::encode as hex_encode; +use tokio::time::{interval, MissedTickBehavior}; +use tonic::{Request, Response, Status}; + +use crate::bridge_status::{BridgeStatus, ALERT_HISTORY_CAPACITY}; +use crate::config::NonceEpochConfig; +use crate::deposit_log::DepositLog; +use crate::errors::BridgeError; +use crate::health::{NodeHealthSnapshot, NodeHealthStatus}; +use crate::metrics; +use crate::status::BridgeStatusState; +use crate::tui::types::{ + format_nock_from_nicks, Alert, AlertSeverity, AlertState, BatchStatus as TuiBatchStatus, + BridgeTx as TuiBridgeTx, ChainState as TuiChainState, DepositLogSnapshot, DepositLogView, + MetricsState as TuiMetricsState, NockchainApiStatus as TuiNockchainApiStatus, + Proposal as TuiProposal, ProposalState as TuiProposalState, + ProposalStatus as TuiProposalStatus, TransactionState as TuiTransactionState, + TxDirection as TuiTxDirection, TxStatus as TuiTxStatus, DEPOSIT_LOG_PAGE_SIZE, +}; + +pub mod proto { + tonic::include_proto!("bridge.tui.v1"); +} + +use proto::batch_status::Status as ProtoBatchStatusKind; +use proto::bridge_tui_server::BridgeTui; +use proto::nockchain_api_status::State as ProtoNockchainApiState; +use proto::tx_status::Status as ProtoTxStatusKind; +use proto::{ + Alert as ProtoAlert, AlertSeverity as ProtoAlertSeverity, AlertView as ProtoAlertView, + AlertsSnapshot as ProtoAlertsSnapshot, Base58Hash, BatchAwaitingSignatures, BatchIdle, + BatchProcessing, BatchStatus, BatchSubmitting, BridgeTx as ProtoBridgeTx, ChainState, + DepositLogRow, DepositLogSnapshot as ProtoDepositLogSnapshot, + DepositLogView as ProtoDepositLogView, EthAddress as EthAddressProto, GetSnapshotRequest, + GetSnapshotResponse, LastDeposit, MetricsState as ProtoMetricsState, NetworkState, + PeerHealthStatus, PeerStatus, Proposal, ProposalState, ProposalStatus, RunningState, + SuccessfulDeposit, TransactionState as ProtoTransactionState, TxDirection as ProtoTxDirection, + TxStatus as ProtoTxStatus, TxStatusCompleted, TxStatusConfirming, TxStatusFailed, + TxStatusPending, TxStatusProcessing, +}; + +#[derive(Clone)] +pub struct BridgeTuiService { + deposit_log: Arc, + nonce_epoch: NonceEpochConfig, + snapshot_cache: Arc>>, +} + +impl BridgeTuiService { + pub async fn new( + bridge_status: BridgeStatus, + status_state: BridgeStatusState, + deposit_log: Arc, + nonce_epoch: NonceEpochConfig, + ) -> Result { + let snapshot_cache = Arc::new(RwLock::new(None)); + + match build_cached_snapshot(&bridge_status, &status_state, &deposit_log, &nonce_epoch).await + { + Ok(initial_snapshot) => { + if let Ok(mut guard) = snapshot_cache.write() { + *guard = Some(initial_snapshot); + } + } + Err(err) => { + tracing::warn!( + target: "bridge.tui", + error=%err, + "failed to warm TUI snapshot cache, will retry" + ); + } + } + + spawn_snapshot_refresher( + snapshot_cache.clone(), + bridge_status.clone(), + status_state.clone(), + deposit_log.clone(), + nonce_epoch.clone(), + ); + + Ok(Self { + deposit_log, + nonce_epoch, + snapshot_cache, + }) + } +} + +#[tonic::async_trait] +impl BridgeTui for BridgeTuiService { + async fn get_snapshot( + &self, + request: Request, + ) -> Result, Status> { + let metrics = metrics::init_metrics(); + let started = Instant::now(); + metrics.tui_snapshot_requests.increment(); + + let request = request.into_inner(); + let view = request + .deposit_log_view + .map(deposit_log_view_from_proto) + .unwrap_or_default(); + let alert_limit = request + .alert_view + .map(alert_view_from_proto) + .unwrap_or(ALERT_HISTORY_CAPACITY); + metrics + .tui_snapshot_alert_limit_requested + .swap(alert_limit as f64); + metrics.tui_snapshot_limit_requested.swap(view.limit as f64); + metrics.tui_snapshot_offset_requested.swap(view.offset as f64); + if view.limit > SNAPSHOT_CACHE_LIMIT { + metrics.tui_snapshot_limit_over_cache.increment(); + } + if view.limit > 10_000 { + metrics.tui_snapshot_limit_over_10000.increment(); + } + + let cached = self + .snapshot_cache + .read() + .ok() + .and_then(|guard| guard.clone()); + + let Some(snapshot) = cached else { + metrics.tui_snapshot_response_time.add_timing(&started.elapsed()); + return Err(Status::unavailable("snapshot cache is not ready")); + }; + + let to_response_started = Instant::now(); + let mut response = snapshot.to_response(view, alert_limit); + metrics + .tui_snapshot_to_response_time + .add_timing(&to_response_started.elapsed()); + if !snapshot.deposit_log.covers(view) { + metrics.tui_snapshot_uncached_requests.increment(); + let uncached_started = Instant::now(); + match self.deposit_log.snapshot(&self.nonce_epoch, view).await { + Ok(snapshot) => { + response.deposit_log = Some(deposit_log_snapshot_to_proto(&snapshot)); + } + Err(err) => { + tracing::warn!( + target: "bridge.tui", + error=%err, + "failed to load deposit log page" + ); + } + } + metrics + .tui_snapshot_uncached_load_time + .add_timing(&uncached_started.elapsed()); + } + + metrics.tui_snapshot_response_time.add_timing(&started.elapsed()); + Ok(Response::new(response)) + } +} + +const SNAPSHOT_REFRESH_INTERVAL: Duration = Duration::from_secs(5); +const SNAPSHOT_CACHE_LIMIT: usize = DEPOSIT_LOG_PAGE_SIZE; + +#[derive(Clone, Debug)] +struct CachedDepositLog { + total_count: u64, + first_epoch_nonce: u64, + rows: Vec, +} + +impl CachedDepositLog { + fn from_snapshot(snapshot: &DepositLogSnapshot) -> Self { + Self { + total_count: snapshot.total_count, + first_epoch_nonce: snapshot.first_epoch_nonce, + rows: snapshot + .rows + .iter() + .map(|row| DepositLogRow { + nonce: row.nonce, + block_height: row.block_height, + tx_id_base58: row.tx_id_base58.clone(), + recipient_hex: row.recipient_hex.clone(), + amount: row.amount, + }) + .collect(), + } + } + + fn covers(&self, view: DepositLogView) -> bool { + if self.total_count == 0 { + return true; + } + + let end = view.offset.saturating_add(view.limit); + end <= self.rows.len() + } + + fn slice(&self, view: DepositLogView) -> ProtoDepositLogSnapshot { + if self.rows.is_empty() { + return ProtoDepositLogSnapshot { + total_count: self.total_count, + first_epoch_nonce: self.first_epoch_nonce, + rows: Vec::new(), + }; + } + + let start = view.offset.min(self.rows.len()); + let end = start.saturating_add(view.limit).min(self.rows.len()); + let rows = if start >= end { + Vec::new() + } else { + self.rows[start..end].to_vec() + }; + + ProtoDepositLogSnapshot { + total_count: self.total_count, + first_epoch_nonce: self.first_epoch_nonce, + rows, + } + } +} + +#[derive(Clone, Debug)] +struct CachedSnapshot { + running_state: i32, + nock_hold: bool, + base_hold: bool, + nock_hold_height: Option, + base_hold_height: Option, + network_state: NetworkState, + deposit_log: CachedDepositLog, + proposals: ProposalState, + peer_statuses: Vec, + last_submitted_deposit: Option, + last_successful_deposit: Option, + alerts: Vec, + metrics: ProtoMetricsState, + transactions: ProtoTransactionState, +} + +impl CachedSnapshot { + fn to_response(&self, view: DepositLogView, alert_limit: usize) -> GetSnapshotResponse { + let alerts = if alert_limit == 0 { + Vec::new() + } else { + self.alerts + .iter() + .take(alert_limit.min(self.alerts.len())) + .cloned() + .collect() + }; + + GetSnapshotResponse { + running_state: self.running_state, + nock_hold: self.nock_hold, + base_hold: self.base_hold, + nock_hold_height: self.nock_hold_height, + base_hold_height: self.base_hold_height, + network_state: Some(self.network_state.clone()), + deposit_log: Some(self.deposit_log.slice(view)), + proposals: Some(self.proposals.clone()), + peer_statuses: self.peer_statuses.clone(), + last_submitted_deposit: self.last_submitted_deposit.clone(), + last_successful_deposit: self.last_successful_deposit.clone(), + alerts: Some(ProtoAlertsSnapshot { alerts }), + metrics: Some(self.metrics.clone()), + transactions: Some(self.transactions.clone()), + } + } +} + +fn spawn_snapshot_refresher( + snapshot_cache: Arc>>, + bridge_status: BridgeStatus, + status_state: BridgeStatusState, + deposit_log: Arc, + nonce_epoch: NonceEpochConfig, +) { + tokio::spawn(async move { + let mut ticker = interval(SNAPSHOT_REFRESH_INTERVAL); + ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); + loop { + ticker.tick().await; + match build_cached_snapshot(&bridge_status, &status_state, &deposit_log, &nonce_epoch) + .await + { + Ok(snapshot) => { + if let Ok(mut guard) = snapshot_cache.write() { + *guard = Some(snapshot); + } + } + Err(err) => { + tracing::warn!( + target: "bridge.tui", + error=%err, + "failed to refresh cached TUI snapshot" + ); + } + } + } + }); +} + +async fn build_cached_snapshot( + bridge_status: &BridgeStatus, + status_state: &BridgeStatusState, + deposit_log: &DepositLog, + nonce_epoch: &NonceEpochConfig, +) -> Result { + let metrics = metrics::init_metrics(); + let build_started = Instant::now(); + let network = bridge_status.network(); + let running_state = if network.kernel_stopped { + RunningState::Stopped + } else { + RunningState::Running + }; + + let last_submitted_deposit = status_state + .last_submitted_deposit() + .map(last_submitted_deposit_to_proto); + + let last_successful_deposit = match bridge_status.last_deposit_nonce() { + Some(nonce) => match deposit_log.get_by_nonce(nonce, nonce_epoch).await { + Ok(Some(entry)) => Some(SuccessfulDeposit { + tx_id: Some(Base58Hash { + value: entry.tx_id.to_base58(), + }), + name_first: Some(Base58Hash { + value: entry.name.first.to_base58(), + }), + name_last: Some(Base58Hash { + value: entry.name.last.to_base58(), + }), + recipient: Some(EthAddressProto { + value: format!("0x{}", hex_encode(entry.recipient.0)), + }), + amount: entry.amount_to_mint, + block_height: entry.block_height, + as_of: Some(Base58Hash { + value: entry.as_of.to_base58(), + }), + nonce, + }), + Ok(None) => None, + Err(err) => { + tracing::warn!( + target: "bridge.tui", + error=%err, + nonce, + "failed to load last successful deposit from log" + ); + None + } + }, + None => None, + }; + + let view = DepositLogView { + offset: 0, + limit: SNAPSHOT_CACHE_LIMIT, + }; + let deposit_log_snapshot = deposit_log.snapshot(nonce_epoch, view).await?; + let cached_deposit_log = CachedDepositLog::from_snapshot(&deposit_log_snapshot); + + let alerts_snapshot = alerts_snapshot_to_proto(&bridge_status.alerts(), ALERT_HISTORY_CAPACITY); + let proposals_state = bridge_status.proposals(); + + let proposal_build_started = Instant::now(); + let proposals_proto = proposal_state_to_proto(&proposals_state); + metrics + .tui_snapshot_build_proposals_time + .add_timing(&proposal_build_started.elapsed()); + + let pending_inbound_signature_count: usize = proposals_state + .pending_inbound + .iter() + .map(|proposal| proposal.signers.len()) + .sum(); + let history_signature_count: usize = proposals_state + .history + .iter() + .map(|proposal| proposal.signers.len()) + .sum(); + let pending_inbound_bytes: usize = proposals_state + .pending_inbound + .iter() + .map(approximate_tui_proposal_bytes) + .sum(); + let history_bytes: usize = proposals_state + .history + .iter() + .map(approximate_tui_proposal_bytes) + .sum(); + let last_submitted_bytes = proposals_state + .last_submitted + .as_ref() + .map(approximate_tui_proposal_bytes) + .unwrap_or_default(); + + metrics + .tui_proposals_pending_inbound_count + .swap(proposals_state.pending_inbound.len() as f64); + metrics + .tui_proposals_history_count + .swap(proposals_state.history.len() as f64); + metrics.tui_proposals_last_submitted_present.swap(if proposals_state + .last_submitted + .is_some() + { + 1.0 + } else { + 0.0 + }); + metrics + .tui_proposals_pending_inbound_signature_count + .swap(pending_inbound_signature_count as f64); + metrics + .tui_proposals_history_signature_count + .swap(history_signature_count as f64); + metrics + .tui_proposals_pending_inbound_approx_bytes + .swap(pending_inbound_bytes as f64); + metrics + .tui_proposals_history_approx_bytes + .swap(history_bytes as f64); + metrics + .tui_proposals_last_submitted_approx_bytes + .swap(last_submitted_bytes as f64); + metrics + .tui_proposals_approx_total_bytes + .swap((pending_inbound_bytes + history_bytes + last_submitted_bytes) as f64); + + let snapshot = CachedSnapshot { + running_state: running_state as i32, + nock_hold: network.nock_hold, + base_hold: network.base_hold, + nock_hold_height: if network.nock_hold { + network.nock_hold_height + } else { + None + }, + base_hold_height: if network.base_hold { + network.base_hold_height + } else { + None + }, + network_state: network_state_to_proto(&network), + deposit_log: cached_deposit_log, + proposals: proposals_proto, + peer_statuses: bridge_status + .health_snapshots() + .iter() + .map(peer_status_to_proto) + .collect(), + last_submitted_deposit, + last_successful_deposit, + alerts: alerts_snapshot.alerts, + metrics: metrics_state_to_proto(&bridge_status.metrics()), + transactions: transaction_state_to_proto(&bridge_status.transactions()), + }; + metrics + .tui_snapshot_build_cache_time + .add_timing(&build_started.elapsed()); + Ok(snapshot) +} + +fn deposit_log_view_from_proto(view: ProtoDepositLogView) -> DepositLogView { + DepositLogView { + offset: usize::try_from(view.offset).unwrap_or(usize::MAX), + limit: usize::try_from(view.limit).unwrap_or(usize::MAX), + } +} + +fn alert_view_from_proto(view: ProtoAlertView) -> usize { + usize::try_from(view.limit).unwrap_or(ALERT_HISTORY_CAPACITY) +} + +fn alerts_snapshot_to_proto(alerts: &AlertState, limit: usize) -> ProtoAlertsSnapshot { + if limit == 0 { + return ProtoAlertsSnapshot { alerts: Vec::new() }; + } + + let mut all: Vec = alerts.alerts.iter().cloned().collect(); + all.sort_by_key(|alert| std::cmp::Reverse(alert_timestamp_ms(alert))); + all.truncate(limit); + + ProtoAlertsSnapshot { + alerts: all.iter().map(alert_to_proto).collect(), + } +} + +fn alert_to_proto(alert: &Alert) -> ProtoAlert { + ProtoAlert { + id: alert.id, + severity: alert_severity_to_proto(alert.severity) as i32, + title: alert.title.clone(), + message: alert.message.clone(), + source: alert.source.clone(), + created_at_ms: alert_timestamp_ms(alert), + } +} + +fn alert_severity_to_proto(severity: AlertSeverity) -> ProtoAlertSeverity { + match severity { + AlertSeverity::Info => ProtoAlertSeverity::Info, + AlertSeverity::Warning => ProtoAlertSeverity::Warning, + AlertSeverity::Error => ProtoAlertSeverity::Error, + AlertSeverity::Critical => ProtoAlertSeverity::Critical, + } +} + +fn alert_timestamp_ms(alert: &Alert) -> u64 { + system_time_to_millis(alert.timestamp).unwrap_or(0) +} + +fn deposit_log_snapshot_to_proto(snapshot: &DepositLogSnapshot) -> ProtoDepositLogSnapshot { + ProtoDepositLogSnapshot { + total_count: snapshot.total_count, + first_epoch_nonce: snapshot.first_epoch_nonce, + rows: snapshot + .rows + .iter() + .map(|row| DepositLogRow { + nonce: row.nonce, + block_height: row.block_height, + tx_id_base58: row.tx_id_base58.clone(), + recipient_hex: row.recipient_hex.clone(), + amount: row.amount, + }) + .collect(), + } +} + +fn network_state_to_proto(state: &crate::tui::types::NetworkState) -> NetworkState { + NetworkState { + base: Some(chain_state_to_proto(&state.base)), + nockchain: Some(chain_state_to_proto(&state.nockchain)), + pending_deposits: state.pending_deposits, + pending_withdrawals: state.pending_withdrawals, + unsettled_deposit_count: state.unsettled_deposit_count, + unsettled_withdrawal_count: state.unsettled_withdrawal_count, + batch_status: Some(batch_status_to_proto(&state.batch_status)), + is_mainnet: state.is_mainnet, + nockchain_api_status: Some(nockchain_api_status_to_proto(&state.nockchain_api_status)), + base_next_height: state.base_next_height, + nock_next_height: state.nock_next_height, + degradation_warning: state.degradation_warning.clone(), + } +} + +fn chain_state_to_proto(state: &TuiChainState) -> ChainState { + ChainState { + height: state.height, + tip_hash: state.tip_hash.clone(), + confirmations: state.confirmations, + is_syncing: state.is_syncing, + last_updated_ms: state.last_updated.and_then(system_time_to_millis), + } +} + +fn nockchain_api_status_to_proto(status: &TuiNockchainApiStatus) -> proto::NockchainApiStatus { + match status { + TuiNockchainApiStatus::Connected { since } => proto::NockchainApiStatus { + state: ProtoNockchainApiState::Connected as i32, + since_ms: system_time_to_millis(*since), + attempt: None, + last_error: None, + }, + TuiNockchainApiStatus::Connecting { + attempt, + last_error, + since, + } => proto::NockchainApiStatus { + state: ProtoNockchainApiState::Connecting as i32, + since_ms: system_time_to_millis(*since), + attempt: Some(*attempt), + last_error: last_error.clone(), + }, + TuiNockchainApiStatus::Disconnected { since, error } => proto::NockchainApiStatus { + state: ProtoNockchainApiState::Disconnected as i32, + since_ms: system_time_to_millis(*since), + attempt: None, + last_error: Some(error.clone()), + }, + } +} + +fn batch_status_to_proto(status: &TuiBatchStatus) -> BatchStatus { + let status = match status { + TuiBatchStatus::Idle => Some(ProtoBatchStatusKind::Idle(BatchIdle {})), + TuiBatchStatus::Processing { + batch_id, + progress_pct, + } => Some(ProtoBatchStatusKind::Processing(BatchProcessing { + batch_id: *batch_id, + progress_pct: u32::from(*progress_pct), + })), + TuiBatchStatus::AwaitingSignatures { + batch_id, + collected, + required, + } => Some(ProtoBatchStatusKind::AwaitingSignatures( + BatchAwaitingSignatures { + batch_id: *batch_id, + collected: u32::from(*collected), + required: u32::from(*required), + }, + )), + TuiBatchStatus::Submitting { batch_id } => { + Some(ProtoBatchStatusKind::Submitting(BatchSubmitting { + batch_id: *batch_id, + })) + } + }; + + BatchStatus { status } +} + +fn proposal_state_to_proto(state: &TuiProposalState) -> ProposalState { + ProposalState { + last_submitted: state.last_submitted.as_ref().map(proposal_to_proto), + pending_inbound: state + .pending_inbound + .iter() + .map(proposal_to_proto) + .collect(), + history: state.history.iter().map(proposal_to_proto).collect(), + } +} + +fn proposal_to_proto(proposal: &TuiProposal) -> Proposal { + let (status, failure_reason) = match &proposal.status { + TuiProposalStatus::Pending => (ProposalStatus::Pending, None), + TuiProposalStatus::Ready => (ProposalStatus::Ready, None), + TuiProposalStatus::Submitted => (ProposalStatus::Submitted, None), + TuiProposalStatus::Executed => (ProposalStatus::Executed, None), + TuiProposalStatus::Expired => (ProposalStatus::Expired, None), + TuiProposalStatus::Failed { reason } => (ProposalStatus::Failed, Some(reason.clone())), + }; + + Proposal { + id: proposal.id.clone(), + proposal_type: proposal.proposal_type.clone(), + description: proposal.description.clone(), + signatures_collected: u32::from(proposal.signatures_collected), + signatures_required: u32::from(proposal.signatures_required), + signers: proposal.signers.clone(), + created_at_ms: system_time_to_millis(proposal.created_at), + status: status as i32, + data_hash: proposal.data_hash.clone(), + submitted_at_block: proposal.submitted_at_block, + submitted_at_ms: proposal.submitted_at.and_then(system_time_to_millis), + tx_hash: proposal.tx_hash.clone(), + time_to_submit_ms: proposal.time_to_submit_ms, + executed_at_block: proposal.executed_at_block, + source_block: proposal.source_block, + amount: proposal.amount.map(format_nock_from_nicks), + recipient: proposal.recipient.clone(), + nonce: proposal.nonce, + source_tx_id: proposal.source_tx_id.clone(), + current_proposer: proposal.current_proposer, + is_my_turn: proposal.is_my_turn, + time_until_takeover_ms: proposal.time_until_takeover.map(duration_to_millis), + failure_reason, + } +} + +fn approximate_tui_proposal_bytes(proposal: &TuiProposal) -> usize { + let mut bytes = std::mem::size_of::(); + bytes = bytes + .saturating_add(proposal.id.len()) + .saturating_add(proposal.proposal_type.len()) + .saturating_add(proposal.description.len()) + .saturating_add(proposal.data_hash.len()) + .saturating_add(proposal.signers.len().saturating_mul(std::mem::size_of::())); + + if let Some(tx_hash) = &proposal.tx_hash { + bytes = bytes.saturating_add(tx_hash.len()); + } + if let Some(recipient) = &proposal.recipient { + bytes = bytes.saturating_add(recipient.len()); + } + if let Some(source_tx_id) = &proposal.source_tx_id { + bytes = bytes.saturating_add(source_tx_id.len()); + } + if let TuiProposalStatus::Failed { reason } = &proposal.status { + bytes = bytes.saturating_add(reason.len()); + } + + bytes +} + +fn metrics_state_to_proto(state: &TuiMetricsState) -> ProtoMetricsState { + ProtoMetricsState { + total_deposited: state.total_deposited.to_string(), + total_withdrawn: state.total_withdrawn.to_string(), + hourly_tx_counts: state.hourly_tx_counts.iter().copied().collect(), + avg_latency_secs: state.avg_latency_secs, + success_rate: state.success_rate, + total_fees: state.total_fees.to_string(), + tx_count: state.tx_count, + latency_sum_ms: state.latency_sum_ms, + latency_count: state.latency_count, + } +} + +fn transaction_state_to_proto(state: &TuiTransactionState) -> ProtoTransactionState { + ProtoTransactionState { + transactions: state.transactions.iter().map(bridge_tx_to_proto).collect(), + max_transactions: u64::try_from(state.max_transactions).unwrap_or(u64::MAX), + } +} + +fn bridge_tx_to_proto(tx: &TuiBridgeTx) -> ProtoBridgeTx { + ProtoBridgeTx { + tx_hash: tx.tx_hash.clone(), + direction: tx_direction_to_proto(tx.direction) as i32, + from: tx.from.clone(), + to: tx.to.clone(), + amount: tx.amount.to_string(), + status: Some(tx_status_to_proto(&tx.status)), + timestamp_ms: system_time_to_millis(tx.timestamp).unwrap_or(0), + base_block: tx.base_block, + nock_height: tx.nock_height, + } +} + +fn tx_direction_to_proto(direction: TuiTxDirection) -> ProtoTxDirection { + match direction { + TuiTxDirection::Deposit => ProtoTxDirection::Deposit, + TuiTxDirection::Withdrawal => ProtoTxDirection::Withdrawal, + } +} + +fn tx_status_to_proto(status: &TuiTxStatus) -> ProtoTxStatus { + let status = match status { + TuiTxStatus::Pending => ProtoTxStatusKind::Pending(TxStatusPending {}), + TuiTxStatus::Confirming { + confirmations, + required, + } => ProtoTxStatusKind::Confirming(TxStatusConfirming { + confirmations: *confirmations, + required: *required, + }), + TuiTxStatus::Processing => ProtoTxStatusKind::Processing(TxStatusProcessing {}), + TuiTxStatus::Completed => ProtoTxStatusKind::Completed(TxStatusCompleted {}), + TuiTxStatus::Failed { reason } => ProtoTxStatusKind::Failed(TxStatusFailed { + reason: reason.clone(), + }), + }; + + ProtoTxStatus { + status: Some(status), + } +} + +fn peer_status_to_proto(snapshot: &NodeHealthSnapshot) -> PeerStatus { + let (status, error) = match &snapshot.status { + NodeHealthStatus::Healthy => (PeerHealthStatus::Healthy, None), + NodeHealthStatus::Unreachable { error } => { + (PeerHealthStatus::Unreachable, Some(error.clone())) + } + }; + + PeerStatus { + node_id: snapshot.node_id, + address: snapshot.address.clone(), + status: status as i32, + error, + latency_ms: snapshot.latency_ms.map(u128_to_u64), + peer_uptime_ms: snapshot.peer_uptime_ms, + last_updated_ms: system_time_to_millis(snapshot.last_updated), + } +} + +fn last_submitted_deposit_to_proto(entry: crate::status::LastSubmittedDeposit) -> LastDeposit { + LastDeposit { + tx_id: Some(Base58Hash { + value: entry.deposit.tx_id.to_base58(), + }), + name_first: Some(Base58Hash { + value: entry.deposit.name.first.to_base58(), + }), + name_last: Some(Base58Hash { + value: entry.deposit.name.last.to_base58(), + }), + recipient: Some(EthAddressProto { + value: format!("0x{}", hex_encode(entry.deposit.recipient.0)), + }), + amount: entry.deposit.amount, + block_height: entry.deposit.block_height, + as_of: Some(Base58Hash { + value: entry.deposit.as_of.to_base58(), + }), + nonce: entry.deposit.nonce, + base_tx_hash: entry.base_tx_hash, + base_block_number: entry.base_block_number, + } +} + +fn system_time_to_millis(time: SystemTime) -> Option { + let duration = time.duration_since(UNIX_EPOCH).ok()?; + u64::try_from(duration.as_millis()).ok() +} + +fn duration_to_millis(duration: Duration) -> u64 { + u64::try_from(duration.as_millis()).unwrap_or(u64::MAX) +} + +fn u128_to_u64(value: u128) -> u64 { + u64::try_from(value).unwrap_or(u64::MAX) +} + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + + use super::*; + + fn base_proposal() -> TuiProposal { + TuiProposal { + id: "proposal-1".to_string(), + proposal_type: "deposit".to_string(), + description: "test".to_string(), + signatures_collected: 1, + signatures_required: 3, + signers: vec![1], + created_at: UNIX_EPOCH + Duration::from_secs(1), + status: TuiProposalStatus::Pending, + data_hash: "hash".to_string(), + submitted_at_block: None, + submitted_at: None, + tx_hash: None, + time_to_submit_ms: None, + executed_at_block: None, + source_block: None, + amount: None, + recipient: None, + nonce: None, + source_tx_id: None, + current_proposer: None, + is_my_turn: false, + time_until_takeover: None, + } + } + + #[test] + fn proposal_failure_reason_is_preserved() { + let mut proposal = base_proposal(); + proposal.status = TuiProposalStatus::Failed { + reason: "boom".to_string(), + }; + proposal.amount = Some(1234); + + let proto = proposal_to_proto(&proposal); + assert_eq!(proto.status, ProposalStatus::Failed as i32); + assert_eq!(proto.failure_reason, Some("boom".to_string())); + assert_eq!(proto.amount, Some(format_nock_from_nicks(1234))); + assert_eq!(proto.created_at_ms, Some(1000)); + } + + #[test] + fn proposal_amount_is_formatted_as_nock_decimal() { + let mut proposal = base_proposal(); + let nicks_per_nock = crate::tui::types::NICKS_PER_NOCK; + proposal.amount = Some(nicks_per_nock + (nicks_per_nock / 2)); + + let proto = proposal_to_proto(&proposal); + assert_eq!(proto.amount, Some("1.5".to_string())); + } + + #[test] + fn nockchain_api_status_includes_attempts_and_error() { + let since = UNIX_EPOCH + Duration::from_secs(5); + let status = TuiNockchainApiStatus::Connecting { + attempt: 2, + last_error: Some("no route".to_string()), + since, + }; + + let proto = nockchain_api_status_to_proto(&status); + assert_eq!(proto.state, ProtoNockchainApiState::Connecting as i32); + assert_eq!(proto.attempt, Some(2)); + assert_eq!(proto.last_error, Some("no route".to_string())); + assert_eq!(proto.since_ms, Some(5000)); + } + + #[test] + fn alerts_snapshot_limits_and_orders_newest_first() { + let alert_old = Alert { + id: 1, + severity: AlertSeverity::Info, + title: "old".to_string(), + message: "old".to_string(), + timestamp: UNIX_EPOCH + Duration::from_secs(1), + source: "test".to_string(), + }; + let alert_new = Alert { + id: 2, + severity: AlertSeverity::Error, + title: "new".to_string(), + message: "new".to_string(), + timestamp: UNIX_EPOCH + Duration::from_secs(5), + source: "test".to_string(), + }; + + let mut state = AlertState::new(10); + state.alerts = VecDeque::from(vec![alert_old.clone(), alert_new.clone()]); + + let snapshot = alerts_snapshot_to_proto(&state, 1); + assert_eq!(snapshot.alerts.len(), 1); + assert_eq!(snapshot.alerts[0].id, alert_new.id); + assert_eq!( + snapshot.alerts[0].severity, + ProtoAlertSeverity::Error as i32 + ); + } +} diff --git a/crates/bridge/src/tui_client.rs b/crates/bridge/src/tui_client.rs new file mode 100644 index 000000000..3e405913b --- /dev/null +++ b/crates/bridge/src/tui_client.rs @@ -0,0 +1,913 @@ +use std::collections::VecDeque; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use tokio::time::{interval, MissedTickBehavior}; +use tonic::Request; +use tracing::{debug, info, warn}; + +use crate::bridge_status::ALERT_HISTORY_CAPACITY; +use crate::health::{NodeHealthSnapshot, NodeHealthStatus}; +use crate::tui::state::TuiStatus; +use crate::tui::types::{ + Alert, AlertSeverity, AlertState, BatchStatus, BridgeTx, ChainState, DepositLogRow, + DepositLogSnapshot, DepositLogView, NetworkState, NockchainApiStatus, Proposal, ProposalState, + ProposalStatus, TransactionState, TxDirection, TxStatus, NOCK_BASE_PER_NICK, NOCK_BASE_UNIT, +}; +use crate::tui_api::proto::bridge_tui_client::BridgeTuiClient as GrpcBridgeTuiClient; + +pub mod proto { + pub use crate::tui_api::proto::*; +} + +const SNAPSHOT_POLL_INTERVAL: Duration = Duration::from_secs(1); +const RECONNECT_INTERVAL: Duration = Duration::from_secs(5); +static PROPOSAL_AMOUNT_PARSE_WARNED: AtomicBool = AtomicBool::new(false); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConnectionStatus { + NeverConnected, + Connected, + Disconnected, + Reconnecting, +} + +#[derive(Clone, Debug)] +pub struct BridgeTuiSnapshot { + pub running_state: proto::RunningState, + pub nock_hold: bool, + pub base_hold: bool, + pub nock_hold_height: Option, + pub base_hold_height: Option, + pub network_state: NetworkState, + pub deposit_log: DepositLogSnapshot, + pub proposals: ProposalState, + pub transactions: TransactionState, + pub alerts: AlertState, + pub peer_statuses: Vec, + pub last_submitted_deposit: Option, + pub last_successful_deposit: Option, +} + +impl BridgeTuiSnapshot { + pub fn from_proto(response: proto::GetSnapshotResponse) -> Self { + let mut network_state = response + .network_state + .map(network_state_from_proto) + .unwrap_or_default(); + + network_state.base_hold = response.base_hold; + network_state.nock_hold = response.nock_hold; + network_state.base_hold_height = response.base_hold_height; + network_state.nock_hold_height = response.nock_hold_height; + network_state.kernel_stopped = + response.running_state == proto::RunningState::Stopped as i32; + + BridgeTuiSnapshot { + running_state: proto::RunningState::try_from(response.running_state) + .unwrap_or(proto::RunningState::Unspecified), + nock_hold: response.nock_hold, + base_hold: response.base_hold, + nock_hold_height: response.nock_hold_height, + base_hold_height: response.base_hold_height, + network_state, + deposit_log: response + .deposit_log + .map(deposit_log_snapshot_from_proto) + .unwrap_or_default(), + proposals: response + .proposals + .map(proposal_state_from_proto) + .unwrap_or_default(), + transactions: response + .transactions + .map(transaction_state_from_proto) + .unwrap_or_else(default_transaction_state), + alerts: response + .alerts + .map(alert_state_from_proto) + .unwrap_or_default(), + peer_statuses: response + .peer_statuses + .into_iter() + .map(peer_status_from_proto) + .collect(), + last_submitted_deposit: response.last_submitted_deposit, + last_successful_deposit: response.last_successful_deposit, + } + } + + pub fn apply_to(self, status: &TuiStatus) { + status.update_network(self.network_state); + if let Ok(mut guard) = status.proposals.write() { + *guard = self.proposals; + } + if let Ok(mut guard) = status.transactions.write() { + *guard = self.transactions; + } + if let Ok(mut guard) = status.alerts.write() { + *guard = self.alerts; + } + if let Ok(mut guard) = status.health.write() { + *guard = self.peer_statuses; + } + status.update_deposit_log_snapshot(self.deposit_log); + status.set_last_deposit_nonce(self.last_successful_deposit.map(|deposit| deposit.nonce)); + } +} + +pub struct BridgeTuiClient { + server_uri: String, + client: Option>, + connection_status: ConnectionStatus, + last_successful_connection: Option, + last_connection_attempt: Instant, + last_connection_error: Option, + poll_interval: Duration, +} + +impl BridgeTuiClient { + pub async fn new(server_uri: String) -> Self { + let mut client = Self { + server_uri, + client: None, + connection_status: ConnectionStatus::NeverConnected, + last_successful_connection: None, + last_connection_attempt: Instant::now(), + last_connection_error: None, + poll_interval: SNAPSHOT_POLL_INTERVAL, + }; + client.connect_initial().await; + client + } + + pub async fn run(mut self, shared: TuiStatus, shutdown: Arc) { + let mut ticker = interval(self.poll_interval); + ticker.set_missed_tick_behavior(MissedTickBehavior::Skip); + + loop { + if shutdown.load(Ordering::Relaxed) { + break; + } + ticker.tick().await; + + if self.should_retry_connection() { + let _ = self.attempt_reconnect().await; + } + + if self.connection_status != ConnectionStatus::Connected { + continue; + } + + self.poll_snapshot(&shared).await; + } + } + + async fn connect_initial(&mut self) { + match GrpcBridgeTuiClient::connect(self.server_uri.clone()).await { + Ok(client) => { + info!("TUI connected to {}", self.server_uri); + self.client = Some(client); + self.connection_status = ConnectionStatus::Connected; + self.last_successful_connection = Some(Instant::now()); + self.last_connection_error = None; + } + Err(err) => { + warn!("Initial TUI gRPC connection failed (will retry): {}", err); + self.connection_status = ConnectionStatus::NeverConnected; + self.last_connection_error = Some(err.to_string()); + } + } + } + + async fn poll_snapshot(&mut self, shared: &TuiStatus) { + let Some(client) = self.client.as_mut() else { + return; + }; + + let view = shared.deposit_log_view(); + let request = snapshot_request_with_alert_limit(view, ALERT_HISTORY_CAPACITY); + + match client.get_snapshot(Request::new(request)).await { + Ok(response) => { + if self.connection_status != ConnectionStatus::Connected { + self.connection_status = ConnectionStatus::Connected; + self.last_successful_connection = Some(Instant::now()); + self.last_connection_error = None; + info!("TUI reconnected to {}", self.server_uri); + } + + let snapshot = BridgeTuiSnapshot::from_proto(response.into_inner()); + snapshot.apply_to(shared); + } + Err(err) => { + warn!("TUI snapshot poll failed: {}", err); + self.connection_status = ConnectionStatus::Disconnected; + self.last_connection_error = Some(err.to_string()); + self.client = None; + } + } + } + + async fn attempt_reconnect(&mut self) -> Result<(), tonic::transport::Error> { + self.last_connection_attempt = Instant::now(); + self.connection_status = ConnectionStatus::Reconnecting; + + match GrpcBridgeTuiClient::connect(self.server_uri.clone()).await { + Ok(client) => { + self.client = Some(client); + self.connection_status = ConnectionStatus::Connected; + self.last_successful_connection = Some(Instant::now()); + self.last_connection_error = None; + info!("TUI reconnected to {}", self.server_uri); + Ok(()) + } + Err(err) => { + self.connection_status = if self.last_successful_connection.is_some() { + ConnectionStatus::Disconnected + } else { + ConnectionStatus::NeverConnected + }; + self.last_connection_error = Some(err.to_string()); + debug!("TUI reconnection failed: {}", err); + Err(err) + } + } + } + + fn should_retry_connection(&self) -> bool { + matches!( + self.connection_status, + ConnectionStatus::NeverConnected | ConnectionStatus::Disconnected + ) && self.last_connection_attempt.elapsed() >= RECONNECT_INTERVAL + } +} + +pub fn snapshot_request_from_view(view: DepositLogView) -> proto::GetSnapshotRequest { + proto::GetSnapshotRequest { + deposit_log_view: Some(deposit_log_view_to_proto(view)), + alert_view: None, + } +} + +pub fn snapshot_request_with_alert_limit( + view: DepositLogView, + alert_limit: usize, +) -> proto::GetSnapshotRequest { + proto::GetSnapshotRequest { + deposit_log_view: Some(deposit_log_view_to_proto(view)), + alert_view: Some(proto::AlertView { + limit: u32::try_from(alert_limit).unwrap_or(u32::MAX), + }), + } +} + +pub fn deposit_log_view_to_proto(view: DepositLogView) -> proto::DepositLogView { + proto::DepositLogView { + offset: u64::try_from(view.offset).unwrap_or(u64::MAX), + limit: u64::try_from(view.limit).unwrap_or(u64::MAX), + } +} + +fn deposit_log_snapshot_from_proto(snapshot: proto::DepositLogSnapshot) -> DepositLogSnapshot { + DepositLogSnapshot { + total_count: snapshot.total_count, + first_epoch_nonce: snapshot.first_epoch_nonce, + rows: snapshot + .rows + .into_iter() + .map(|row| DepositLogRow { + nonce: row.nonce, + block_height: row.block_height, + tx_id_base58: row.tx_id_base58, + recipient_hex: row.recipient_hex, + amount: row.amount, + }) + .collect(), + } +} + +fn network_state_from_proto(state: proto::NetworkState) -> NetworkState { + NetworkState { + base: state.base.map(chain_state_from_proto).unwrap_or_default(), + nockchain: state + .nockchain + .map(chain_state_from_proto) + .unwrap_or_default(), + base_next_height: state.base_next_height, + nock_next_height: state.nock_next_height, + nockchain_api_status: state + .nockchain_api_status + .map(nockchain_api_status_from_proto) + .unwrap_or_default(), + pending_deposits: state.pending_deposits, + pending_withdrawals: state.pending_withdrawals, + batch_status: state + .batch_status + .map(batch_status_from_proto) + .unwrap_or(BatchStatus::Idle), + degradation_warning: state.degradation_warning, + unsettled_deposit_count: state.unsettled_deposit_count, + unsettled_withdrawal_count: state.unsettled_withdrawal_count, + base_hold: false, + nock_hold: false, + kernel_stopped: false, + base_hold_height: None, + nock_hold_height: None, + is_mainnet: state.is_mainnet, + } +} + +fn chain_state_from_proto(state: proto::ChainState) -> ChainState { + ChainState { + height: state.height, + tip_hash: state.tip_hash, + confirmations: state.confirmations, + is_syncing: state.is_syncing, + last_updated: state.last_updated_ms.map(system_time_from_millis), + } +} + +fn nockchain_api_status_from_proto(status: proto::NockchainApiStatus) -> NockchainApiStatus { + let since = status + .since_ms + .map(system_time_from_millis) + .unwrap_or_else(SystemTime::now); + let attempt = status.attempt.unwrap_or(0); + + match proto::nockchain_api_status::State::try_from(status.state) + .unwrap_or(proto::nockchain_api_status::State::Unspecified) + { + proto::nockchain_api_status::State::Connected => NockchainApiStatus::Connected { since }, + proto::nockchain_api_status::State::Connecting => NockchainApiStatus::Connecting { + attempt, + last_error: status.last_error, + since, + }, + proto::nockchain_api_status::State::Disconnected => NockchainApiStatus::Disconnected { + since, + error: status + .last_error + .unwrap_or_else(|| "disconnected".to_string()), + }, + proto::nockchain_api_status::State::Unspecified => NockchainApiStatus::default(), + } +} + +fn batch_status_from_proto(status: proto::BatchStatus) -> BatchStatus { + match status.status { + Some(proto::batch_status::Status::Idle(_)) => BatchStatus::Idle, + Some(proto::batch_status::Status::Processing(processing)) => BatchStatus::Processing { + batch_id: processing.batch_id, + progress_pct: u8::try_from(processing.progress_pct).unwrap_or(u8::MAX), + }, + Some(proto::batch_status::Status::AwaitingSignatures(awaiting)) => { + BatchStatus::AwaitingSignatures { + batch_id: awaiting.batch_id, + collected: u8::try_from(awaiting.collected).unwrap_or(u8::MAX), + required: u8::try_from(awaiting.required).unwrap_or(u8::MAX), + } + } + Some(proto::batch_status::Status::Submitting(submitting)) => BatchStatus::Submitting { + batch_id: submitting.batch_id, + }, + None => BatchStatus::Idle, + } +} + +fn proposal_state_from_proto(state: proto::ProposalState) -> ProposalState { + let max_history = crate::bridge_status::PROPOSAL_HISTORY_CAPACITY; + ProposalState { + last_submitted: state.last_submitted.map(proposal_from_proto), + pending_inbound: state + .pending_inbound + .into_iter() + .map(proposal_from_proto) + .collect(), + history: state + .history + .into_iter() + .map(proposal_from_proto) + .collect::>(), + max_history, + } +} + +fn proposal_from_proto(proposal: proto::Proposal) -> Proposal { + let status = match proto::ProposalStatus::try_from(proposal.status) + .unwrap_or(proto::ProposalStatus::Unspecified) + { + proto::ProposalStatus::Pending => ProposalStatus::Pending, + proto::ProposalStatus::Ready => ProposalStatus::Ready, + proto::ProposalStatus::Submitted => ProposalStatus::Submitted, + proto::ProposalStatus::Executed => ProposalStatus::Executed, + proto::ProposalStatus::Expired => ProposalStatus::Expired, + proto::ProposalStatus::Failed => ProposalStatus::Failed { + reason: proposal + .failure_reason + .clone() + .unwrap_or_else(|| "failed".to_string()), + }, + proto::ProposalStatus::Unspecified => ProposalStatus::Pending, + }; + + let amount = proposal.amount.as_deref().and_then(|value| { + let parsed = parse_nock_amount_to_nicks(value); + if parsed.is_none() && !PROPOSAL_AMOUNT_PARSE_WARNED.swap(true, Ordering::Relaxed) { + warn!( + amount = value, + "failed to parse proposal amount as NOCK decimal" + ); + } + parsed + }); + + Proposal { + id: proposal.id, + proposal_type: proposal.proposal_type, + description: proposal.description, + signatures_collected: u8::try_from(proposal.signatures_collected).unwrap_or(u8::MAX), + signatures_required: u8::try_from(proposal.signatures_required).unwrap_or(u8::MAX), + signers: proposal.signers, + created_at: proposal + .created_at_ms + .map(system_time_from_millis) + .unwrap_or_else(SystemTime::now), + status, + data_hash: proposal.data_hash, + submitted_at_block: proposal.submitted_at_block, + submitted_at: proposal.submitted_at_ms.map(system_time_from_millis), + tx_hash: proposal.tx_hash, + time_to_submit_ms: proposal.time_to_submit_ms, + executed_at_block: proposal.executed_at_block, + source_block: proposal.source_block, + amount, + recipient: proposal.recipient, + nonce: proposal.nonce, + source_tx_id: proposal.source_tx_id, + current_proposer: proposal.current_proposer, + is_my_turn: proposal.is_my_turn, + time_until_takeover: proposal.time_until_takeover_ms.map(duration_from_millis), + } +} + +fn transaction_state_from_proto(state: proto::TransactionState) -> TransactionState { + let max_transactions = usize::try_from(state.max_transactions) + .ok() + .filter(|value| *value > 0) + .unwrap_or(crate::bridge_status::TX_CAPACITY); + let mut transactions = VecDeque::with_capacity(max_transactions.max(state.transactions.len())); + for tx in state.transactions { + transactions.push_back(bridge_tx_from_proto(tx)); + } + TransactionState { + transactions, + max_transactions, + } +} + +/// Parse a NOCK decimal string into nicks, requiring exact nick granularity. +/// 1 NOCK = 10^16 base units, 1 NOCK = 65,536 nicks, so the base amount must +/// be divisible by NOCK_BASE_PER_NICK (no rounding). +fn parse_nock_amount_to_nicks(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed.starts_with('-') { + return None; + } + + let mut parts = trimmed.split('.'); + let whole_str = parts.next().unwrap_or(""); + let frac_str = parts.next(); + if parts.next().is_some() { + return None; + } + + let whole = if whole_str.is_empty() { + 0 + } else { + whole_str.parse::().ok()? + }; + + let frac_scaled = match frac_str { + None => 0, + Some("") => 0, + Some(frac) => { + if frac.len() > 16 || !frac.chars().all(|c| c.is_ascii_digit()) { + return None; + } + let raw = frac.parse::().ok()?; + let scale = 16usize.saturating_sub(frac.len()); + raw.checked_mul(10u128.pow(scale as u32))? + } + }; + + let base_units = whole + .checked_mul(NOCK_BASE_UNIT)? + .checked_add(frac_scaled)?; + + if base_units % NOCK_BASE_PER_NICK != 0 { + return None; + } + + Some(base_units / NOCK_BASE_PER_NICK) +} + +fn default_transaction_state() -> TransactionState { + TransactionState::new(crate::bridge_status::TX_CAPACITY) +} + +fn bridge_tx_from_proto(tx: proto::BridgeTx) -> BridgeTx { + let direction = match proto::TxDirection::try_from(tx.direction) + .unwrap_or(proto::TxDirection::Unspecified) + { + proto::TxDirection::Deposit => TxDirection::Deposit, + proto::TxDirection::Withdrawal => TxDirection::Withdrawal, + proto::TxDirection::Unspecified => TxDirection::Deposit, + }; + + let status = tx_status_from_proto(tx.status); + + BridgeTx { + tx_hash: tx.tx_hash, + direction, + from: tx.from, + to: tx.to, + amount: tx.amount.parse::().unwrap_or(0), + status, + timestamp: system_time_from_millis(tx.timestamp_ms), + base_block: tx.base_block, + nock_height: tx.nock_height, + } +} + +fn tx_status_from_proto(status: Option) -> TxStatus { + let Some(status) = status.and_then(|status| status.status) else { + return TxStatus::Pending; + }; + + match status { + proto::tx_status::Status::Pending(_) => TxStatus::Pending, + proto::tx_status::Status::Confirming(confirming) => TxStatus::Confirming { + confirmations: confirming.confirmations, + required: confirming.required, + }, + proto::tx_status::Status::Processing(_) => TxStatus::Processing, + proto::tx_status::Status::Completed(_) => TxStatus::Completed, + proto::tx_status::Status::Failed(failed) => TxStatus::Failed { + reason: failed.reason, + }, + } +} + +fn alert_state_from_proto(snapshot: proto::AlertsSnapshot) -> AlertState { + let mut state = AlertState::new(ALERT_HISTORY_CAPACITY); + state.alerts = snapshot + .alerts + .into_iter() + .map(alert_from_proto) + .collect::>(); + state.refresh_next_id_from_alerts(); + state +} + +fn alert_from_proto(alert: proto::Alert) -> Alert { + Alert { + id: alert.id, + severity: alert_severity_from_proto(alert.severity), + title: alert.title, + message: alert.message, + timestamp: system_time_from_millis(alert.created_at_ms), + source: alert.source, + } +} + +fn alert_severity_from_proto(severity: i32) -> AlertSeverity { + match proto::AlertSeverity::try_from(severity).unwrap_or(proto::AlertSeverity::Unspecified) { + proto::AlertSeverity::Info => AlertSeverity::Info, + proto::AlertSeverity::Warning => AlertSeverity::Warning, + proto::AlertSeverity::Error => AlertSeverity::Error, + proto::AlertSeverity::Critical => AlertSeverity::Critical, + proto::AlertSeverity::Unspecified => AlertSeverity::Info, + } +} + +fn peer_status_from_proto(status: proto::PeerStatus) -> NodeHealthSnapshot { + let health_status = match proto::PeerHealthStatus::try_from(status.status) + .unwrap_or(proto::PeerHealthStatus::Unspecified) + { + proto::PeerHealthStatus::Healthy => NodeHealthStatus::Healthy, + proto::PeerHealthStatus::Unreachable => NodeHealthStatus::Unreachable { + error: status + .error + .clone() + .unwrap_or_else(|| "unreachable".to_string()), + }, + proto::PeerHealthStatus::Unspecified => NodeHealthStatus::Unreachable { + error: status + .error + .clone() + .unwrap_or_else(|| "unknown".to_string()), + }, + }; + + let last_updated = status + .last_updated_ms + .map(system_time_from_millis) + .unwrap_or_else(SystemTime::now); + + NodeHealthSnapshot { + node_id: status.node_id, + address: status.address, + status: health_status, + latency_ms: status.latency_ms.map(u64_to_u128), + peer_uptime_ms: status.peer_uptime_ms, + last_updated, + } +} + +fn system_time_from_millis(ms: u64) -> SystemTime { + UNIX_EPOCH + Duration::from_millis(ms) +} + +fn duration_from_millis(ms: u64) -> Duration { + Duration::from_millis(ms) +} + +fn u64_to_u128(value: u64) -> u128 { + value as u128 +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, RwLock}; + + use super::*; + use crate::bridge_status::BridgeStatus; + use crate::tui::state::{new_log_buffer, TuiStatus}; + + #[test] + fn proposal_from_proto_parses_amount_and_failure_reason() { + let nicks_per_nock = NOCK_BASE_UNIT / NOCK_BASE_PER_NICK; + let proposal = proto::Proposal { + id: "id".to_string(), + proposal_type: "deposit".to_string(), + description: "desc".to_string(), + signatures_collected: 1, + signatures_required: 3, + signers: vec![1], + created_at_ms: Some(1_000), + status: proto::ProposalStatus::Failed as i32, + data_hash: "hash".to_string(), + submitted_at_block: None, + submitted_at_ms: None, + tx_hash: None, + time_to_submit_ms: None, + executed_at_block: None, + source_block: None, + amount: Some("1.5".to_string()), + recipient: None, + nonce: None, + source_tx_id: None, + current_proposer: None, + is_my_turn: false, + time_until_takeover_ms: None, + failure_reason: Some("boom".to_string()), + }; + + let parsed = proposal_from_proto(proposal); + assert_eq!(parsed.amount, Some(nicks_per_nock + (nicks_per_nock / 2))); + assert!(matches!(parsed.status, ProposalStatus::Failed { .. })); + if let ProposalStatus::Failed { reason } = parsed.status { + assert_eq!(reason, "boom".to_string()); + } + } + + #[test] + fn proposal_from_proto_parses_integer_nock_amounts() { + let nicks_per_nock = NOCK_BASE_UNIT / NOCK_BASE_PER_NICK; + let proposal = proto::Proposal { + id: "id".to_string(), + proposal_type: "deposit".to_string(), + description: "desc".to_string(), + signatures_collected: 1, + signatures_required: 3, + signers: vec![1], + created_at_ms: Some(1_000), + status: proto::ProposalStatus::Pending as i32, + data_hash: "hash".to_string(), + submitted_at_block: None, + submitted_at_ms: None, + tx_hash: None, + time_to_submit_ms: None, + executed_at_block: None, + source_block: None, + amount: Some("2".to_string()), + recipient: None, + nonce: None, + source_tx_id: None, + current_proposer: None, + is_my_turn: false, + time_until_takeover_ms: None, + failure_reason: None, + }; + + let parsed = proposal_from_proto(proposal); + assert_eq!(parsed.amount, Some(nicks_per_nock * 2)); + } + + #[test] + fn parse_nock_amount_to_nicks_accepts_decimal_amounts() { + let nicks_per_nock = NOCK_BASE_UNIT / NOCK_BASE_PER_NICK; + assert_eq!(parse_nock_amount_to_nicks("1"), Some(nicks_per_nock)); + assert_eq!( + parse_nock_amount_to_nicks("1.5"), + Some(nicks_per_nock + (nicks_per_nock / 2)) + ); + assert_eq!(parse_nock_amount_to_nicks("0.0000152587890625"), Some(1)); + assert_eq!( + parse_nock_amount_to_nicks("2.0000000000000000"), + Some(nicks_per_nock * 2) + ); + } + + #[test] + fn parse_nock_amount_to_nicks_rejects_invalid_amounts() { + assert_eq!(parse_nock_amount_to_nicks(""), None); + assert_eq!(parse_nock_amount_to_nicks("-1"), None); + assert_eq!(parse_nock_amount_to_nicks("1.0000000000000001"), None); + assert_eq!(parse_nock_amount_to_nicks("0.00001525878906250"), None); + assert_eq!(parse_nock_amount_to_nicks("abc"), None); + assert_eq!(parse_nock_amount_to_nicks("1.2.3"), None); + } + + #[test] + fn network_state_applies_holds_from_snapshot() { + let response = proto::GetSnapshotResponse { + running_state: proto::RunningState::Stopped as i32, + nock_hold: true, + base_hold: false, + nock_hold_height: Some(10), + base_hold_height: None, + network_state: Some(proto::NetworkState { + base: None, + nockchain: None, + pending_deposits: 0, + pending_withdrawals: 0, + unsettled_deposit_count: 0, + unsettled_withdrawal_count: 0, + batch_status: None, + is_mainnet: None, + nockchain_api_status: None, + base_next_height: None, + nock_next_height: None, + degradation_warning: None, + }), + deposit_log: None, + proposals: None, + metrics: None, + transactions: None, + alerts: None, + peer_statuses: Vec::new(), + last_submitted_deposit: None, + last_successful_deposit: None, + }; + + let snapshot = BridgeTuiSnapshot::from_proto(response); + assert!(snapshot.network_state.kernel_stopped); + assert!(snapshot.network_state.nock_hold); + assert_eq!(snapshot.network_state.nock_hold_height, Some(10)); + } + + #[test] + fn alerts_snapshot_preserves_order() { + let snapshot = proto::AlertsSnapshot { + alerts: vec![ + proto::Alert { + id: 1, + severity: proto::AlertSeverity::Warning as i32, + title: "warn".to_string(), + message: "warn".to_string(), + source: "test".to_string(), + created_at_ms: 1_000, + }, + proto::Alert { + id: 2, + severity: proto::AlertSeverity::Info as i32, + title: "info".to_string(), + message: "info".to_string(), + source: "test".to_string(), + created_at_ms: 2_000, + }, + ], + }; + + let alert_state = alert_state_from_proto(snapshot); + assert_eq!(alert_state.alerts.len(), 2); + assert_eq!(alert_state.alerts[0].id, 1); + assert_eq!(alert_state.alerts[1].id, 2); + + let mut alert_state = alert_state; + alert_state.push( + AlertSeverity::Info, + "next".to_string(), + "next".to_string(), + "test".to_string(), + ); + assert_eq!(alert_state.alerts.front().map(|alert| alert.id), Some(3)); + } + + #[test] + fn apply_snapshot_updates_tui_state() { + let health = Arc::new(RwLock::new(Vec::new())); + let core = BridgeStatus::new(health); + let tui_status = TuiStatus::new(core, new_log_buffer()); + + let response = proto::GetSnapshotResponse { + running_state: proto::RunningState::Running as i32, + nock_hold: false, + base_hold: false, + nock_hold_height: None, + base_hold_height: None, + network_state: Some(proto::NetworkState { + base: None, + nockchain: None, + pending_deposits: 3, + pending_withdrawals: 2, + unsettled_deposit_count: 1, + unsettled_withdrawal_count: 0, + batch_status: None, + is_mainnet: Some(true), + nockchain_api_status: None, + base_next_height: Some(10), + nock_next_height: None, + degradation_warning: None, + }), + deposit_log: Some(proto::DepositLogSnapshot { + total_count: 1, + first_epoch_nonce: 100, + rows: vec![proto::DepositLogRow { + nonce: 101, + block_height: 5, + tx_id_base58: "tx".to_string(), + recipient_hex: "0xabc".to_string(), + amount: 42, + }], + }), + proposals: Some(proto::ProposalState { + last_submitted: None, + pending_inbound: Vec::new(), + history: Vec::new(), + }), + metrics: None, + transactions: Some(proto::TransactionState { + transactions: vec![proto::BridgeTx { + tx_hash: "0xabc".to_string(), + direction: proto::TxDirection::Deposit as i32, + from: "0xfrom".to_string(), + to: "0xto".to_string(), + amount: "123".to_string(), + status: Some(proto::TxStatus { + status: Some(proto::tx_status::Status::Processing( + proto::TxStatusProcessing {}, + )), + }), + timestamp_ms: 1_000, + base_block: Some(42), + nock_height: None, + }], + max_transactions: 100, + }), + peer_statuses: vec![proto::PeerStatus { + node_id: 1, + address: "http://localhost:1".to_string(), + status: proto::PeerHealthStatus::Healthy as i32, + error: None, + latency_ms: Some(5), + peer_uptime_ms: Some(10), + last_updated_ms: Some(1_000), + }], + last_submitted_deposit: None, + last_successful_deposit: Some(proto::SuccessfulDeposit { + tx_id: None, + name_first: None, + name_last: None, + recipient: None, + amount: 0, + block_height: 0, + as_of: None, + nonce: 7, + }), + alerts: Some(proto::AlertsSnapshot { alerts: Vec::new() }), + }; + + let snapshot = BridgeTuiSnapshot::from_proto(response); + snapshot.apply_to(&tui_status); + + assert_eq!(tui_status.network().pending_deposits, 3); + assert_eq!(tui_status.deposit_log_snapshot().total_count, 1); + assert_eq!(tui_status.health_snapshots().len(), 1); + assert_eq!(tui_status.last_deposit_nonce(), Some(7)); + assert_eq!(tui_status.transactions().transactions.len(), 1); + } +} diff --git a/crates/bridge/src/types.rs b/crates/bridge/src/types.rs new file mode 100644 index 000000000..8632de300 --- /dev/null +++ b/crates/bridge/src/types.rs @@ -0,0 +1,2617 @@ +use alloy::primitives::U256; +use nockchain_math::belt::Belt; +pub use nockchain_types::tx_engine::common::Hash as Tip5Hash; +use nockchain_types::tx_engine::common::Hash as NockPkh; +use nockchain_types::v1::Name; +pub use nockchain_types::EthAddress; +use nockvm::noun::{Noun, NounAllocator}; +use noun_serde::{NounDecode, NounEncode}; +use serde::{Deserialize, Serialize}; +use serde_bytes::ByteBuf; +use tiny_keccak::{Hasher, Keccak}; + +/// Unique identifier for a deposit across all nodes. +/// Derived from the effect payload: (as_of, name). +/// This is used as a key for signature aggregation in the ProposalCache. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DepositId { + /// Hashchain hash from effect payload + pub as_of: Tip5Hash, + /// Note name with first/last from effect payload + pub name: Name, +} + +impl std::hash::Hash for DepositId { + fn hash(&self, state: &mut H) { + // Hash as_of + for limb in &self.as_of.0 { + limb.0.hash(state); + } + // Hash name.first + for limb in &self.name.first.0 { + limb.0.hash(state); + } + // Hash name.last + for limb in &self.name.last.0 { + limb.0.hash(state); + } + } +} + +impl DepositId { + /// Construct a DepositId from an NockDepositRequestData effect payload. + pub fn from_effect_payload(request: &NockDepositRequestData) -> Self { + Self { + as_of: request.as_of.clone(), + name: request.name.clone(), + } + } + + /// Serialize DepositId to bytes for storage or transmission. + /// Format: as_of (40 bytes: 5 × u64 BE) || name.first (40 bytes) || name.last (40 bytes) = 120 bytes total + pub fn to_bytes(&self) -> Vec { + let mut bytes = Vec::with_capacity(120); + + // Encode as_of (5 × u64 big-endian = 40 bytes) + for limb in &self.as_of.0 { + bytes.extend_from_slice(&limb.0.to_be_bytes()); + } + + // Encode name.first (5 × u64 big-endian = 40 bytes) + for limb in &self.name.first.0 { + bytes.extend_from_slice(&limb.0.to_be_bytes()); + } + + // Encode name.last (5 × u64 big-endian = 40 bytes) + for limb in &self.name.last.0 { + bytes.extend_from_slice(&limb.0.to_be_bytes()); + } + + bytes + } + + /// Deserialize DepositId from bytes. + /// Expects exactly 120 bytes: as_of (40) || name.first (40) || name.last (40) + pub fn from_bytes(bytes: &[u8]) -> Result { + if bytes.len() != 120 { + return Err(format!( + "expected 120 bytes for DepositId, got {}", + bytes.len() + )); + } + + // Parse as_of (first 40 bytes) + let as_of = Tip5Hash::from_be_limb_bytes(&bytes[0..40]).map_err(|err| err.to_string())?; + + // Parse name.first (next 40 bytes) + let name_first = + Tip5Hash::from_be_limb_bytes(&bytes[40..80]).map_err(|err| err.to_string())?; + + // Parse name.last (last 40 bytes) + let name_last = + Tip5Hash::from_be_limb_bytes(&bytes[80..120]).map_err(|err| err.to_string())?; + + Ok(Self { + as_of, + name: Name::new(name_first, name_last), + }) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct SignatureSet { + pub eth_signatures: Vec, + pub nock_signatures: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct DepositSubmission { + pub tx_id: Tip5Hash, + /// First component of the nname (hash of the lock) + pub name_first: Tip5Hash, + /// Last component of the nname (hash of the source) + pub name_last: Tip5Hash, + pub recipient: [u8; 20], + pub amount: u128, + pub block_height: u64, + pub as_of: Tip5Hash, + pub nonce: u64, + pub signatures: SignatureSet, +} + +pub fn keccak256(data: &[u8]) -> [u8; 32] { + let mut hasher = Keccak::v256(); + hasher.update(data); + let mut out = [0u8; 32]; + hasher.finalize(&mut out); + out +} + +/// Nicks per NOCK on Nockchain (2^16) +const NICKS_PER_NOCK: u128 = 65_536; + +/// Base unit for Nock token (10^16) - Nock.sol uses 16 decimals +const NOCK_BASE_UNIT: u128 = 10_000_000_000_000_000; + +/// Conversion factor: NOCK base units per nick +/// 1 nick = 10^16 / 65,536 = 152,587,890,625 NOCK base units +const NOCK_BASE_PER_NICK: u128 = NOCK_BASE_UNIT / NICKS_PER_NOCK; + +/// Compute proposal hash: +/// `keccak256(abi.encodePacked(txId[0..4], name_first[0..4], name_last[0..4], recipient, amount, blockHeight, asOf[0..4], nonce))` +/// +/// NOTE: `amount` is in nicks (Nockchain internal units), but the hash is computed +/// with the amount converted to NOCK base units to match the Solidity contract. +#[allow(clippy::too_many_arguments)] +pub fn compute_proposal_hash( + tx_id: &[u64; 5], + name_first: &[u64; 5], + name_last: &[u64; 5], + recipient: &[u8; 20], + amount: u64, + block_height: u64, + as_of: &[u64; 5], + nonce: u64, +) -> [u8; 32] { + let mut encoded = Vec::new(); + + for limb in tx_id { + encoded.extend_from_slice(&limb.to_be_bytes()); + } + for limb in name_first { + encoded.extend_from_slice(&limb.to_be_bytes()); + } + for limb in name_last { + encoded.extend_from_slice(&limb.to_be_bytes()); + } + encoded.extend_from_slice(recipient); + + // Convert nicks to NOCK base units to match Solidity contract + let amount_nock = U256::from(amount) * U256::from(NOCK_BASE_PER_NICK); + encoded.extend_from_slice(&amount_nock.to_be_bytes::<32>()); + + let block_height_u256 = U256::from(block_height); + encoded.extend_from_slice(&block_height_u256.to_be_bytes::<32>()); + + for limb in as_of { + encoded.extend_from_slice(&limb.to_be_bytes()); + } + + let nonce_u256 = U256::from(nonce); + encoded.extend_from_slice(&nonce_u256.to_be_bytes::<32>()); + + keccak256(&encoded) +} + +/// Ethereum ECDSA signature (r, s, v) +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct EthSignatureParts { + pub r: [u8; 32], + pub s: [u8; 32], + pub v: u64, +} + +impl EthSignatureParts { + pub fn validate(&self) -> Result<(), String> { + let is_zero = |arr: &[u8; 32]| arr.iter().all(|&b| b == 0); + + if is_zero(&self.r) { + return Err("r component cannot be zero".to_string()); + } + + if is_zero(&self.s) { + return Err("s component cannot be zero".to_string()); + } + + if self.v != 27 && self.v != 28 { + return Err(format!("v component must be 27 or 28, got {}", self.v)); + } + + Ok(()) + } +} + +impl NounEncode for EthSignatureParts { + fn to_noun(&self, allocator: &mut A) -> nockvm::noun::Noun { + let r_atom = unsafe { + let mut ia = nockvm::noun::IndirectAtom::new_raw_bytes(allocator, 32, self.r.as_ptr()); + ia.normalize_as_atom().as_noun() + }; + let s_atom = unsafe { + let mut ia = nockvm::noun::IndirectAtom::new_raw_bytes(allocator, 32, self.s.as_ptr()); + ia.normalize_as_atom().as_noun() + }; + let v_atom = nockvm::noun::Atom::new(allocator, self.v).as_noun(); + let inner = nockvm::noun::T(allocator, &[s_atom, v_atom]); + nockvm::noun::T(allocator, &[r_atom, inner]) + } +} + +impl NounDecode for EthSignatureParts { + fn from_noun(noun: &nockvm::noun::Noun) -> Result { + let c0 = noun + .as_cell() + .map_err(|_| noun_serde::NounDecodeError::ExpectedCell)?; + let r_bytes = c0 + .head() + .as_atom() + .map_err(|_| noun_serde::NounDecodeError::ExpectedAtom)? + .to_be_bytes(); + let c1 = c0 + .tail() + .as_cell() + .map_err(|_| noun_serde::NounDecodeError::ExpectedCell)?; + let s_bytes = c1 + .head() + .as_atom() + .map_err(|_| noun_serde::NounDecodeError::ExpectedAtom)? + .to_be_bytes(); + let v = c1 + .tail() + .as_atom() + .map_err(|_| noun_serde::NounDecodeError::ExpectedAtom)? + .as_u64() + .map_err(|_| noun_serde::NounDecodeError::Custom("Expected small atom".into()))?; + + fn to_fixed_32(mut b: Vec) -> [u8; 32] { + if b.len() > 32 { + b = b.split_off(b.len() - 32); + } else if b.len() < 32 { + let mut pad = vec![0u8; 32 - b.len()]; + pad.extend_from_slice(&b); + b = pad; + } + let mut out = [0u8; 32]; + out.copy_from_slice(&b); + out + } + + Ok(EthSignatureParts { + r: to_fixed_32(r_bytes), + s: to_fixed_32(s_bytes), + v, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ByteArray(pub [u8; N]); + +impl NounEncode for ByteArray { + fn to_noun(&self, allocator: &mut A) -> nockvm::noun::Noun { + let mut atoms = Vec::new(); + for &byte in &self.0 { + atoms.push(nockvm::noun::Atom::new(allocator, byte as u64).as_noun()); + } + + let mut result = nockvm::noun::D(0); + for atom in atoms.into_iter().rev() { + result = nockvm::noun::T(allocator, &[atom, result]); + } + result + } +} + +impl NounDecode for ByteArray { + fn from_noun(noun: &nockvm::noun::Noun) -> Result { + let mut bytes = Vec::new(); + let mut current = *noun; + + while let Ok(cell) = current.as_cell() { + let head = cell.head(); + let byte = head + .as_atom() + .map_err(|_| noun_serde::NounDecodeError::ExpectedAtom)? + .as_u64() + .map_err(|_| { + noun_serde::NounDecodeError::Custom("Invalid byte value".to_string()) + })?; + + if byte > 255 { + return Err(noun_serde::NounDecodeError::Custom( + "Byte value too large".to_string(), + )); + } + + bytes.push(byte as u8); + current = cell.tail(); + } + + if let Ok(atom) = current.as_atom() { + if atom.as_u64()? != 0 { + return Err(noun_serde::NounDecodeError::Custom( + "Invalid list termination".to_string(), + )); + } + } else { + return Err(noun_serde::NounDecodeError::ExpectedAtom); + } + + if bytes.len() != N { + return Err(noun_serde::NounDecodeError::Custom(format!( + "Expected {} bytes, got {}", + N, + bytes.len() + ))); + } + + let mut array = [0u8; N]; + array.copy_from_slice(&bytes); + Ok(ByteArray(array)) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AtomBytes(pub Vec); + +impl AtomBytes { + pub fn as_slice(&self) -> &[u8] { + &self.0 + } +} + +impl std::ops::Deref for AtomBytes { + type Target = [u8]; + + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl AsRef<[u8]> for AtomBytes { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +impl From> for AtomBytes { + fn from(value: Vec) -> Self { + Self(value) + } +} + +impl NounEncode for AtomBytes { + fn to_noun(&self, allocator: &mut A) -> nockvm::noun::Noun { + if self.0.is_empty() { + return nockvm::noun::Atom::new(allocator, 0).as_noun(); + } + unsafe { + let mut ia = + nockvm::noun::IndirectAtom::new_raw_bytes(allocator, self.0.len(), self.0.as_ptr()); + ia.normalize_as_atom().as_noun() + } + } +} + +impl NounDecode for AtomBytes { + fn from_noun(noun: &nockvm::noun::Noun) -> Result { + let atom = noun + .as_atom() + .map_err(|_| noun_serde::NounDecodeError::ExpectedAtom)?; + let bytes = atom.as_ne_bytes(); + let len = bytes + .iter() + .rposition(|&b| b != 0) + .map(|i| i + 1) + .unwrap_or(0); + Ok(Self(bytes[..len].to_vec())) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SchnorrSecretKey(pub [Belt; 8]); + +impl SchnorrSecretKey { + pub fn limbs(&self) -> &[Belt; 8] { + &self.0 + } +} + +impl From<[Belt; 8]> for SchnorrSecretKey { + fn from(value: [Belt; 8]) -> Self { + Self(value) + } +} + +impl NounEncode for SchnorrSecretKey { + fn to_noun(&self, allocator: &mut A) -> nockvm::noun::Noun { + encode_belt_array(&self.0, allocator) + } +} + +impl NounDecode for SchnorrSecretKey { + fn from_noun(noun: &nockvm::noun::Noun) -> Result { + Ok(SchnorrSecretKey(decode_belt_array(noun)?)) + } +} + +/// Bridge constants matching Hoon `bridge-constants` type. +/// These are static parameters that configure bridge behavior. +#[derive(Debug, Clone, PartialEq, Eq, NounEncode, NounDecode)] +pub struct BridgeConstants { + /// Version tag (always 0 for now) + pub version: u64, + /// Minimum signatures required (default: 3) + pub min_signers: u64, + /// Total number of bridge nodes (default: 5) + pub total_signers: u64, + /// Minimum nocks for a bridge event (default: 1_000_000) + pub minimum_event_nocks: u64, + /// Fee per nock in nicks (default: 195) + pub nicks_fee_per_nock: u64, + /// Base blocks per chunk (default: 100) + pub base_blocks_chunk: u64, + /// Base chain start height (default: 33_387_036) + pub base_start_height: u64, + /// Nockchain start height (default: 25) + pub nockchain_start_height: u64, +} + +impl Default for BridgeConstants { + fn default() -> Self { + Self { + version: 0, + min_signers: 3, + total_signers: 5, + minimum_event_nocks: 1_000_000, + nicks_fee_per_nock: 195, + base_blocks_chunk: 100, + base_start_height: 39_694_000, + nockchain_start_height: 46_810, + } + } +} + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct BridgeCause(pub u64, pub BridgeCauseVariant); + +impl BridgeCause { + pub fn cfg_load(config: Option) -> Self { + Self(0, BridgeCauseVariant::ConfigLoad(config)) + } + + pub fn set_constants(constants: BridgeConstants) -> Self { + Self(0, BridgeCauseVariant::SetConstants(constants)) + } + + pub fn stop(last: StopLastBlocks) -> Self { + Self(0, BridgeCauseVariant::Stop(last)) + } + + pub fn start() -> Self { + Self(0, BridgeCauseVariant::Start(NullTag)) + } +} + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct BaseCallSigData(pub EthSignatureParts, pub AtomBytes); + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub enum BridgeCauseVariant { + #[noun(tag = "base-blocks")] + BaseBlocks(RawBaseBlocks), + + #[noun(tag = "nockchain-block")] + NockchainBlock(NockchainBlockCause), + + #[noun(tag = "proposed-base-call")] + ProposedBaseCall(ProposedBaseCallData), + + #[noun(tag = "proposed-nock-tx")] + ProposedNockTx(nockchain_types::v1::RawTx), + + #[noun(tag = "base-call-sig")] + BaseCallSig(BaseCallSigData), + + #[noun(tag = "cfg-load")] + ConfigLoad(Option), + + #[noun(tag = "set-constants")] + SetConstants(BridgeConstants), + + #[noun(tag = "stop")] + Stop(StopLastBlocks), + + #[noun(tag = "start")] + Start(NullTag), +} + +// TODO: generalize this or move it up into the types crate +#[derive(Debug, Clone, Copy, Default)] +pub struct NullTag; + +impl NounEncode for NullTag { + fn to_noun(&self, _allocator: &mut A) -> Noun { + nockvm::noun::D(0) + } +} + +impl NounDecode for NullTag { + fn from_noun(noun: &Noun) -> Result { + let atom = noun + .as_atom() + .map_err(|_| noun_serde::NounDecodeError::ExpectedAtom)?; + if atom.as_u64()? == 0 { + Ok(NullTag) + } else { + Err(noun_serde::NounDecodeError::Custom( + "expected ~ (null), got non-zero atom".into(), + )) + } + } +} + +pub type RawBaseBlocks = Vec; + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct RawBaseBlockEntry { + pub height: u64, + pub block_id: AtomBytes, + pub parent_block_id: AtomBytes, + pub txs: Vec, +} + +#[derive(Clone)] +pub struct NockchainBlockCause { + pub page_slab: nockapp::noun::slab::NounSlab, + pub page_noun: nockvm::noun::Noun, + pub txs: NockchainTxsMap, +} + +impl std::fmt::Debug for NockchainBlockCause { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("NockchainBlockCause") + .field("txs", &self.txs) + .finish() + } +} + +impl NockchainBlockCause { + pub fn new( + page_slab: nockapp::noun::slab::NounSlab, + page_noun: nockvm::noun::Noun, + txs: NockchainTxsMap, + ) -> Self { + Self { + page_slab, + page_noun, + txs, + } + } +} + +impl NounEncode for NockchainBlockCause { + fn to_noun(&self, allocator: &mut A) -> nockvm::noun::Noun { + use nockapp::noun::NounAllocatorExt; + + let page_noun = allocator.copy_into(self.page_noun); + let txs_noun = self.txs.to_noun(allocator); + nockvm::noun::T(allocator, &[page_noun, txs_noun]) + } +} + +impl NounDecode for NockchainBlockCause { + fn from_noun(noun: &nockvm::noun::Noun) -> Result { + use nockapp::noun::slab::{NockJammer, NounSlab}; + + let cell = noun + .as_cell() + .map_err(|_| noun_serde::NounDecodeError::ExpectedCell)?; + + let mut page_slab: NounSlab = NounSlab::new(); + let page_noun = page_slab.copy_into(cell.head()); + + let txs = NockchainTxsMap::from_noun(&cell.tail())?; + + Ok(Self { + page_slab, + page_noun, + txs, + }) + } +} + +#[derive(Debug, Clone)] +pub struct NockchainTxsMap(pub Vec<(nockchain_types::tx_engine::common::TxId, Tx)>); + +impl NounEncode for NockchainTxsMap { + fn to_noun(&self, allocator: &mut A) -> nockvm::noun::Noun { + use nockchain_math::zoon::common::DefaultTipHasher; + use nockchain_math::zoon::zmap; + self.0.iter().fold(nockvm::noun::D(0), |acc, (tx_id, tx)| { + let mut key = tx_id.to_noun(allocator); + let mut value = tx.to_noun(allocator); + zmap::z_map_put(allocator, &acc, &mut key, &mut value, &DefaultTipHasher) + .expect("failed to encode txs map") + }) + } +} + +impl NounDecode for NockchainTxsMap { + fn from_noun(noun: &nockvm::noun::Noun) -> Result { + fn traverse( + node: &nockvm::noun::Noun, + acc: &mut Vec<(nockchain_types::tx_engine::common::TxId, Tx)>, + ) -> Result<(), noun_serde::NounDecodeError> { + if let Ok(atom) = node.as_atom() { + if atom.as_u64()? == 0 { + return Ok(()); + } + return Err(noun_serde::NounDecodeError::ExpectedCell); + } + let cell = node + .as_cell() + .map_err(|_| noun_serde::NounDecodeError::ExpectedCell)?; + let kv = cell + .head() + .as_cell() + .map_err(|_| noun_serde::NounDecodeError::ExpectedCell)?; + let tx_id = nockchain_types::tx_engine::common::TxId::from_noun(&kv.head())?; + let tx = Tx::from_noun(&kv.tail())?; + acc.push((tx_id, tx)); + let branches = cell + .tail() + .as_cell() + .map_err(|_| noun_serde::NounDecodeError::ExpectedCell)?; + traverse(&branches.head(), acc)?; + traverse(&branches.tail(), acc)?; + Ok(()) + } + let mut acc = Vec::new(); + traverse(noun, &mut acc)?; + Ok(NockchainTxsMap(acc)) + } +} + +#[derive(Debug, Clone)] +pub enum Tx { + V1(TxV1), +} + +impl NounEncode for Tx { + fn to_noun(&self, allocator: &mut A) -> nockvm::noun::Noun { + match self { + Tx::V1(tx) => tx.to_noun(allocator), + } + } +} + +impl NounDecode for Tx { + fn from_noun(noun: &nockvm::noun::Noun) -> Result { + let cell = noun + .as_cell() + .map_err(|_| noun_serde::NounDecodeError::ExpectedCell)?; + let tag = cell + .head() + .as_atom() + .map_err(|_| noun_serde::NounDecodeError::ExpectedAtom)? + .as_u64() + .map_err(|_| noun_serde::NounDecodeError::Custom("tx tag too large".into()))?; + match tag { + 1 => Ok(Tx::V1(TxV1::from_noun(noun)?)), + _ => Err(noun_serde::NounDecodeError::Custom(format!( + "unsupported tx version: {}", + tag + ))), + } + } +} + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct TxV1 { + pub version: u64, + pub raw_tx: nockchain_types::v1::RawTx, + pub total_size: u64, + pub outputs: OutputsV1, +} + +#[derive(Debug, Clone)] +pub struct OutputsV1(pub Vec); + +impl NounEncode for OutputsV1 { + fn to_noun(&self, allocator: &mut A) -> nockvm::noun::Noun { + use nockchain_math::zoon::common::DefaultTipHasher; + use nockchain_math::zoon::zset; + self.0.iter().fold(nockvm::noun::D(0), |acc, output| { + let mut value = output.to_noun(allocator); + zset::z_set_put(allocator, &acc, &mut value, &DefaultTipHasher) + .expect("failed to encode outputs set") + }) + } +} + +impl NounDecode for OutputsV1 { + fn from_noun(noun: &nockvm::noun::Noun) -> Result { + fn traverse( + node: &nockvm::noun::Noun, + acc: &mut Vec, + ) -> Result<(), noun_serde::NounDecodeError> { + if let Ok(atom) = node.as_atom() { + if atom.as_u64()? == 0 { + return Ok(()); + } + return Err(noun_serde::NounDecodeError::ExpectedCell); + } + let cell = node + .as_cell() + .map_err(|_| noun_serde::NounDecodeError::ExpectedCell)?; + acc.push(OutputV1::from_noun(&cell.head())?); + let branches = cell + .tail() + .as_cell() + .map_err(|_| noun_serde::NounDecodeError::ExpectedCell)?; + traverse(&branches.head(), acc)?; + traverse(&branches.tail(), acc)?; + Ok(()) + } + let mut acc = Vec::new(); + traverse(noun, &mut acc)?; + Ok(OutputsV1(acc)) + } +} + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct OutputV1 { + pub note: nockchain_types::v1::Note, + pub seeds: nockchain_types::v1::Seeds, +} + +/// ProposedBaseCallData contains the list of eth signature requests +/// that are being proposed for signing. This matches the Hoon type: +/// `(list nock-deposit-request)` +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct ProposedBaseCallData { + pub requests: Vec, +} + +#[derive(Debug, Clone)] +pub struct BaseBlockRef { + pub height: u64, + pub block_id: AtomBytes, + pub parent_block_id: AtomBytes, +} + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct BaseWithdrawalEntry { + pub base_tx_id: AtomBytes, + pub withdrawal: Withdrawal, +} + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct Withdrawal { + pub base_tx_id: AtomBytes, + pub dest: Option, + pub raw_amount: u64, +} + +/// Deposit from unsettled-deposits in Hoon bridge state. +/// Matches the Hoon type: +/// ```hoon +/// $: =tx-id +/// =nname +/// dest=(unit base-addr) +/// raw-amount=coins +/// == +/// ``` +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct Deposit { + pub tx_id: Tip5Hash, + pub nname: Tip5Hash, + pub dest: Option, + pub raw_amount: u64, +} + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct BaseDepositSettlementEntry { + pub base_tx_id: AtomBytes, + pub settlement: DepositSettlement, +} + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct DepositSettlement { + pub base_tx_id: AtomBytes, + pub data: DepositSettlementData, +} + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct BaseEvent { + pub base_event_id: AtomBytes, + pub content: BaseEventContent, +} + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub enum BaseEventContent { + #[noun(tag = "deposit-processed")] + DepositProcessed { + nock_tx_id: Tip5Hash, + note_name: Name, + recipient: EthAddress, + amount: u64, + block_height: u64, + as_of: Tip5Hash, + nonce: u64, + }, + #[noun(tag = "bridge-node-updated")] + BridgeNodeUpdated(NullTag), + #[noun(tag = "burn-for-withdrawal")] + BurnForWithdrawal { + burner: EthAddress, + amount: u64, + lock_root: Tip5Hash, + }, +} + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct NodeConfig { + pub node_id: u64, + pub nodes: Vec, + pub my_eth_key: AtomBytes, + pub my_nock_key: SchnorrSecretKey, +} + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct NodeInfo { + pub ip: String, + pub eth_pubkey: AtomBytes, + /// Nockchain public key hash (PKH) - base58 encoded ~52 chars + pub nock_pkh: NockPkh, +} + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct BridgeEffect { + // TODO: I have no idea what the tag is doing, it doesn't seem to have an effect on the decoding result + //#[noun(tag = "0")] + pub version: u64, + #[noun(flatten)] + pub variant: BridgeEffectVariant, +} + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct BaseCallData(pub Vec, pub AtomBytes); + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct CommitNockDepositsData( + pub nockchain_types::tx_engine::common::SchnorrPubkey, + pub AtomBytes, +); + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct ProposeNockchainTxData( + pub nockchain_types::tx_engine::common::SchnorrPubkey, + pub AtomBytes, +); + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct StopTipBase { + pub base_hash: Tip5Hash, + pub height: u64, +} + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct StopTipNock { + pub nock_hash: Tip5Hash, + pub height: u64, +} + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct StopLastBlocks { + pub base: StopTipBase, + pub nock: StopTipNock, +} + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct StopEffectData { + pub reason: String, + pub last: StopLastBlocks, +} + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub enum BridgeEffectVariant { + #[noun(tag = "base-call")] + BaseCall(BaseCallData), + + #[noun(tag = "assemble-base-call")] + AssembleBaseCall(DepositSettlementData), + + #[noun(tag = "nock-deposit-request")] + NockDepositRequest(NockDepositRequestKernelData), + + #[noun(tag = "commit-nock-deposits")] + CommitNockDeposits(Vec), + + #[noun(tag = "nockchain-tx")] + NockchainTx(NockchainTxData), + + #[noun(tag = "propose-nockchain-tx")] + ProposeNockchainTx(ProposeNockchainTxData), + + #[noun(tag = "grpc")] + Grpc(GrpcEffect), + + #[noun(tag = "stop")] + Stop(StopEffectData), +} + +/// Nock deposit request data matching Hoon `nock-deposit-request`: +/// `[tx-id=tx-id:t name=nname:t recipient=base-addr amount=@ block-height=@ as-of=nock-hash nonce=@]` +/// +/// This structure contains all fields needed to compute the keccak256 hash +/// that will be signed. The hash is computed over the ABI-encoded tuple of +/// these fields: `keccak256(abi.encode(txId, name.first, name.last, recipient, amount, blockHeight, asOf, nonce))` +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct NockDepositRequestData { + pub tx_id: Tip5Hash, + pub name: Name, + pub recipient: EthAddress, + pub amount: u64, + pub block_height: u64, + pub as_of: Tip5Hash, + pub nonce: u64, +} + +impl NockDepositRequestData { + pub fn compute_proposal_hash(&self) -> [u8; 32] { + let tx_id_limbs = self.tx_id.to_array(); + let name_first_limbs = self.name.first.to_array(); + let name_last_limbs = self.name.last.to_array(); + let as_of_limbs = self.as_of.to_array(); + + compute_proposal_hash( + &tx_id_limbs, + &name_first_limbs, + &name_last_limbs, + self.recipient.as_bytes(), + self.amount, + self.block_height, + &as_of_limbs, + self.nonce, + ) + } +} + +/// Nock deposit request as emitted by the kernel (nonce-free). +/// +/// Matches the Hoon type: +/// `[tx-id=tx-id:t name=nname:t recipient=base-addr amount=@ block-height=@ as-of=nock-hash]` +/// +/// The Rust runtime assigns `nonce` deterministically and constructs the final +/// `NockDepositRequestData` used for proposal hashing, signing, and contract submission. +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct NockDepositRequestKernelData { + pub tx_id: Tip5Hash, + pub name: Name, + pub recipient: EthAddress, + pub amount: u64, + pub block_height: u64, + pub as_of: Tip5Hash, +} + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct NockchainTxData { + pub tx: nockchain_types::v1::RawTx, +} + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub enum GrpcEffect { + #[noun(tag = "peek")] + Peek(GrpcPeekData), + #[noun(tag = "call")] + Call(GrpcCallData), +} + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct GrpcPeekData { + pub pid: u64, + pub typ: AtomBytes, + pub path: Vec, +} + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct GrpcCallData { + pub ip: String, + pub method: AtomBytes, + pub data: AtomBytes, +} + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct DepositSettlementData { + pub counterpart: nockchain_types::tx_engine::common::TxId, + pub as_of: nockchain_types::tx_engine::common::Hash, + pub dest: AtomBytes, + pub settled_amount: u64, + pub fees: Vec, + pub bridge_fee: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, NounEncode, NounDecode)] +pub struct DepositSettlementFee { + pub address: AtomBytes, + pub amount: u64, +} + +pub type NounDigest = Tip5Hash; + +pub fn zero_tip5_hash() -> Tip5Hash { + Tip5Hash([Belt(0); 5]) +} + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct HeightPeek { + pub inner: Option>, +} + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct HoldInfo { + pub hash: Tip5Hash, + pub height: u64, +} + +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct HoldPeek { + pub inner: Option>, +} + +/// Peek response for unsettled deposit lookup. +/// Matches Hoon peek response: `[~ [~ deposit]]` or `[~ ~]` +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct DepositPeek { + pub inner: Option>, +} + +/// Peek response for count queries (deposits, withdrawals). +/// Matches Hoon peek response: `[~ ~ @ud]` +/// The structure is (unit (unit @ud)) +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct CountPeek { + pub inner: Option>, +} + +/// Peek response for boolean queries (hold status). +/// Matches Hoon peek response: `[~ ~ ?]` +/// The structure is (unit (unit ?)) +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct BoolPeek { + pub inner: Option>, +} + +/// Peek response for stop-info. +/// Matches Hoon peek response: `[~ ~ stop-info]` +/// The structure is (unit (unit stop-info)) +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct StopInfoPeek { + pub inner: Option>, +} + +/// Peek response for lists of nock deposit requests. +/// Matches Hoon peek response: `[~ ~ (list nock-deposit-request)]` +/// The structure is (unit (unit (list nock-deposit-request))). +#[derive(Debug, Clone, NounEncode, NounDecode)] +pub struct NockDepositRequestsPeek { + pub inner: Option>>, +} + +/// Aggregated kernel state counts for TUI display. +#[derive(Debug, Clone, Default)] +pub struct BridgeState { + /// Number of deposits awaiting settlement on Base + pub unsettled_deposits: u64, + /// Number of withdrawals awaiting settlement on Nockchain + pub unsettled_withdrawals: u64, + /// Latest observed Base tip hash from the driver (hex with 0x prefix). + pub base_tip_hash: Option, + /// Next base hashchain height (kernel expects next block height). + pub base_next_height: Option, + /// Next nock hashchain height (kernel expects next block height). + pub nock_next_height: Option, + /// Whether base chain processing is held waiting for nock + pub base_hold: bool, + /// Whether nock chain processing is held waiting for base + pub nock_hold: bool, + /// Whether the kernel has latched a stop state. + pub kernel_stopped: bool, + /// Whether the kernel is in fakenet mode (true) or mainnet mode (false). + /// None indicates the status hasn't been fetched yet. + pub is_fakenet: Option, + /// Counterparty nock height that releases the base hold. + pub base_hold_height: Option, + /// Counterparty base height that releases the nock hold. + pub nock_hold_height: Option, +} + +fn encode_belt_array( + limbs: &[Belt; N], + allocator: &mut A, +) -> Noun { + let mut tail = limbs[N - 1].to_noun(allocator); + for limb in limbs[..N - 1].iter().rev() { + let head = limb.to_noun(allocator); + tail = nockvm::noun::T(allocator, &[head, tail]); + } + tail +} + +fn decode_belt_array( + noun: &Noun, +) -> Result<[Belt; N], noun_serde::NounDecodeError> { + let mut result = [Belt(0); N]; + let mut current = *noun; + for (idx, item) in result.iter_mut().enumerate() { + if idx == N - 1 { + *item = Belt::from_noun(¤t)?; + } else { + let cell = current + .as_cell() + .map_err(|_| noun_serde::NounDecodeError::ExpectedCell)?; + *item = Belt::from_noun(&cell.head())?; + current = cell.tail(); + } + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use nockapp::noun::slab::{NockJammer, NounSlab}; + use nockchain_math::belt::Belt; + use noun_serde::{NounDecode, NounEncode}; + use tracing::{debug, info}; + + use super::*; + + fn init_test_logging() { + let _ = tracing_subscriber::fmt() + .with_test_writer() + .with_max_level(tracing::Level::DEBUG) + .try_init(); + } + + fn sample_base_blocks_cause() -> RawBaseBlocks { + vec![RawBaseBlockEntry { + height: 12345, + block_id: AtomBytes(vec![0xde, 0xad, 0xbe, 0xef]), + parent_block_id: AtomBytes(vec![0xca, 0xfe, 0xba, 0xbe]), + txs: vec![], + }] + } + + fn sample_nockchain_block_cause() -> NockchainBlockCause { + use nockchain_types::tx_engine::common::{BigNum, CoinbaseSplit, Hash as NockHash, Page}; + use noun_serde::NounEncode; + + let page = Page { + digest: NockHash([Belt(0); 5]), + pow: None, + parent: NockHash([Belt(0); 5]), + tx_ids: vec![], + coinbase: CoinbaseSplit::V0(vec![]), + timestamp: 0, + epoch_counter: 0, + target: BigNum::from_u64(0), + accumulated_work: BigNum::from_u64(0), + height: 0, + msg: vec![], + }; + + let mut page_slab: NounSlab = NounSlab::new(); + let page_noun = page.to_noun(&mut page_slab); + + NockchainBlockCause::new(page_slab, page_noun, NockchainTxsMap(vec![])) + } + + #[test] + fn test_cause_cfg_load_none_roundtrip() { + init_test_logging(); + info!("Starting cfg-load (None) cause roundtrip test"); + + let mut allocator: NounSlab = NounSlab::new(); + + let original_cause = BridgeCause::cfg_load(None); + debug!("Created original cause with None config"); + + let encoded_noun = original_cause.to_noun(&mut allocator); + info!("Encoded cause to noun"); + + let decoded_cause = BridgeCause::from_noun(&encoded_noun) + .expect("Failed to decode cfg-load cause from noun"); + debug!("Decoded cause successfully"); + + assert_eq!(decoded_cause.0, 0, "Version should be 0"); + + match decoded_cause.1 { + BridgeCauseVariant::ConfigLoad(config) => { + assert!(config.is_none(), "Config should be None"); + info!("cfg-load (None) validated successfully"); + } + _ => panic!("Expected ConfigLoad variant"), + } + } + + #[test] + fn test_cause_nockchain_block_roundtrip() { + use nockchain_types::tx_engine::common::{BigNum, CoinbaseSplit, Hash as NockHash, Page}; + use noun_serde::NounEncode; + + init_test_logging(); + info!("Starting nockchain-block cause roundtrip test"); + + let mut allocator: NounSlab = NounSlab::new(); + + let digest = NockHash([Belt(1), Belt(2), Belt(3), Belt(4), Belt(5)]); + let parent = NockHash([Belt(10), Belt(20), Belt(30), Belt(40), Belt(50)]); + + let page = Page { + digest, + pow: None, + parent, + tx_ids: vec![], + coinbase: CoinbaseSplit::V0(vec![]), + timestamp: 1234567890, + epoch_counter: 42, + target: BigNum::from_u64(1000), + accumulated_work: BigNum::from_u64(5000), + height: 100, + msg: vec![], + }; + + let mut page_slab: NounSlab = NounSlab::new(); + let page_noun = page.to_noun(&mut page_slab); + + let nockchain_block_cause = + NockchainBlockCause::new(page_slab, page_noun, NockchainTxsMap(vec![])); + debug!("Created nockchain-block cause with height={}", page.height); + + let inner_noun = nockchain_block_cause.to_noun(&mut allocator); + assert!(inner_noun.is_cell(), "Cause noun should be a cell"); + info!("Nockchain-block cause created successfully"); + } + + #[test] + fn test_cause_base_blocks_roundtrip() { + init_test_logging(); + info!("Starting base-blocks cause roundtrip test"); + + let mut allocator: NounSlab = NounSlab::new(); + + let batch = sample_base_blocks_cause(); + let original_cause = BridgeCause(0, BridgeCauseVariant::BaseBlocks(batch.clone())); + debug!("Created base-blocks cause with {} entries", batch.len()); + + let encoded_noun = original_cause.to_noun(&mut allocator); + info!("Encoded base-blocks cause to noun"); + + let decoded_cause = BridgeCause::from_noun(&encoded_noun) + .expect("Failed to decode base-blocks cause from noun"); + debug!("Decoded base-blocks cause successfully"); + + assert_eq!(decoded_cause.0, 0, "Version should be 0"); + + match decoded_cause.1 { + BridgeCauseVariant::BaseBlocks(decoded) => { + assert_eq!(decoded.len(), batch.len(), "Entry count should match"); + assert_eq!(decoded[0].height, batch[0].height, "Height should match"); + assert_eq!( + decoded[0].block_id.0, batch[0].block_id.0, + "Block id bytes should match" + ); + info!("All base-blocks fields validated successfully"); + } + _ => panic!("Expected BaseBlocks variant"), + } + } + + #[test] + fn test_cause_proposed_base_call_roundtrip() { + init_test_logging(); + info!("Starting proposed-base-call cause roundtrip test"); + + let mut allocator: NounSlab = NounSlab::new(); + + let call_data = ProposedBaseCallData { + requests: vec![NockDepositRequestData { + tx_id: zero_tip5_hash(), + name: Name::new(zero_tip5_hash(), zero_tip5_hash()), + recipient: EthAddress::ZERO, + amount: 1000, + block_height: 100, + as_of: zero_tip5_hash(), + nonce: 1, + }], + }; + + let original_cause = + BridgeCause(0, BridgeCauseVariant::ProposedBaseCall(call_data.clone())); + debug!( + "Created proposed-base-call cause with {} requests", + call_data.requests.len() + ); + + let encoded_noun = original_cause.to_noun(&mut allocator); + info!("Encoded proposed-base-call cause to noun"); + + let decoded_cause = BridgeCause::from_noun(&encoded_noun) + .expect("Failed to decode proposed-base-call cause from noun"); + debug!("Decoded proposed-base-call cause successfully"); + + assert_eq!(decoded_cause.0, 0, "Version should be 0"); + + match decoded_cause.1 { + BridgeCauseVariant::ProposedBaseCall(data) => { + assert_eq!( + data.requests.len(), + call_data.requests.len(), + "Request count should match" + ); + info!("Proposed-base-call data validated successfully"); + } + _ => panic!("Expected ProposedBaseCall variant"), + } + } + + #[test] + fn test_cause_base_call_sig_roundtrip() { + init_test_logging(); + info!("Starting base-call-sig cause roundtrip test"); + + let mut allocator: NounSlab = NounSlab::new(); + + let sig = EthSignatureParts { + r: [0x33u8; 32], + s: [0x44u8; 32], + v: 28, + }; + let call_data = AtomBytes(vec![0x12, 0x34, 0x56]); + + let original_cause = BridgeCause( + 0, + BridgeCauseVariant::BaseCallSig(BaseCallSigData(sig, call_data.clone())), + ); + debug!("Created base-call-sig cause with v={}", sig.v); + + let encoded_noun = original_cause.to_noun(&mut allocator); + info!("Encoded base-call-sig cause to noun"); + + let decoded_cause = BridgeCause::from_noun(&encoded_noun) + .expect("Failed to decode base-call-sig cause from noun"); + debug!("Decoded base-call-sig cause successfully"); + + assert_eq!(decoded_cause.0, 0, "Version should be 0"); + + match decoded_cause.1 { + BridgeCauseVariant::BaseCallSig(BaseCallSigData(decoded_sig, data)) => { + assert_eq!(decoded_sig.r, sig.r, "Signature r should match"); + assert_eq!(decoded_sig.s, sig.s, "Signature s should match"); + assert_eq!(decoded_sig.v, sig.v, "Signature v should match"); + assert_eq!(data.0, call_data.0, "Call data should match"); + info!("All base-call-sig fields validated successfully"); + } + _ => panic!("Expected BaseCallSig variant"), + } + } + + #[test] + fn test_cause_stop_roundtrip() { + init_test_logging(); + info!("Starting stop cause roundtrip test"); + + let mut allocator: NounSlab = NounSlab::new(); + + let last = StopLastBlocks { + base: StopTipBase { + base_hash: Tip5Hash([Belt(1); 5]), + height: 123, + }, + nock: StopTipNock { + nock_hash: Tip5Hash([Belt(2); 5]), + height: 456, + }, + }; + let original_cause = BridgeCause::stop(last.clone()); + + let encoded_noun = original_cause.to_noun(&mut allocator); + let decoded_cause = + BridgeCause::from_noun(&encoded_noun).expect("Failed to decode stop cause from noun"); + + assert_eq!(decoded_cause.0, 0, "Version should be 0"); + + match decoded_cause.1 { + BridgeCauseVariant::Stop(decoded_last) => { + assert_eq!(decoded_last.base.base_hash, last.base.base_hash); + assert_eq!(decoded_last.base.height, last.base.height); + assert_eq!(decoded_last.nock.nock_hash, last.nock.nock_hash); + assert_eq!(decoded_last.nock.height, last.nock.height); + info!("stop cause validated successfully"); + } + _ => panic!("Expected Stop variant"), + } + } + + #[test] + fn test_cause_start_roundtrip() { + init_test_logging(); + info!("Starting start cause roundtrip test"); + + let mut allocator: NounSlab = NounSlab::new(); + + let original_cause = BridgeCause::start(); + let encoded_noun = original_cause.to_noun(&mut allocator); + let decoded_cause = + BridgeCause::from_noun(&encoded_noun).expect("Failed to decode start cause from noun"); + + assert_eq!(decoded_cause.0, 0, "Version should be 0"); + + match decoded_cause.1 { + BridgeCauseVariant::Start(_tag) => { + info!("start cause validated successfully"); + } + _ => panic!("Expected Start variant"), + } + } + + #[test] + fn test_all_cause_variants_have_version_zero() { + init_test_logging(); + info!("Testing that all cause variants preserve version 0"); + + let mut allocator: NounSlab = NounSlab::new(); + + let test_cases: Vec<(&str, BridgeCause)> = vec![ + ( + "cfg-load", + BridgeCause(0, BridgeCauseVariant::ConfigLoad(None)), + ), + ( + "base-blocks", + BridgeCause( + 0, + BridgeCauseVariant::BaseBlocks(sample_base_blocks_cause()), + ), + ), + ( + "nockchain-block", + BridgeCause( + 0, + BridgeCauseVariant::NockchainBlock(sample_nockchain_block_cause()), + ), + ), + ( + "proposed-base-call", + BridgeCause( + 0, + BridgeCauseVariant::ProposedBaseCall(ProposedBaseCallData { requests: vec![] }), + ), + ), + ( + "stop", + BridgeCause( + 0, + BridgeCauseVariant::Stop(StopLastBlocks { + base: StopTipBase { + base_hash: Tip5Hash([Belt(1); 5]), + height: 123, + }, + nock: StopTipNock { + nock_hash: Tip5Hash([Belt(2); 5]), + height: 456, + }, + }), + ), + ), + ("start", BridgeCause(0, BridgeCauseVariant::Start(NullTag))), + ]; + + for (name, cause) in test_cases { + debug!("Testing version for {} variant", name); + let encoded = cause.to_noun(&mut allocator); + let decoded = BridgeCause::from_noun(&encoded) + .unwrap_or_else(|_| panic!("Failed to decode {} variant", name)); + assert_eq!(decoded.0, 0, "{} variant should have version 0", name); + } + + info!("All cause variants correctly preserve version 0"); + } + + #[test] + fn test_empty_vs_nonempty_collections() { + init_test_logging(); + info!("Testing empty vs non-empty collection encoding"); + + let mut allocator: NounSlab = NounSlab::new(); + + let empty_batch: RawBaseBlocks = vec![]; + let empty_blocks = BridgeCause(0, BridgeCauseVariant::BaseBlocks(empty_batch)); + debug!("Encoding empty blocks list"); + let encoded_empty = empty_blocks.to_noun(&mut allocator); + let decoded_empty = + BridgeCause::from_noun(&encoded_empty).expect("Failed to decode empty blocks"); + + match decoded_empty.1 { + BridgeCauseVariant::BaseBlocks(batch) => { + assert!(batch.is_empty(), "Blocks list should be empty"); + info!("Empty blocks list encoded/decoded correctly"); + } + _ => panic!("Expected BaseBlocks variant"), + } + + let nonempty_blocks = BridgeCause( + 0, + BridgeCauseVariant::BaseBlocks(sample_base_blocks_cause()), + ); + debug!("Encoding non-empty blocks list"); + let encoded_nonempty = nonempty_blocks.to_noun(&mut allocator); + let decoded_nonempty = + BridgeCause::from_noun(&encoded_nonempty).expect("Failed to decode non-empty blocks"); + + match decoded_nonempty.1 { + BridgeCauseVariant::BaseBlocks(batch) => { + assert_eq!(batch.len(), 1, "Blocks list should contain an entry"); + info!("Non-empty blocks list encoded/decoded correctly"); + } + _ => panic!("Expected BaseBlocks variant"), + } + } + + #[test] + fn test_effect_base_call_roundtrip() { + init_test_logging(); + info!("Starting base-call effect roundtrip test"); + + let mut allocator: NounSlab = NounSlab::new(); + + let sig1 = EthSignatureParts { + r: [0x11u8; 32], + s: [0x22u8; 32], + v: 27, + }; + let sig2 = EthSignatureParts { + r: [0x33u8; 32], + s: [0x44u8; 32], + v: 28, + }; + let call_data = AtomBytes(vec![0xde, 0xad, 0xbe, 0xef]); + + let original_effect = BridgeEffect { + version: 0, + variant: BridgeEffectVariant::BaseCall(BaseCallData( + vec![sig1, sig2], + call_data.clone(), + )), + }; + debug!("Created base-call effect with 2 signatures"); + + let encoded_noun = original_effect.to_noun(&mut allocator); + info!("Encoded base-call effect to noun"); + + let decoded_effect = BridgeEffect::from_noun(&encoded_noun) + .expect("Failed to decode base-call effect from noun"); + debug!("Decoded base-call effect successfully"); + + assert_eq!(decoded_effect.version, 0, "Version should be 0"); + + match decoded_effect.variant { + BridgeEffectVariant::BaseCall(BaseCallData(sigs, data)) => { + assert_eq!(sigs.len(), 2, "Should have 2 signatures"); + assert_eq!(sigs[0], sig1, "First signature should match"); + assert_eq!(sigs[1], sig2, "Second signature should match"); + assert_eq!(data.0, call_data.0, "Call data should match"); + info!("All base-call fields validated successfully"); + } + _ => panic!("Expected BaseCall variant"), + } + } + + #[test] + fn test_effect_assemble_base_call_roundtrip() { + init_test_logging(); + info!("Starting assemble-base-call effect roundtrip test"); + + let mut allocator: NounSlab = NounSlab::new(); + + let deposit_data = DepositSettlementData { + counterpart: nockchain_types::tx_engine::common::Hash([Belt(1); 5]), + as_of: nockchain_types::tx_engine::common::Hash([Belt(2); 5]), + dest: AtomBytes(vec![0xaa; 20]), + settled_amount: 67890, + fees: vec![ + DepositSettlementFee { + address: AtomBytes(vec![0xbb; 20]), + amount: 100, + }, + DepositSettlementFee { + address: AtomBytes(vec![0xcc; 20]), + amount: 200, + }, + ], + bridge_fee: 50, + }; + + let original_effect = BridgeEffect { + version: 0, + variant: BridgeEffectVariant::AssembleBaseCall(deposit_data.clone()), + }; + debug!("Created assemble-base-call effect"); + + let encoded_noun = original_effect.to_noun(&mut allocator); + info!("Encoded assemble-base-call effect to noun"); + + let decoded_effect = BridgeEffect::from_noun(&encoded_noun) + .expect("Failed to decode assemble-base-call effect from noun"); + debug!("Decoded assemble-base-call effect successfully"); + + assert_eq!(decoded_effect.version, 0, "Version should be 0"); + + match decoded_effect.variant { + BridgeEffectVariant::AssembleBaseCall(data) => { + assert_eq!( + data.counterpart, deposit_data.counterpart, + "Counterpart should match" + ); + assert_eq!(data.as_of, deposit_data.as_of, "as_of should match"); + assert_eq!(data.dest, deposit_data.dest, "dest should match"); + assert_eq!( + data.settled_amount, deposit_data.settled_amount, + "settled_amount should match" + ); + assert_eq!(data.fees, deposit_data.fees, "fees should match"); + info!("All assemble-base-call fields validated successfully"); + } + _ => panic!("Expected AssembleBaseCall variant"), + } + } + + #[test] + fn test_effect_nock_deposit_request_roundtrip() { + init_test_logging(); + info!("Starting nock-deposit-request effect roundtrip test"); + + let mut allocator: NounSlab = NounSlab::new(); + + let request_data = NockDepositRequestKernelData { + tx_id: Tip5Hash([Belt(1); 5]), + name: Name::new(Tip5Hash([Belt(2); 5]), Tip5Hash([Belt(3); 5])), + recipient: EthAddress([0xca; 20]), + amount: 1000, + block_height: 42, + as_of: Tip5Hash([Belt(4); 5]), + }; + + let original_effect = BridgeEffect { + version: 0, + variant: BridgeEffectVariant::NockDepositRequest(request_data.clone()), + }; + debug!("Created nock-deposit-request effect"); + + let encoded_noun = original_effect.to_noun(&mut allocator); + info!("Encoded nock-deposit-request effect to noun"); + + let decoded_effect = BridgeEffect::from_noun(&encoded_noun) + .expect("Failed to decode nock-deposit-request effect from noun"); + debug!("Decoded nock-deposit-request effect successfully"); + + assert_eq!(decoded_effect.version, 0, "Version should be 0"); + + match decoded_effect.variant { + BridgeEffectVariant::NockDepositRequest(data) => { + assert_eq!(data.tx_id, request_data.tx_id, "tx_id should match"); + assert_eq!(data.name, request_data.name, "name should match"); + assert_eq!( + data.recipient, request_data.recipient, + "recipient should match" + ); + assert_eq!(data.amount, request_data.amount, "amount should match"); + assert_eq!( + data.block_height, request_data.block_height, + "block_height should match" + ); + assert_eq!(data.as_of, request_data.as_of, "as_of should match"); + info!("Nock-deposit-request data validated successfully"); + } + _ => panic!("Expected NockDepositRequest variant"), + } + } + + #[test] + fn test_effect_commit_nock_deposits_roundtrip() { + init_test_logging(); + info!("Starting commit-nock-deposits effect roundtrip test"); + + let mut allocator: NounSlab = NounSlab::new(); + + let req1 = NockDepositRequestKernelData { + tx_id: Tip5Hash([Belt(1); 5]), + name: Name::new(Tip5Hash([Belt(2); 5]), Tip5Hash([Belt(3); 5])), + recipient: EthAddress([0xde; 20]), + amount: 1000, + block_height: 42, + as_of: Tip5Hash([Belt(4); 5]), + }; + let req2 = NockDepositRequestKernelData { + tx_id: Tip5Hash([Belt(5); 5]), + name: Name::new(Tip5Hash([Belt(6); 5]), Tip5Hash([Belt(7); 5])), + recipient: EthAddress([0xad; 20]), + amount: 2000, + block_height: 43, + as_of: Tip5Hash([Belt(8); 5]), + }; + let proposal_data = vec![req1.clone(), req2.clone()]; + + let original_effect = BridgeEffect { + version: 0, + variant: BridgeEffectVariant::CommitNockDeposits(proposal_data.clone()), + }; + debug!( + "Created commit-nock-deposits effect with {} requests", + proposal_data.len() + ); + + let encoded_noun = original_effect.to_noun(&mut allocator); + info!("Encoded commit-nock-deposits effect to noun"); + eprintln!( + "encoded_noun: {:?}", + nockvm::noun::FullDebugCell(&encoded_noun.as_cell().unwrap()) + ); + + let decoded_effect = BridgeEffect::from_noun(&encoded_noun) + .expect("Failed to decode commit-nock-deposits effect from noun"); + debug!("Decoded commit-nock-deposits effect successfully"); + + assert_eq!(decoded_effect.version, 0, "Version should be 0"); + + match decoded_effect.variant { + BridgeEffectVariant::CommitNockDeposits(data) => { + assert_eq!(data.len(), 2, "Should have 2 requests"); + assert_eq!( + data[0].tx_id, req1.tx_id, + "First request tx_id should match" + ); + assert_eq!( + data[1].tx_id, req2.tx_id, + "Second request tx_id should match" + ); + info!("All commit-nock-deposits fields validated successfully"); + } + _ => panic!("Expected CommitNockDeposits variant"), + } + } + + #[test] + fn test_effect_nockchain_tx_roundtrip() { + use nockchain_types::tx_engine::common::Hash as TxId; + use nockchain_types::v1::{RawTx, Spends, Version}; + + init_test_logging(); + info!("Starting nockchain-tx effect roundtrip test"); + + let mut allocator: NounSlab = NounSlab::new(); + + let raw_tx = RawTx { + version: Version::V1, + id: TxId([Belt(1); 5]), + spends: Spends(vec![]), + }; + + let original_effect = BridgeEffect { + version: 0, + variant: BridgeEffectVariant::NockchainTx(NockchainTxData { tx: raw_tx.clone() }), + }; + debug!("Created nockchain-tx effect"); + + let encoded_noun = original_effect.to_noun(&mut allocator); + info!("Encoded nockchain-tx effect to noun"); + + let decoded_effect = BridgeEffect::from_noun(&encoded_noun) + .expect("Failed to decode nockchain-tx effect from noun"); + debug!("Decoded nockchain-tx effect successfully"); + + assert_eq!(decoded_effect.version, 0, "Version should be 0"); + + match decoded_effect.variant { + BridgeEffectVariant::NockchainTx(tx_data) => { + assert_eq!(tx_data.tx.id, raw_tx.id, "Raw tx id should match"); + assert_eq!( + tx_data.tx.version, raw_tx.version, + "Raw tx version should match" + ); + info!("Nockchain-tx data validated successfully"); + } + _ => panic!("Expected NockchainTx variant"), + } + } + + #[test] + fn test_effect_propose_nockchain_tx_roundtrip() { + use nockchain_math::crypto::cheetah::{CheetahPoint, F6lt}; + use nockchain_types::tx_engine::common::SchnorrPubkey; + + init_test_logging(); + info!("Starting propose-nockchain-tx effect roundtrip test"); + + let mut allocator: NounSlab = NounSlab::new(); + + let pubkey = SchnorrPubkey(CheetahPoint { + x: F6lt([Belt(3); 6]), + y: F6lt([Belt(4); 6]), + inf: false, + }); + let tx_data = AtomBytes(vec![0xbe, 0xef]); + + let original_effect = BridgeEffect { + version: 0, + variant: BridgeEffectVariant::ProposeNockchainTx(ProposeNockchainTxData( + pubkey.clone(), + tx_data.clone(), + )), + }; + debug!("Created propose-nockchain-tx effect"); + + let encoded_noun = original_effect.to_noun(&mut allocator); + info!("Encoded propose-nockchain-tx effect to noun"); + + let decoded_effect = BridgeEffect::from_noun(&encoded_noun) + .expect("Failed to decode propose-nockchain-tx effect from noun"); + debug!("Decoded propose-nockchain-tx effect successfully"); + + assert_eq!(decoded_effect.version, 0, "Version should be 0"); + + match decoded_effect.variant { + BridgeEffectVariant::ProposeNockchainTx(ProposeNockchainTxData(pk, data)) => { + assert_eq!(pk, pubkey, "Pubkey should match"); + assert_eq!(data.0, tx_data.0, "Tx data should match"); + info!("All propose-nockchain-tx fields validated successfully"); + } + _ => panic!("Expected ProposeNockchainTx variant"), + } + } + + #[test] + fn test_effect_stop_roundtrip() { + init_test_logging(); + info!("Starting stop effect roundtrip test"); + + let mut allocator: NounSlab = NounSlab::new(); + + let last = StopLastBlocks { + base: StopTipBase { + base_hash: Tip5Hash([Belt(1); 5]), + height: 123, + }, + nock: StopTipNock { + nock_hash: Tip5Hash([Belt(2); 5]), + height: 456, + }, + }; + let reason = "invariant violated".to_string(); + let original_effect = BridgeEffect { + version: 0, + variant: BridgeEffectVariant::Stop(StopEffectData { + reason: reason.clone(), + last: last.clone(), + }), + }; + + let encoded_noun = original_effect.to_noun(&mut allocator); + let decoded_effect = + BridgeEffect::from_noun(&encoded_noun).expect("Failed to decode stop effect from noun"); + + assert_eq!(decoded_effect.version, 0, "Version should be 0"); + match decoded_effect.variant { + BridgeEffectVariant::Stop(data) => { + assert_eq!(data.reason, reason); + assert_eq!(data.last.base.base_hash, last.base.base_hash); + assert_eq!(data.last.base.height, last.base.height); + assert_eq!(data.last.nock.nock_hash, last.nock.nock_hash); + assert_eq!(data.last.nock.height, last.nock.height); + info!("stop effect validated successfully"); + } + _ => panic!("Expected Stop variant"), + } + } + + #[test] + fn test_eth_signature_validation() { + init_test_logging(); + info!("Testing Ethereum signature validation"); + + let valid_sig = EthSignatureParts { + r: [0x11u8; 32], + s: [0x22u8; 32], + v: 27, + }; + assert!( + valid_sig.validate().is_ok(), + "Valid signature should pass validation" + ); + info!("Valid signature (v=27) passed validation"); + + let valid_sig_v28 = EthSignatureParts { + r: [0x11u8; 32], + s: [0x22u8; 32], + v: 28, + }; + assert!( + valid_sig_v28.validate().is_ok(), + "Valid signature with v=28 should pass validation" + ); + info!("Valid signature (v=28) passed validation"); + + let zero_r = EthSignatureParts { + r: [0u8; 32], + s: [0x22u8; 32], + v: 27, + }; + assert!( + zero_r.validate().is_err(), + "Signature with zero r should fail validation" + ); + info!("Zero r component correctly rejected"); + + let zero_s = EthSignatureParts { + r: [0x11u8; 32], + s: [0u8; 32], + v: 27, + }; + assert!( + zero_s.validate().is_err(), + "Signature with zero s should fail validation" + ); + info!("Zero s component correctly rejected"); + + let invalid_v = EthSignatureParts { + r: [0x11u8; 32], + s: [0x22u8; 32], + v: 26, + }; + assert!( + invalid_v.validate().is_err(), + "Signature with invalid v should fail validation" + ); + info!("Invalid v component (26) correctly rejected"); + + let invalid_v_high = EthSignatureParts { + r: [0x11u8; 32], + s: [0x22u8; 32], + v: 29, + }; + assert!( + invalid_v_high.validate().is_err(), + "Signature with invalid v should fail validation" + ); + info!("Invalid v component (29) correctly rejected"); + + info!("All Ethereum signature validation tests passed"); + } + + #[test] + fn test_edge_case_large_atom_bytes() { + init_test_logging(); + info!("Testing large AtomBytes encoding/decoding via BaseBlocks"); + + let mut allocator: NounSlab = NounSlab::new(); + + // Create a RawBaseBlockEntry with large block_id data + let large_data = vec![0xffu8; 1024]; + let entry = RawBaseBlockEntry { + height: 12345, + block_id: AtomBytes(large_data.clone()), + parent_block_id: AtomBytes(vec![0xca, 0xfe]), + txs: vec![], + }; + let cause = BridgeCause(0, BridgeCauseVariant::BaseBlocks(vec![entry])); + + let encoded = cause.to_noun(&mut allocator); + let decoded = BridgeCause::from_noun(&encoded).expect("Failed to decode large AtomBytes"); + + match decoded.1 { + BridgeCauseVariant::BaseBlocks(blocks) => { + assert_eq!(blocks.len(), 1, "Should have 1 block"); + assert_eq!( + blocks[0].block_id.0.len(), + 1024, + "Large data should preserve length" + ); + assert_eq!(blocks[0].block_id.0, large_data, "Large data should match"); + info!("Large AtomBytes (1024 bytes) encoded/decoded correctly"); + } + _ => panic!("Expected BaseBlocks variant"), + } + } + + #[test] + fn test_edge_case_many_signatures() { + init_test_logging(); + info!("Testing effect with many signatures"); + + let mut allocator: NounSlab = NounSlab::new(); + + let mut sigs = Vec::new(); + for i in 0..10 { + sigs.push(EthSignatureParts { + r: [i as u8; 32], + s: [(i + 1) as u8; 32], + v: if i % 2 == 0 { 27 } else { 28 }, + }); + } + + let effect = BridgeEffect { + version: 0, + variant: BridgeEffectVariant::BaseCall(BaseCallData( + sigs.clone(), + AtomBytes(vec![0xaa, 0xbb]), + )), + }; + + let encoded = effect.to_noun(&mut allocator); + let decoded = BridgeEffect::from_noun(&encoded) + .expect("Failed to decode effect with many signatures"); + + match decoded.variant { + BridgeEffectVariant::BaseCall(BaseCallData(decoded_sigs, _)) => { + assert_eq!(decoded_sigs.len(), 10, "Should have 10 signatures"); + for (i, sig) in decoded_sigs.iter().enumerate() { + assert_eq!(*sig, sigs[i], "Signature {} should match", i); + } + info!("Effect with 10 signatures encoded/decoded correctly"); + } + _ => panic!("Expected BaseCall variant"), + } + } + + #[test] + fn test_edge_case_empty_fees_map() { + init_test_logging(); + info!("Testing DepositSettlementData with empty fees map"); + + let mut allocator: NounSlab = NounSlab::new(); + + let deposit_data = DepositSettlementData { + counterpart: nockchain_types::tx_engine::common::Hash([Belt(0); 5]), + as_of: nockchain_types::tx_engine::common::Hash([Belt(0); 5]), + dest: AtomBytes(vec![0; 20]), + settled_amount: 0, + fees: Vec::new(), + bridge_fee: 0, + }; + + let effect = BridgeEffect { + version: 0, + variant: BridgeEffectVariant::AssembleBaseCall(deposit_data.clone()), + }; + + let encoded = effect.to_noun(&mut allocator); + let decoded = + BridgeEffect::from_noun(&encoded).expect("Failed to decode effect with empty fees"); + + match decoded.variant { + BridgeEffectVariant::AssembleBaseCall(data) => { + assert!(data.fees.is_empty(), "Fees map should be empty"); + info!("Empty fees map encoded/decoded correctly"); + } + _ => panic!("Expected AssembleBaseCall variant"), + } + } + + #[test] + fn test_eth_signature_request_with_nockchain_hashes() { + use nockchain_math::belt::Belt; + + init_test_logging(); + info!("Testing NockDepositRequestData with nockchain-native hashes"); + + let req = NockDepositRequestData { + tx_id: Tip5Hash([Belt(100), Belt(200), Belt(300), Belt(400), Belt(500)]), + name: Name::new( + Tip5Hash([Belt(1), Belt(2), Belt(3), Belt(4), Belt(5)]), + Tip5Hash([Belt(6), Belt(7), Belt(8), Belt(9), Belt(10)]), + ), + recipient: EthAddress([0xaa; 20]), + amount: 1000, + block_height: 42, + as_of: Tip5Hash([Belt(10), Belt(20), Belt(30), Belt(40), Belt(50)]), + nonce: 1, + }; + + let proposal_hash = req.compute_proposal_hash(); + + assert_eq!(proposal_hash.len(), 32, "Proposal hash should be 32 bytes"); + + let tx_id_limbs = req.tx_id.to_array(); + let name_first_limbs = req.name.first.to_array(); + let name_last_limbs = req.name.last.to_array(); + let as_of_limbs = req.as_of.to_array(); + + assert_eq!(tx_id_limbs.len(), 5, "tx_id should encode to 5 limbs"); + assert_eq!( + name_first_limbs.len(), + 5, + "name.first should encode to 5 limbs" + ); + assert_eq!( + name_last_limbs.len(), + 5, + "name.last should encode to 5 limbs" + ); + assert_eq!(as_of_limbs.len(), 5, "as_of should encode to 5 limbs"); + + let reconstructed_tx_id = Tip5Hash::from_limbs(&tx_id_limbs); + let reconstructed_name_first = Tip5Hash::from_limbs(&name_first_limbs); + let reconstructed_name_last = Tip5Hash::from_limbs(&name_last_limbs); + let reconstructed_as_of = Tip5Hash::from_limbs(&as_of_limbs); + + assert_eq!(reconstructed_tx_id, req.tx_id, "tx_id should roundtrip"); + assert_eq!( + reconstructed_name_first, req.name.first, + "name.first should roundtrip" + ); + assert_eq!( + reconstructed_name_last, req.name.last, + "name.last should roundtrip" + ); + assert_eq!(reconstructed_as_of, req.as_of, "as_of should roundtrip"); + + info!("Nockchain-native hashes roundtrip correctly through limbs encoding"); + } + + #[test] + fn test_deposit_id_roundtrip() { + use nockchain_math::belt::Belt; + + init_test_logging(); + info!("Testing DepositId serialization roundtrip"); + + let original = DepositId { + as_of: Tip5Hash([Belt(1), Belt(2), Belt(3), Belt(4), Belt(5)]), + name: Name::new( + Tip5Hash([Belt(10), Belt(20), Belt(30), Belt(40), Belt(50)]), + Tip5Hash([Belt(100), Belt(200), Belt(300), Belt(400), Belt(500)]), + ), + }; + + let bytes = original.to_bytes(); + assert_eq!(bytes.len(), 120, "Should serialize to 120 bytes"); + + let decoded = DepositId::from_bytes(&bytes).expect("Failed to deserialize DepositId"); + + assert_eq!(decoded.as_of, original.as_of, "as_of should match"); + assert_eq!( + decoded.name.first, original.name.first, + "name.first should match" + ); + assert_eq!( + decoded.name.last, original.name.last, + "name.last should match" + ); + assert_eq!(decoded, original, "Full DepositId should match"); + info!("DepositId roundtrip successful"); + } + + #[test] + fn test_deposit_id_from_effect_payload() { + use nockchain_math::belt::Belt; + + init_test_logging(); + info!("Testing DepositId construction from NockDepositRequestData"); + + let request = NockDepositRequestData { + tx_id: Tip5Hash([Belt(1); 5]), + name: Name::new(Tip5Hash([Belt(2); 5]), Tip5Hash([Belt(3); 5])), + recipient: EthAddress([0xaa; 20]), + amount: 1000, + block_height: 42, + as_of: Tip5Hash([Belt(4); 5]), + nonce: 1, + }; + + let deposit_id = DepositId::from_effect_payload(&request); + + assert_eq!( + deposit_id.as_of, request.as_of, + "as_of should match request" + ); + assert_eq!(deposit_id.name, request.name, "name should match request"); + info!("DepositId constructed correctly from effect payload"); + } + + #[test] + fn test_deposit_id_hash_uniqueness() { + use std::collections::HashSet; + + use nockchain_math::belt::Belt; + + init_test_logging(); + info!("Testing DepositId hash uniqueness"); + + let id1 = DepositId { + as_of: Tip5Hash([Belt(1); 5]), + name: Name::new(Tip5Hash([Belt(2); 5]), Tip5Hash([Belt(3); 5])), + }; + + let id2 = DepositId { + as_of: Tip5Hash([Belt(1); 5]), + name: Name::new(Tip5Hash([Belt(2); 5]), Tip5Hash([Belt(4); 5])), // Different last + }; + + let id3 = DepositId { + as_of: Tip5Hash([Belt(2); 5]), // Different as_of + name: Name::new(Tip5Hash([Belt(2); 5]), Tip5Hash([Belt(3); 5])), + }; + + let mut set = HashSet::new(); + set.insert(id1.clone()); + set.insert(id2.clone()); + set.insert(id3.clone()); + + assert_eq!( + set.len(), + 3, + "All three DepositIds should be unique in HashSet" + ); + assert!(set.contains(&id1), "HashSet should contain id1"); + assert!(set.contains(&id2), "HashSet should contain id2"); + assert!(set.contains(&id3), "HashSet should contain id3"); + info!("DepositId hashing works correctly for HashMap/HashSet usage"); + } + + #[test] + fn test_edge_case_cfg_load_some() { + init_test_logging(); + info!("Testing cfg-load with Some(config)"); + + let mut allocator: NounSlab = NounSlab::new(); + + let node_info = NodeInfo { + ip: "127.0.0.1".to_string(), + eth_pubkey: AtomBytes(vec![0x01, 0x02]), + // Use a valid PKH (Tip5 hash with 5 Belt limbs) + nock_pkh: nockchain_types::tx_engine::common::Hash([ + Belt(1), + Belt(2), + Belt(3), + Belt(4), + Belt(5), + ]), + }; + + let config = NodeConfig { + node_id: 0, + nodes: vec![node_info], + my_eth_key: AtomBytes(vec![0xab, 0xcd]), + my_nock_key: SchnorrSecretKey([Belt(42); 8]), + }; + + let cause = BridgeCause(0, BridgeCauseVariant::ConfigLoad(Some(config.clone()))); + + let encoded = cause.to_noun(&mut allocator); + let decoded = + BridgeCause::from_noun(&encoded).expect("Failed to decode cfg-load with Some"); + + match decoded.1 { + BridgeCauseVariant::ConfigLoad(Some(decoded_config)) => { + assert_eq!( + decoded_config.node_id, config.node_id, + "node_id should match" + ); + assert_eq!( + decoded_config.nodes.len(), + config.nodes.len(), + "nodes length should match" + ); + info!("cfg-load with Some(config) encoded/decoded correctly"); + } + _ => panic!("Expected ConfigLoad(Some(_)) variant"), + } + } + + #[test] + fn test_bridge_constants_roundtrip() { + init_test_logging(); + info!("Starting bridge-constants roundtrip test"); + + let mut allocator: NounSlab = NounSlab::new(); + let original = BridgeConstants::default(); + + let encoded = original.to_noun(&mut allocator); + let decoded = + BridgeConstants::from_noun(&encoded).expect("Failed to decode BridgeConstants"); + + assert_eq!(decoded.version, original.version); + assert_eq!(decoded.min_signers, original.min_signers); + assert_eq!(decoded.total_signers, original.total_signers); + assert_eq!(decoded.minimum_event_nocks, original.minimum_event_nocks); + assert_eq!(decoded.nicks_fee_per_nock, original.nicks_fee_per_nock); + assert_eq!(decoded.base_blocks_chunk, original.base_blocks_chunk); + assert_eq!(decoded.base_start_height, original.base_start_height); + assert_eq!( + decoded.nockchain_start_height, + original.nockchain_start_height + ); + + info!("BridgeConstants roundtrip successful"); + } + + #[test] + fn test_cause_set_constants_roundtrip() { + init_test_logging(); + info!("Starting set-constants cause roundtrip test"); + + let mut allocator: NounSlab = NounSlab::new(); + let constants = BridgeConstants::default(); + let original = BridgeCause::set_constants(constants.clone()); + + let encoded = original.to_noun(&mut allocator); + let decoded = + BridgeCause::from_noun(&encoded).expect("Failed to decode set-constants cause"); + + assert_eq!(decoded.0, 0); + match decoded.1 { + BridgeCauseVariant::SetConstants(c) => { + assert_eq!(c.min_signers, constants.min_signers); + assert_eq!(c.total_signers, constants.total_signers); + info!("set-constants cause roundtrip successful"); + } + _ => panic!("Expected SetConstants variant"), + } + } + + #[test] + fn test_base_event_deposit_processed_roundtrip() { + init_test_logging(); + info!("Starting base-event deposit-processed roundtrip test"); + + let mut allocator: NounSlab = NounSlab::new(); + + let event = BaseEvent { + base_event_id: AtomBytes(vec![0x12, 0x34, 0x56, 0x78]), + content: BaseEventContent::DepositProcessed { + nock_tx_id: Tip5Hash([Belt(1), Belt(2), Belt(3), Belt(4), Belt(5)]), + note_name: Name::new( + Tip5Hash([Belt(10), Belt(20), Belt(30), Belt(40), Belt(50)]), + Tip5Hash([Belt(11), Belt(21), Belt(31), Belt(41), Belt(51)]), + ), + recipient: EthAddress([0xaa; 20]), + amount: 65536, // 1 NOCK in internal units + block_height: 12345, + as_of: Tip5Hash([Belt(100), Belt(200), Belt(300), Belt(400), Belt(500)]), + nonce: 42, + }, + }; + + let encoded = event.to_noun(&mut allocator); + info!("Encoded BaseEvent with DepositProcessed to noun"); + + let decoded = BaseEvent::from_noun(&encoded) + .expect("Failed to decode BaseEvent with DepositProcessed"); + info!("Decoded BaseEvent successfully"); + + assert_eq!( + decoded.base_event_id, event.base_event_id, + "base_event_id should match" + ); + + match (&decoded.content, &event.content) { + ( + BaseEventContent::DepositProcessed { + nock_tx_id: d_tx_id, + note_name: d_name, + recipient: d_recipient, + amount: d_amount, + block_height: d_height, + as_of: d_as_of, + nonce: d_nonce, + }, + BaseEventContent::DepositProcessed { + nock_tx_id: e_tx_id, + note_name: e_name, + recipient: e_recipient, + amount: e_amount, + block_height: e_height, + as_of: e_as_of, + nonce: e_nonce, + }, + ) => { + assert_eq!(d_tx_id, e_tx_id, "nock_tx_id should match"); + assert_eq!(d_name, e_name, "note_name should match"); + assert_eq!(d_recipient, e_recipient, "recipient should match"); + assert_eq!(d_amount, e_amount, "amount should match"); + assert_eq!(d_height, e_height, "block_height should match"); + assert_eq!(d_as_of, e_as_of, "as_of should match"); + assert_eq!(d_nonce, e_nonce, "nonce should match"); + info!("All DepositProcessed fields validated successfully"); + } + _ => panic!("Expected DepositProcessed variant"), + } + } + + #[test] + fn test_base_event_burn_for_withdrawal_roundtrip() { + init_test_logging(); + info!("Starting base-event burn-for-withdrawal roundtrip test"); + + let mut allocator: NounSlab = NounSlab::new(); + + let event = BaseEvent { + base_event_id: AtomBytes(vec![0xab, 0xcd, 0xef]), + content: BaseEventContent::BurnForWithdrawal { + burner: EthAddress([0xbb; 20]), + amount: 131072, // 2 NOCK in internal units + lock_root: Tip5Hash([Belt(111), Belt(222), Belt(333), Belt(444), Belt(555)]), + }, + }; + + let encoded = event.to_noun(&mut allocator); + info!("Encoded BaseEvent with BurnForWithdrawal to noun"); + + let decoded = BaseEvent::from_noun(&encoded) + .expect("Failed to decode BaseEvent with BurnForWithdrawal"); + info!("Decoded BaseEvent successfully"); + + assert_eq!( + decoded.base_event_id, event.base_event_id, + "base_event_id should match" + ); + + match (&decoded.content, &event.content) { + ( + BaseEventContent::BurnForWithdrawal { + burner: d_burner, + amount: d_amount, + lock_root: d_lock_root, + }, + BaseEventContent::BurnForWithdrawal { + burner: e_burner, + amount: e_amount, + lock_root: e_lock_root, + }, + ) => { + assert_eq!(d_burner, e_burner, "burner should match"); + assert_eq!(d_amount, e_amount, "amount should match"); + assert_eq!(d_lock_root, e_lock_root, "lock_root should match"); + info!("All BurnForWithdrawal fields validated successfully"); + } + _ => panic!("Expected BurnForWithdrawal variant"), + } + } + + #[test] + fn test_raw_base_blocks_with_events_roundtrip() { + init_test_logging(); + info!("Starting raw-base-blocks with events roundtrip test"); + + let mut allocator: NounSlab = NounSlab::new(); + + let deposit_event = BaseEvent { + base_event_id: AtomBytes(vec![0x01, 0x02]), + content: BaseEventContent::DepositProcessed { + nock_tx_id: Tip5Hash([Belt(1); 5]), + note_name: Name::new(Tip5Hash([Belt(2); 5]), Tip5Hash([Belt(3); 5])), + recipient: EthAddress([0xcc; 20]), + amount: 65536, + block_height: 100, + as_of: Tip5Hash([Belt(4); 5]), + nonce: 1, + }, + }; + + let withdrawal_event = BaseEvent { + base_event_id: AtomBytes(vec![0x03, 0x04]), + content: BaseEventContent::BurnForWithdrawal { + burner: EthAddress([0xdd; 20]), + amount: 131072, + lock_root: Tip5Hash([Belt(5); 5]), + }, + }; + + let raw_blocks: RawBaseBlocks = vec![RawBaseBlockEntry { + height: 12345, + block_id: AtomBytes(vec![0xde, 0xad, 0xbe, 0xef]), + parent_block_id: AtomBytes(vec![0xca, 0xfe, 0xba, 0xbe]), + txs: vec![deposit_event.clone(), withdrawal_event.clone()], + }]; + + let cause = BridgeCause(0, BridgeCauseVariant::BaseBlocks(raw_blocks.clone())); + let encoded = cause.to_noun(&mut allocator); + info!("Encoded BridgeCause with BaseBlocks containing events"); + + let decoded = + BridgeCause::from_noun(&encoded).expect("Failed to decode BridgeCause with BaseBlocks"); + info!("Decoded BridgeCause successfully"); + + match decoded.1 { + BridgeCauseVariant::BaseBlocks(blocks) => { + assert_eq!(blocks.len(), 1, "Should have 1 block"); + let block = &blocks[0]; + assert_eq!(block.height, 12345, "height should match"); + assert_eq!(block.txs.len(), 2, "Should have 2 events"); + + // Verify first event (DepositProcessed) + match &block.txs[0].content { + BaseEventContent::DepositProcessed { amount, nonce, .. } => { + assert_eq!(*amount, 65536, "deposit amount should match"); + assert_eq!(*nonce, 1, "deposit nonce should match"); + } + _ => panic!("First event should be DepositProcessed"), + } + + // Verify second event (BurnForWithdrawal) + match &block.txs[1].content { + BaseEventContent::BurnForWithdrawal { amount, .. } => { + assert_eq!(*amount, 131072, "withdrawal amount should match"); + } + _ => panic!("Second event should be BurnForWithdrawal"), + } + + info!("All BaseBlocks events validated successfully"); + } + _ => panic!("Expected BaseBlocks variant"), + } + } + + #[test] + fn test_count_peek_roundtrip() { + init_test_logging(); + info!("Starting CountPeek roundtrip test"); + + let mut allocator: NounSlab = NounSlab::new(); + + // Test with a value + let original = CountPeek { + inner: Some(Some(42)), + }; + let encoded = original.to_noun(&mut allocator); + let decoded = CountPeek::from_noun(&encoded).expect("Failed to decode CountPeek from noun"); + assert_eq!( + decoded.inner, + Some(Some(42)), + "CountPeek value should match" + ); + + // Test with None (absent) + let mut allocator2: NounSlab = NounSlab::new(); + let original_none = CountPeek { inner: Some(None) }; + let encoded_none = original_none.to_noun(&mut allocator2); + let decoded_none = + CountPeek::from_noun(&encoded_none).expect("Failed to decode CountPeek None from noun"); + assert_eq!( + decoded_none.inner, + Some(None), + "CountPeek None should match" + ); + + info!("CountPeek roundtrip validated successfully"); + } + + #[test] + fn test_bool_peek_roundtrip() { + init_test_logging(); + info!("Starting BoolPeek roundtrip test"); + + let mut allocator: NounSlab = NounSlab::new(); + + // Test with true + let original_true = BoolPeek { + inner: Some(Some(true)), + }; + let encoded_true = original_true.to_noun(&mut allocator); + let decoded_true = + BoolPeek::from_noun(&encoded_true).expect("Failed to decode BoolPeek true from noun"); + assert_eq!( + decoded_true.inner, + Some(Some(true)), + "BoolPeek true should match" + ); + + // Test with false + let mut allocator2: NounSlab = NounSlab::new(); + let original_false = BoolPeek { + inner: Some(Some(false)), + }; + let encoded_false = original_false.to_noun(&mut allocator2); + let decoded_false = + BoolPeek::from_noun(&encoded_false).expect("Failed to decode BoolPeek false from noun"); + assert_eq!( + decoded_false.inner, + Some(Some(false)), + "BoolPeek false should match" + ); + + info!("BoolPeek roundtrip validated successfully"); + } +} diff --git a/crates/bridge/tests/config_tests.rs b/crates/bridge/tests/config_tests.rs new file mode 100644 index 000000000..a97c4800c --- /dev/null +++ b/crates/bridge/tests/config_tests.rs @@ -0,0 +1,962 @@ +use std::fs; +use std::str::FromStr; + +use alloy::primitives::Address; +use bridge::config::BridgeConfigToml; +use ibig::UBig; +use nockchain_math::belt::{Belt, PRIME}; +use nockchain_types::tx_engine::common::Hash as NockPkh; +use tempfile::TempDir; + +// FIXME: Bad third-party inputs shouldn't be able to induce panics, this should be Result<> +fn base58_belts(value: &str) -> [Belt; N] { + let bytes = bs58::decode(value) + .into_vec() + .expect("Failed to decode base58 string"); + assert!(!bytes.is_empty()); + let mut big = UBig::from_be_bytes(&bytes); + let prime = UBig::from(PRIME); + let mut belts = [Belt(0); N]; + for belt in belts.iter_mut() { + let rem = (&big % &prime) + .try_into() + .expect("Failed to convert remainder to u64"); + *belt = Belt(rem); + big /= ′ + } + assert!(big == UBig::from(0u8)); + belts +} + +/// Fake test PKHs (valid base58 format, deterministic for tests) +/// These are NOT real operator PKHs - just valid format placeholders +fn sample_pkhs_b58() -> Vec<&'static str> { + vec![ + "2222222222222222222222222222222222222222222222222222", // test node 0 + "3333333333333333333333333333333333333333333333333333", // test node 1 + "4444444444444444444444444444444444444444444444444444", // test node 2 + "5555555555555555555555555555555555555555555555555555", // test node 3 + "6666666666666666666666666666666666666666666666666666", // test node 4 + ] +} + +fn sample_pkh_b58() -> &'static str { + sample_pkhs_b58()[0] +} + +#[test] +fn test_missing_confirmation_depths_fails_parse() { + let temp_dir = TempDir::new().expect("Failed to create temporary directory"); + let config_path = temp_dir.path().join("bridge-conf.toml"); + let pkhs = sample_pkhs_b58(); + + let config_content = format!( + r#" +node_id = 0 +base_ws_url = "wss://mainnet.base.org" +inbox_contract_address = "0x1234567890123456789012345678901234567890" +nock_contract_address = "0x0000000000000000000000000000000000000001" +my_eth_key = "0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318" +my_nock_key = "3yZe7d" +grpc_address = "http://localhost:5555" +[[nodes]] +ip = "localhost:8001" +eth_pubkey = "0x2c7536E3605D9C16a7a3D7b1898e529396a65c23" +nock_pkh = "{pkh0}" + +[[nodes]] +ip = "localhost:8002" +eth_pubkey = "0x0EE156f080d9cB3BaA3C0DB53D07f13D69CEf4C9" +nock_pkh = "{pkh1}" + +[[nodes]] +ip = "localhost:8003" +eth_pubkey = "0x274BD645de480C325D618c60c661F11275eB77F1" +nock_pkh = "{pkh2}" + +[[nodes]] +ip = "localhost:8004" +eth_pubkey = "0x6dc59eb20f7928935c47A391e35545a2CEC51013" +nock_pkh = "{pkh3}" + +[[nodes]] +ip = "localhost:8005" +eth_pubkey = "0xcaB10dA05fC0aDBb7e91Eadc30f224bcDF601375" +nock_pkh = "{pkh4}" +"#, + pkh0 = pkhs[0], + pkh1 = pkhs[1], + pkh2 = pkhs[2], + pkh3 = pkhs[3], + pkh4 = pkhs[4] + ); + + fs::write(&config_path, config_content).expect("Failed to write test config file"); + + let result = BridgeConfigToml::from_file(&config_path); + assert!(result.is_err()); +} + +#[test] +fn test_parse_confirmation_depths_present() { + let temp_dir = TempDir::new().expect("Failed to create temporary directory"); + let config_path = temp_dir.path().join("bridge-conf.toml"); + let pkhs = sample_pkhs_b58(); + + let config_content = format!( + r#" +node_id = 0 +base_ws_url = "wss://mainnet.base.org" +inbox_contract_address = "0x1234567890123456789012345678901234567890" +nock_contract_address = "0x0000000000000000000000000000000000000001" +my_eth_key = "0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318" +my_nock_key = "3yZe7d" +grpc_address = "http://localhost:5555" +base_confirmation_depth = 301 +nockchain_confirmation_depth = 123 + +[[nodes]] +ip = "localhost:8001" +eth_pubkey = "0x2c7536E3605D9C16a7a3D7b1898e529396a65c23" +nock_pkh = "{pkh0}" + +[[nodes]] +ip = "localhost:8002" +eth_pubkey = "0x0EE156f080d9cB3BaA3C0DB53D07f13D69CEf4C9" +nock_pkh = "{pkh1}" + +[[nodes]] +ip = "localhost:8003" +eth_pubkey = "0x274BD645de480C325D618c60c661F11275eB77F1" +nock_pkh = "{pkh2}" + +[[nodes]] +ip = "localhost:8004" +eth_pubkey = "0x6dc59eb20f7928935c47A391e35545a2CEC51013" +nock_pkh = "{pkh3}" + +[[nodes]] +ip = "localhost:8005" +eth_pubkey = "0xcaB10dA05fC0aDBb7e91Eadc30f224bcDF601375" +nock_pkh = "{pkh4}" +"#, + pkh0 = pkhs[0], + pkh1 = pkhs[1], + pkh2 = pkhs[2], + pkh3 = pkhs[3], + pkh4 = pkhs[4] + ); + + fs::write(&config_path, config_content).expect("Failed to write test config file"); + + let config = + BridgeConfigToml::from_file(&config_path).expect("Failed to parse valid TOML config"); + assert_eq!(config.base_confirmation_depth, 301); + assert_eq!(config.nockchain_confirmation_depth, 123); +} + +#[test] +fn test_parse_valid_toml_config() { + let temp_dir = TempDir::new().expect("Failed to create temporary directory"); + let config_path = temp_dir.path().join("bridge-conf.toml"); + let pkhs = sample_pkhs_b58(); + + let config_content = format!( + r#" +node_id = 0 +base_ws_url = "wss://mainnet.base.org" +inbox_contract_address = "0x1234567890123456789012345678901234567890" +nock_contract_address = "0x0000000000000000000000000000000000000001" +my_eth_key = "0x4c0883a69102937d6231471b5dbb6204fe5129617082792ae468d01a3f362318" +my_nock_key = "3yZe7d" +grpc_address = "http://localhost:5555" +base_confirmation_depth = 300 +nockchain_confirmation_depth = 100 + +[[nodes]] +ip = "localhost:8001" +eth_pubkey = "0x2c7536E3605D9C16a7a3D7b1898e529396a65c23" +nock_pkh = "{pkh0}" + +[[nodes]] +ip = "localhost:8002" +eth_pubkey = "0x0EE156f080d9cB3BaA3C0DB53D07f13D69CEf4C9" +nock_pkh = "{pkh1}" + +[[nodes]] +ip = "localhost:8003" +eth_pubkey = "0x274BD645de480C325D618c60c661F11275eB77F1" +nock_pkh = "{pkh2}" + +[[nodes]] +ip = "localhost:8004" +eth_pubkey = "0x6dc59eb20f7928935c47A391e35545a2CEC51013" +nock_pkh = "{pkh3}" + +[[nodes]] +ip = "localhost:8005" +eth_pubkey = "0xcaB10dA05fC0aDBb7e91Eadc30f224bcDF601375" +nock_pkh = "{pkh4}" +"#, + pkh0 = pkhs[0], + pkh1 = pkhs[1], + pkh2 = pkhs[2], + pkh3 = pkhs[3], + pkh4 = pkhs[4] + ); + + fs::write(&config_path, config_content).expect("Failed to write test config file"); + + let config = + BridgeConfigToml::from_file(&config_path).expect("Failed to parse valid TOML config"); + + assert_eq!(config.node_id, 0); + assert_eq!(config.base_ws_url, "wss://mainnet.base.org"); + assert_eq!( + config.inbox_contract_address.as_deref(), + Some("0x1234567890123456789012345678901234567890") + ); + assert_eq!( + config.nock_contract_address.as_deref(), + Some("0x0000000000000000000000000000000000000001") + ); + assert_eq!(config.nodes.len(), 5); + assert_eq!(config.nodes[0].ip, "localhost:8001"); + + let node_config = config + .to_node_config() + .expect("Failed to convert config to node config"); + let expected_pkh = + NockPkh::from_base58(pkhs[0]).expect("Failed to parse expected pkh from base58"); + assert_eq!(node_config.nodes[0].nock_pkh, expected_pkh); +} + +#[test] +fn test_hex_key_parsing() { + let temp_dir = TempDir::new().expect("Failed to create temporary directory"); + let config_path = temp_dir.path().join("bridge-conf.toml"); + let pkhs = sample_pkhs_b58(); + + let config_content = format!( + r#" +node_id = 0 +base_ws_url = "wss://mainnet.base.org" +inbox_contract_address = "0x1234567890123456789012345678901234567890" +nock_contract_address = "0x0000000000000000000000000000000000000001" +my_eth_key = "0xDEADBEEF" +my_nock_key = "3yZe7d" +grpc_address = "http://localhost:5555" +base_confirmation_depth = 300 +nockchain_confirmation_depth = 100 + +[[nodes]] +ip = "localhost:8001" +eth_pubkey = "0x2c7536E3605D9C16a7a3D7b1898e529396a65c23" +nock_pkh = "{pkh0}" + +[[nodes]] +ip = "localhost:8002" +eth_pubkey = "0x0EE156f080d9cB3BaA3C0DB53D07f13D69CEf4C9" +nock_pkh = "{pkh1}" + +[[nodes]] +ip = "localhost:8003" +eth_pubkey = "0x274BD645de480C325D618c60c661F11275eB77F1" +nock_pkh = "{pkh2}" + +[[nodes]] +ip = "localhost:8004" +eth_pubkey = "0x6dc59eb20f7928935c47A391e35545a2CEC51013" +nock_pkh = "{pkh3}" + +[[nodes]] +ip = "localhost:8005" +eth_pubkey = "0xcaB10dA05fC0aDBb7e91Eadc30f224bcDF601375" +nock_pkh = "{pkh4}" +"#, + pkh0 = pkhs[0], + pkh1 = pkhs[1], + pkh2 = pkhs[2], + pkh3 = pkhs[3], + pkh4 = pkhs[4] + ); + + fs::write(&config_path, config_content).expect("Failed to write test config file"); + + let config_toml = + BridgeConfigToml::from_file(&config_path).expect("Failed to parse TOML config"); + let node_config = config_toml + .to_node_config() + .expect("Failed to convert config to node config"); + + assert_eq!(node_config.my_eth_key.as_slice(), &[0xDE, 0xAD, 0xBE, 0xEF]); + assert_eq!( + node_config.my_nock_key.limbs(), + &base58_belts::<8>("3yZe7d") + ); +} + +#[test] +fn test_base58_key_parsing() { + let temp_dir = TempDir::new().expect("Failed to create temporary directory"); + let config_path = temp_dir.path().join("bridge-conf.toml"); + let pkhs = sample_pkhs_b58(); + + let config_content = format!( + r#" +node_id = 1 +base_ws_url = "wss://mainnet.base.org" +inbox_contract_address = "0x1234567890123456789012345678901234567890" +nock_contract_address = "0x0000000000000000000000000000000000000001" +my_eth_key = "0x1234" +my_nock_key = "3yZe7d" +grpc_address = "http://localhost:5555" +base_confirmation_depth = 300 +nockchain_confirmation_depth = 100 + +[[nodes]] +ip = "localhost:8001" +eth_pubkey = "0x2c7536E3605D9C16a7a3D7b1898e529396a65c23" +nock_pkh = "{pkh0}" + +[[nodes]] +ip = "localhost:8002" +eth_pubkey = "0x0EE156f080d9cB3BaA3C0DB53D07f13D69CEf4C9" +nock_pkh = "{pkh1}" + +[[nodes]] +ip = "localhost:8003" +eth_pubkey = "0x274BD645de480C325D618c60c661F11275eB77F1" +nock_pkh = "{pkh2}" + +[[nodes]] +ip = "localhost:8004" +eth_pubkey = "0x6dc59eb20f7928935c47A391e35545a2CEC51013" +nock_pkh = "{pkh3}" + +[[nodes]] +ip = "localhost:8005" +eth_pubkey = "0xcaB10dA05fC0aDBb7e91Eadc30f224bcDF601375" +nock_pkh = "{pkh4}" +"#, + pkh0 = pkhs[0], + pkh1 = pkhs[1], + pkh2 = pkhs[2], + pkh3 = pkhs[3], + pkh4 = pkhs[4] + ); + + fs::write(&config_path, config_content).expect("Failed to write test config file"); + + let config_toml = + BridgeConfigToml::from_file(&config_path).expect("Failed to parse TOML config"); + let node_config = config_toml + .to_node_config() + .expect("Failed to convert config to node config"); + + assert_eq!( + node_config.my_nock_key.limbs(), + &base58_belts::<8>("3yZe7d") + ); +} + +#[test] +fn test_conversion_to_node_config() { + let temp_dir = TempDir::new().expect("Failed to create temporary directory"); + let config_path = temp_dir.path().join("bridge-conf.toml"); + let pkhs = sample_pkhs_b58(); + + let config_content = format!( + r#" +node_id = 2 +base_ws_url = "wss://mainnet.base.org" +inbox_contract_address = "0x1234567890123456789012345678901234567890" +nock_contract_address = "0x0000000000000000000000000000000000000001" +my_eth_key = "0xABCD" +my_nock_key = "3yZe7d" +deposit_address = "5HueCGU8rMjxEXxiPuD5BDku4MkFqeZyd4dZ1jvhTVqvbTLvyTJ" +grpc_address = "http://localhost:5555" +base_confirmation_depth = 300 +nockchain_confirmation_depth = 100 + +[[nodes]] +ip = "node1.example.com" +eth_pubkey = "0x2c7536E3605D9C16a7a3D7b1898e529396a65c23" +nock_pkh = "{pkh0}" + +[[nodes]] +ip = "node2.example.com" +eth_pubkey = "0x0EE156f080d9cB3BaA3C0DB53D07f13D69CEf4C9" +nock_pkh = "{pkh1}" + +[[nodes]] +ip = "node3.example.com" +eth_pubkey = "0x274BD645de480C325D618c60c661F11275eB77F1" +nock_pkh = "{pkh2}" + +[[nodes]] +ip = "node4.example.com" +eth_pubkey = "0x6dc59eb20f7928935c47A391e35545a2CEC51013" +nock_pkh = "{pkh3}" + +[[nodes]] +ip = "node5.example.com" +eth_pubkey = "0xcaB10dA05fC0aDBb7e91Eadc30f224bcDF601375" +nock_pkh = "{pkh4}" +"#, + pkh0 = pkhs[0], + pkh1 = pkhs[1], + pkh2 = pkhs[2], + pkh3 = pkhs[3], + pkh4 = pkhs[4] + ); + + fs::write(&config_path, config_content).expect("Failed to write test config file"); + + let config_toml = + BridgeConfigToml::from_file(&config_path).expect("Failed to parse TOML config"); + let node_config = config_toml + .to_node_config() + .expect("Failed to convert config to node config"); + + assert_eq!(node_config.node_id, 2); + assert_eq!(node_config.nodes.len(), 5); + assert_eq!(node_config.nodes[0].ip, "node1.example.com"); + assert_eq!(node_config.nodes[4].ip, "node5.example.com"); +} + +#[test] +fn test_malformed_toml() { + let temp_dir = TempDir::new().expect("Failed to create temporary directory"); + let config_path = temp_dir.path().join("bridge-conf.toml"); + + let config_content = r#" +node_id = "not a number" +base_ws_url = "wss://mainnet.base.org" +"#; + + fs::write(&config_path, config_content).expect("Failed to write test config file"); + + let result = BridgeConfigToml::from_file(&config_path); + assert!(result.is_err()); +} + +#[test] +fn test_missing_config_file() { + let temp_dir = TempDir::new().expect("Failed to create temporary directory"); + let config_path = temp_dir.path().join("nonexistent.toml"); + + let result = BridgeConfigToml::from_file(&config_path); + assert!(result.is_err()); +} + +#[test] +fn test_invalid_hex_key() { + let temp_dir = TempDir::new().expect("Failed to create temporary directory"); + let config_path = temp_dir.path().join("bridge-conf.toml"); + let pkh = sample_pkh_b58(); + + let config_content = format!( + r#" +node_id = 0 +base_ws_url = "wss://mainnet.base.org" +inbox_contract_address = "0x1234567890123456789012345678901234567890" +nock_contract_address = "0x0000000000000000000000000000000000000001" +my_eth_key = "0xZZZZ" +my_nock_key = "3yZe7d" +deposit_address = "5HueCGU8rMjxEXxiPuD5BDku4MkFqeZyd4dZ1jvhTVqvbTLvyTJ" +grpc_address = "http://localhost:5555" +base_confirmation_depth = 300 +nockchain_confirmation_depth = 100 + +[[nodes]] +ip = "localhost:8001" +eth_pubkey = "0x2c7536E3605D9C16a7a3D7b1898e529396a65c23" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8002" +eth_pubkey = "0x0EE156f080d9cB3BaA3C0DB53D07f13D69CEf4C9" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8003" +eth_pubkey = "0x274BD645de480C325D618c60c661F11275eB77F1" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8004" +eth_pubkey = "0x6dc59eb20f7928935c47A391e35545a2CEC51013" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8005" +eth_pubkey = "0xcaB10dA05fC0aDBb7e91Eadc30f224bcDF601375" +nock_pkh = "{pkh}" +"#, + pkh = pkh + ); + + fs::write(&config_path, config_content).expect("Failed to write test config file"); + + let config_toml = + BridgeConfigToml::from_file(&config_path).expect("Failed to parse TOML config"); + let result = config_toml.to_node_config(); + assert!(result.is_err()); +} + +#[test] +fn test_inbox_contract_address_parsing() { + let temp_dir = TempDir::new().expect("Failed to create temporary directory"); + let config_path = temp_dir.path().join("bridge-conf.toml"); + let pkh = sample_pkh_b58(); + + let config_content = format!( + r#" +node_id = 0 +base_ws_url = "wss://mainnet.base.org" +inbox_contract_address = "0x1234567890123456789012345678901234567890" +nock_contract_address = "0x0000000000000000000000000000000000000001" +my_eth_key = "0x1234" +my_nock_key = "3yZe7d" +deposit_address = "5HueCGU8rMjxEXxiPuD5BDku4MkFqeZyd4dZ1jvhTVqvbTLvyTJ" +grpc_address = "http://localhost:5555" +base_confirmation_depth = 300 +nockchain_confirmation_depth = 100 + +[[nodes]] +ip = "localhost:8001" +eth_pubkey = "0x2c7536E3605D9C16a7a3D7b1898e529396a65c23" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8002" +eth_pubkey = "0x0EE156f080d9cB3BaA3C0DB53D07f13D69CEf4C9" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8003" +eth_pubkey = "0x274BD645de480C325D618c60c661F11275eB77F1" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8004" +eth_pubkey = "0x6dc59eb20f7928935c47A391e35545a2CEC51013" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8005" +eth_pubkey = "0xcaB10dA05fC0aDBb7e91Eadc30f224bcDF601375" +nock_pkh = "{pkh}" +"#, + pkh = pkh + ); + + fs::write(&config_path, config_content).expect("Failed to write test config file"); + + let config_toml = + BridgeConfigToml::from_file(&config_path).expect("Failed to parse TOML config"); + let address = config_toml + .inbox_contract_address() + .expect("Failed to get inbox contract address"); + let expected_inbox = Address::from_str("0x1234567890123456789012345678901234567890") + .expect("Failed to parse expected inbox address"); + assert_eq!(address, expected_inbox); + + let nock_address = config_toml + .nock_contract_address() + .expect("Failed to get nock contract address"); + let expected_nock = Address::from_str("0x0000000000000000000000000000000000000001") + .expect("Failed to parse expected nock address"); + assert_eq!(nock_address, expected_nock); +} + +#[test] +fn test_invalid_base58_pkh_length() { + let temp_dir = TempDir::new().expect("Failed to create temporary directory"); + let config_path = temp_dir.path().join("bridge-conf.toml"); + let pkh = sample_pkh_b58(); + + let config_content = format!( + r#" +node_id = 0 +base_ws_url = "wss://mainnet.base.org" +inbox_contract_address = "0x1234567890123456789012345678901234567890" +nock_contract_address = "0x0000000000000000000000000000000000000001" +my_eth_key = "0x1234" +my_nock_key = "3yZe7d" +deposit_address = "5HueCGU8rMjxEXxiPuD5BDku4MkFqeZyd4dZ1jvhTVqvbTLvyTJ" +grpc_address = "http://localhost:5555" +base_confirmation_depth = 300 +nockchain_confirmation_depth = 100 + +[[nodes]] +ip = "localhost:8001" +eth_pubkey = "0x2c7536E3605D9C16a7a3D7b1898e529396a65c23" +nock_pkh = "short" + +[[nodes]] +ip = "localhost:8002" +eth_pubkey = "0x0EE156f080d9cB3BaA3C0DB53D07f13D69CEf4C9" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8003" +eth_pubkey = "0x274BD645de480C325D618c60c661F11275eB77F1" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8004" +eth_pubkey = "0x6dc59eb20f7928935c47A391e35545a2CEC51013" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8005" +eth_pubkey = "0xcaB10dA05fC0aDBb7e91Eadc30f224bcDF601375" +nock_pkh = "{pkh}" +"#, + pkh = pkh + ); + + fs::write(&config_path, config_content).expect("Failed to write test config file"); + + let config_toml = + BridgeConfigToml::from_file(&config_path).expect("Failed to parse TOML config"); + let result = config_toml.to_node_config(); + assert!(result.is_err()); +} + +#[test] +fn test_invalid_eth_key() { + let temp_dir = TempDir::new().expect("Failed to create temporary directory"); + let config_path = temp_dir.path().join("bridge-conf.toml"); + let pkh = sample_pkh_b58(); + + let config_content = format!( + r#" +node_id = 0 +base_ws_url = "wss://mainnet.base.org" +inbox_contract_address = "0x1234567890123456789012345678901234567890" +nock_contract_address = "0x0000000000000000000000000000000000000001" +my_eth_key = "not a key" +my_nock_key = "3yZe7d" +deposit_address = "5HueCGU8rMjxEXxiPuD5BDku4MkFqeZyd4dZ1jvhTVqvbTLvyTJ" +grpc_address = "http://localhost:5555" +base_confirmation_depth = 300 +nockchain_confirmation_depth = 100 + +[[nodes]] +ip = "localhost:8001" +eth_pubkey = "0x2c7536E3605D9C16a7a3D7b1898e529396a65c23" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8002" +eth_pubkey = "0x0EE156f080d9cB3BaA3C0DB53D07f13D69CEf4C9" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8003" +eth_pubkey = "0x274BD645de480C325D618c60c661F11275eB77F1" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8004" +eth_pubkey = "0x6dc59eb20f7928935c47A391e35545a2CEC51013" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8005" +eth_pubkey = "0xcaB10dA05fC0aDBb7e91Eadc30f224bcDF601375" +nock_pkh = "{pkh}" +"#, + pkh = pkh + ); + + fs::write(&config_path, config_content).expect("Failed to write test config file"); + + let config_toml = + BridgeConfigToml::from_file(&config_path).expect("Failed to parse TOML config"); + let result = config_toml.to_node_config(); + assert!(result.is_err()); +} + +#[test] +fn test_invalid_node_count() { + let temp_dir = TempDir::new().expect("Failed to create temporary directory"); + let config_path = temp_dir.path().join("bridge-conf.toml"); + let pkh = sample_pkh_b58(); + + let config_content = format!( + r#" +node_id = 0 +base_ws_url = "wss://mainnet.base.org" +inbox_contract_address = "0x1234567890123456789012345678901234567890" +nock_contract_address = "0x0000000000000000000000000000000000000001" +my_eth_key = "0x1234" +my_nock_key = "3yZe7d" +deposit_address = "5HueCGU8rMjxEXxiPuD5BDku4MkFqeZyd4dZ1jvhTVqvbTLvyTJ" +grpc_address = "http://localhost:5555" +base_confirmation_depth = 300 +nockchain_confirmation_depth = 100 + +[[nodes]] +ip = "localhost:8001" +eth_pubkey = "0x2c7536E3605D9C16a7a3D7b1898e529396a65c23" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8002" +eth_pubkey = "0x0EE156f080d9cB3BaA3C0DB53D07f13D69CEf4C9" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8003" +eth_pubkey = "0x274BD645de480C325D618c60c661F11275eB77F1" +nock_pkh = "{pkh}" +"#, + pkh = pkh + ); + + fs::write(&config_path, config_content).expect("Failed to write test config file"); + + let config_toml = + BridgeConfigToml::from_file(&config_path).expect("Failed to parse TOML config"); + let result = config_toml.to_node_config(); + assert!(result.is_err()); +} + +#[test] +fn test_duplicate_node_ips() { + let temp_dir = TempDir::new().expect("Failed to create temporary directory"); + let config_path = temp_dir.path().join("bridge-conf.toml"); + let pkh = sample_pkh_b58(); + + let config_content = format!( + r#" +node_id = 0 +base_ws_url = "wss://mainnet.base.org" +inbox_contract_address = "0x1234567890123456789012345678901234567890" +nock_contract_address = "0x0000000000000000000000000000000000000001" +my_eth_key = "0x1234" +my_nock_key = "3yZe7d" +deposit_address = "5HueCGU8rMjxEXxiPuD5BDku4MkFqeZyd4dZ1jvhTVqvbTLvyTJ" +grpc_address = "http://localhost:5555" +base_confirmation_depth = 300 +nockchain_confirmation_depth = 100 + +[[nodes]] +ip = "localhost:8001" +eth_pubkey = "0x2c7536E3605D9C16a7a3D7b1898e529396a65c23" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8001" +eth_pubkey = "0x0EE156f080d9cB3BaA3C0DB53D07f13D69CEf4C9" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8003" +eth_pubkey = "0x274BD645de480C325D618c60c661F11275eB77F1" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8004" +eth_pubkey = "0x6dc59eb20f7928935c47A391e35545a2CEC51013" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8005" +eth_pubkey = "0xcaB10dA05fC0aDBb7e91Eadc30f224bcDF601375" +nock_pkh = "{pkh}" +"#, + pkh = pkh + ); + + fs::write(&config_path, config_content).expect("Failed to write test config file"); + + let config_toml = + BridgeConfigToml::from_file(&config_path).expect("Failed to parse TOML config"); + let result = config_toml.to_node_config(); + assert!(result.is_err()); +} + +#[test] +fn test_duplicate_eth_pubkeys() { + let temp_dir = TempDir::new().expect("Failed to create temporary directory"); + let config_path = temp_dir.path().join("bridge-conf.toml"); + let pkh = sample_pkh_b58(); + + let config_content = format!( + r#" +node_id = 0 +base_ws_url = "wss://mainnet.base.org" +inbox_contract_address = "0x1234567890123456789012345678901234567890" +nock_contract_address = "0x0000000000000000000000000000000000000001" +my_eth_key = "0x1234" +my_nock_key = "3yZe7d" +deposit_address = "5HueCGU8rMjxEXxiPuD5BDku4MkFqeZyd4dZ1jvhTVqvbTLvyTJ" +grpc_address = "http://localhost:5555" +base_confirmation_depth = 300 +nockchain_confirmation_depth = 100 + +[[nodes]] +ip = "localhost:8001" +eth_pubkey = "0x2c7536E3605D9C16a7a3D7b1898e529396a65c23" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8002" +eth_pubkey = "0x2c7536E3605D9C16a7a3D7b1898e529396a65c23" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8003" +eth_pubkey = "0x274BD645de480C325D618c60c661F11275eB77F1" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8004" +eth_pubkey = "0x6dc59eb20f7928935c47A391e35545a2CEC51013" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8005" +eth_pubkey = "0xcaB10dA05fC0aDBb7e91Eadc30f224bcDF601375" +nock_pkh = "{pkh}" +"#, + pkh = pkh + ); + + fs::write(&config_path, config_content).expect("Failed to write test config file"); + + let config_toml = + BridgeConfigToml::from_file(&config_path).expect("Failed to parse TOML config"); + let result = config_toml.to_node_config(); + assert!(result.is_err()); +} + +/// Validates that the production operator addresses are correctly formatted. +/// This test uses the REAL operator addresses from bridge-conf.sepolia.toml +/// to catch any typos or format errors before they cause runtime failures. +#[test] +fn test_production_operator_addresses() { + use std::collections::HashSet; + + // Real production operator data - must match bridge-conf.sepolia.toml and bridge-conf.mainnet.toml + let operators: [(&str, &str, &str); 5] = [ + ( + "Zorp #1", "0x091f5A663Dba081547D60bf0aa50D0a56F1e0964", + "AD6Mw1QUnPUrnVpyj2gW2jT6Jd6WsuZQmPn79XpZoFEocuvV12iDkvh", + ), + ( + "Zorp #2", "0xcadEEee25c168b8Cf054f290f97dbF568a853F64", + "6KrZT5hHLY1fva9AUDeGtZu5Jznm4RDLYfjcGjuU49nWoNym5ZeX5X5", + ), + ( + "Pero", "0x79A2740620d989D51e08f0de494F520e95E5b9cb", + "CDLzgKWAKFXYABkuQaMwbttDSTDMh3Wy2Eoq2XiArsyxn7vScNHupBb", + ), + ( + "Nockbox", "0xDDDE747A20b17ea13F4c4EEB77615b1F18ca0b3B", + "7E47xYNVEyt7jGmLsiChUHnyw88AfBvzJfXfEQkPmMo2ZWsdcPudwmV", + ), + ( + "SWPS", "0xf0441e68E994c20C998e54A23f0d40b76185Be76", + "3xSyK6RQUaYzE8YDUamkpKRHALxaYo8E7eppawwE4sP35c3PASc6koq", + ), + ]; + + let mut eth_addrs: HashSet<&str> = HashSet::new(); + let mut pkhs: HashSet<&str> = HashSet::new(); + + for (name, eth_addr, pkh) in &operators { + // Validate ETH address parses correctly + Address::from_str(eth_addr) + .unwrap_or_else(|e| panic!("{} ETH address '{}' is invalid: {}", name, eth_addr, e)); + + // Validate PKH parses correctly (base58 -> 5 Belt limbs) + NockPkh::from_base58(pkh) + .unwrap_or_else(|e| panic!("{} PKH '{}' is invalid: {}", name, pkh, e)); + + // Check uniqueness + assert!( + eth_addrs.insert(eth_addr), + "{} has duplicate ETH address: {}", + name, + eth_addr + ); + assert!(pkhs.insert(pkh), "{} has duplicate PKH: {}", name, pkh); + } + + // Verify we have exactly 5 unique operators + assert_eq!(eth_addrs.len(), 5, "Expected 5 unique ETH addresses"); + assert_eq!(pkhs.len(), 5, "Expected 5 unique PKHs"); +} + +/// Validates the Sepolia contract addresses are correctly formatted. +#[test] +fn test_sepolia_contract_addresses() { + // Sepolia contract addresses from bridge-conf.sepolia.toml + let inbox = "0x9b1becA13c39b9Be10dB616F1bE10C3CeF9Dfb36"; + let nock = "0xA9cd4087D9B050D8B35727AAf810296CA957c7B3"; + + Address::from_str(inbox).expect("Sepolia inbox contract address is invalid"); + Address::from_str(nock).expect("Sepolia nock contract address is invalid"); +} + +#[test] +fn test_duplicate_nock_pkhs() { + let temp_dir = TempDir::new().expect("Failed to create temporary directory"); + let config_path = temp_dir.path().join("bridge-conf.toml"); + let pkh = sample_pkh_b58(); + + // Using the same PKH for all nodes should fail duplicate detection + let config_content = format!( + r#" +node_id = 0 +base_ws_url = "wss://mainnet.base.org" +inbox_contract_address = "0x1234567890123456789012345678901234567890" +nock_contract_address = "0x0000000000000000000000000000000000000001" +my_eth_key = "0x1234" +my_nock_key = "3yZe7d" +deposit_address = "5HueCGU8rMjxEXxiPuD5BDku4MkFqeZyd4dZ1jvhTVqvbTLvyTJ" +grpc_address = "http://localhost:5555" +base_confirmation_depth = 300 +nockchain_confirmation_depth = 100 + +[[nodes]] +ip = "localhost:8001" +eth_pubkey = "0x2c7536E3605D9C16a7a3D7b1898e529396a65c23" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8002" +eth_pubkey = "0x0EE156f080d9cB3BaA3C0DB53D07f13D69CEf4C9" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8003" +eth_pubkey = "0x274BD645de480C325D618c60c661F11275eB77F1" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8004" +eth_pubkey = "0x6dc59eb20f7928935c47A391e35545a2CEC51013" +nock_pkh = "{pkh}" + +[[nodes]] +ip = "localhost:8005" +eth_pubkey = "0xcaB10dA05fC0aDBb7e91Eadc30f224bcDF601375" +nock_pkh = "{pkh}" +"#, + pkh = pkh + ); + + fs::write(&config_path, config_content).expect("Failed to write test config file"); + + let config_toml = + BridgeConfigToml::from_file(&config_path).expect("Failed to parse TOML config"); + let result = config_toml.to_node_config(); + assert!(result.is_err()); +} diff --git a/crates/bridge/tests/failover_tests.rs b/crates/bridge/tests/failover_tests.rs new file mode 100644 index 000000000..5ff37e25e --- /dev/null +++ b/crates/bridge/tests/failover_tests.rs @@ -0,0 +1,485 @@ +#![allow(clippy::unwrap_used, clippy::unnecessary_cast)] +//! Integration tests for 3-of-5 degraded operation and failover. +//! +//! These tests validate the core architectural change: all nodes sign, +//! proposer selects and posts, failover works when proposer is offline. +//! +//! ## Test Scenarios +//! +//! 1. **Happy path** - All 5 nodes healthy, deposit flows through +//! 2. **Proposer offline** - Kill proposer, verify failover after backoff +//! 3. **Minimum viable (3 nodes)** - Kill 2 nodes, verify still operational +//! 4. **Below threshold (2 nodes)** - Kill 3 nodes, verify bridge halts +//! 5. **Node recovery** - Stalled deposit completes when node returns +//! 6. **Duplicate prevention** - Same deposit twice, verify single submission +//! 7. **Crash recovery** - Kill proposer mid-posting, restart, verify completion + +#[cfg(feature = "bazel_build")] +use bridge_test_harness::{sample_deposit, sample_deposit_with_nonce, TestCluster, TestNode}; +#[cfg(not(feature = "bazel_build"))] +mod test_harness; + +#[cfg(not(feature = "bazel_build"))] +use self::test_harness::{sample_deposit, sample_deposit_with_nonce, TestCluster, TestNode}; + +/// Test 1: Happy path - all 5 nodes online, deposit goes through. +/// +/// Expected behavior: +/// - All 5 nodes sign the deposit +/// - Proposer collects 5 signatures +/// - Proposer posts to Base successfully +/// - Exactly 1 submission recorded +#[tokio::test] +async fn test_happy_path_all_nodes_online() { + let mut cluster = TestCluster::new(5).await; + let deposit = sample_deposit(100, 1000); + + // Trigger deposit across all nodes + cluster.trigger_deposit(deposit.clone()).await; + + // Wait for signatures to propagate (all 5 online nodes should sign) + cluster.wait_for_signatures(&deposit, 5).await; + + // Verify all nodes have threshold + for node in &cluster.nodes { + assert!( + node.has_threshold(&bridge::types::DepositId::from_effect_payload(&deposit)), + "Node {} should have reached threshold", + node.node_id + ); + } + + // Proposer posts + let proposer_id = cluster.initial_proposer(&deposit); + let result = cluster.post_deposit_from_node(&deposit, proposer_id); + assert!(result.is_ok(), "Proposer should successfully post deposit"); + + // Verify exactly 1 submission + let submissions = cluster.base_submissions(); + assert_eq!(submissions.len(), 1, "Should have exactly 1 submission"); + assert_eq!( + submissions[0].submitter_node_id, proposer_id, + "Submission should be from proposer" + ); + assert_eq!( + submissions[0].signature_count, 5, + "Should have all 5 signatures" + ); +} + +/// Test 2: Proposer offline - failover to next node in rotation. +/// +/// Expected behavior: +/// - 4 nodes sign (proposer offline) +/// - Threshold still reached (3-of-5) +/// - Failover node waits for backoff period +/// - Failover node posts successfully +#[tokio::test] +async fn test_proposer_offline_failover() { + let mut cluster = TestCluster::new(5).await; + let deposit = sample_deposit(200, 2000); + + // Determine proposer and kill it BEFORE deposit triggers + let proposer_id = (deposit.block_height as usize) % 5; + cluster.kill_node(proposer_id).await; + + // Trigger deposit (proposer is offline, won't sign) + cluster.trigger_deposit(deposit.clone()).await; + + // Wait for remaining 4 nodes to reach threshold + cluster.wait_for_signatures(&deposit, 4).await; + + // Verify proposer cannot post (offline) + let result = cluster.post_deposit_from_node(&deposit, proposer_id); + assert!(result.is_err(), "Offline proposer should fail to post"); + + // Next node in rotation should be able to post (after backoff in real system) + let failover_id = (proposer_id + 1) % 5; + let result = cluster.post_deposit_from_node(&deposit, failover_id); + assert!(result.is_ok(), "Failover node should successfully post"); + + // Verify submission + let submissions = cluster.base_submissions(); + assert_eq!(submissions.len(), 1); + assert_eq!(submissions[0].submitter_node_id, failover_id); + assert!( + submissions[0].signature_count >= 3, + "Should have at least 3 signatures" + ); +} + +/// Test 3: Minimum viable configuration - 3 of 5 nodes online. +/// +/// Expected behavior: +/// - Exactly 3 nodes online and signing +/// - Threshold reached (3-of-5) +/// - Deposit still processes successfully +#[tokio::test] +async fn test_minimum_viable_three_nodes() { + let mut cluster = TestCluster::new(5).await; + let deposit = sample_deposit(300, 3000); + + // Kill 2 nodes (leaving exactly 3 online) + cluster.kill_node(1).await; + cluster.kill_node(3).await; + + // Trigger deposit + cluster.trigger_deposit(deposit.clone()).await; + + // Wait for 3 nodes to reach threshold + cluster.wait_for_signatures(&deposit, 3).await; + + // Any online node with threshold can post + let online_nodes: Vec = cluster + .nodes + .iter() + .filter(|n| n.is_online()) + .map(|n| n.node_id) + .collect(); + + assert_eq!(online_nodes.len(), 3, "Should have exactly 3 online nodes"); + + // First online node posts + let poster_id = online_nodes[0]; + let result = cluster.post_deposit_from_node(&deposit, poster_id); + assert!(result.is_ok(), "Online node should successfully post"); + + // Verify submission + let submissions = cluster.base_submissions(); + assert_eq!(submissions.len(), 1); + assert_eq!(submissions[0].signature_count, 3); +} + +/// Test 4: Below threshold - only 2 of 5 nodes online. +/// +/// Expected behavior: +/// - Only 2 signatures collected +/// - Threshold NOT reached (need 3-of-5) +/// - Bridge cannot post deposit +#[tokio::test] +async fn test_below_threshold_bridge_halts() { + let mut cluster = TestCluster::new(5).await; + let deposit = sample_deposit(400, 4000); + + // Kill 3 nodes (leaving only 2 online) + cluster.kill_node(0).await; + cluster.kill_node(2).await; + cluster.kill_node(4).await; + + // Trigger deposit + cluster.trigger_deposit(deposit.clone()).await; + + // Give time for signatures to propagate + tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; + + // Verify NO node has reached threshold + for node in &cluster.nodes { + if node.is_online() { + assert!( + !node.has_threshold(&bridge::types::DepositId::from_effect_payload(&deposit)), + "Node {} should NOT have threshold with only 2 signatures", + node.node_id + ); + } + } + + // Attempt to post from online node should fail + let online_id = cluster + .nodes + .iter() + .find(|n| n.is_online()) + .map(|n| n.node_id) + .unwrap(); + + let result = cluster.post_deposit_from_node(&deposit, online_id); + assert!(result.is_err(), "Should fail to post without threshold"); + + // Verify no submissions + assert_eq!(cluster.base_submissions().len(), 0); +} + +/// Test 5: Node recovery - deposit completes when third node returns. +/// +/// Expected behavior: +/// - Start with 2 nodes (below threshold) +/// - Bring third node online +/// - Threshold reached +/// - Deposit successfully posts +#[tokio::test] +async fn test_node_recovery_completes_stalled_deposit() { + let mut cluster = TestCluster::new(5).await; + let deposit = sample_deposit(500, 5000); + + // Kill 3 nodes initially (only 2 online) + cluster.kill_node(0).await; + cluster.kill_node(2).await; + cluster.kill_node(4).await; + + // Trigger deposit (will stall) + cluster.trigger_deposit(deposit.clone()).await; + + // Verify stalled (no threshold) + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + let online_nodes: Vec<&TestNode> = cluster.nodes.iter().filter(|n| n.is_online()).collect(); + assert_eq!(online_nodes.len(), 2); + + for node in &online_nodes { + assert!( + !node.has_threshold(&bridge::types::DepositId::from_effect_payload(&deposit)), + "Should not have threshold with 2 nodes" + ); + } + + // Restart one node (now 3 online) + cluster.restart_node(2).await; + + // Rebroadcast deposit to newly online node + cluster.trigger_deposit(deposit.clone()).await; + + // Wait for threshold + cluster.wait_for_signatures(&deposit, 3).await; + + // Now deposit can post + let poster_id = cluster + .nodes + .iter() + .find(|n| { + n.is_online() + && n.has_threshold(&bridge::types::DepositId::from_effect_payload(&deposit)) + }) + .map(|n| n.node_id) + .unwrap(); + + let result = cluster.post_deposit_from_node(&deposit, poster_id); + assert!( + result.is_ok(), + "Deposit should complete after node recovery" + ); + + // Verify submission + assert_eq!(cluster.base_submissions().len(), 1); +} + +/// Test 6: Duplicate prevention - same deposit submitted twice rejected. +/// +/// Expected behavior: +/// - First submission succeeds +/// - Second submission from different node fails +/// - Only 1 on-chain record +#[tokio::test] +async fn test_duplicate_deposit_prevention() { + let mut cluster = TestCluster::new(5).await; + let deposit = sample_deposit(600, 6000); + + // Trigger deposit + cluster.trigger_deposit(deposit.clone()).await; + + // Wait for signatures + cluster.wait_for_signatures(&deposit, 5).await; + + // First node posts + let result1 = cluster.post_deposit_from_node(&deposit, 0); + assert!(result1.is_ok(), "First submission should succeed"); + + // Second node tries to post (duplicate) + let result2 = cluster.post_deposit_from_node(&deposit, 1); + assert!(result2.is_err(), "Duplicate submission should fail"); + + // Verify only 1 submission + assert_eq!(cluster.base_submissions().len(), 1); +} + +/// Test 7: Crash recovery - proposer crashes mid-posting, restart and complete. +/// +/// Expected behavior: +/// - Proposer starts posting +/// - Proposer crashes (simulated by going offline) +/// - Proposer restarts +/// - Proposer successfully completes posting +#[tokio::test] +async fn test_crash_recovery() { + let mut cluster = TestCluster::new(5).await; + let deposit = sample_deposit(700, 7000); + + // Trigger deposit + cluster.trigger_deposit(deposit.clone()).await; + + // Wait for signatures + cluster.wait_for_signatures(&deposit, 5).await; + + // Determine proposer + let proposer_id = cluster.initial_proposer(&deposit); + + // Simulate crash: kill proposer before posting + cluster.kill_node(proposer_id).await; + + // Verify proposer cannot post while offline + let result = cluster.post_deposit_from_node(&deposit, proposer_id); + assert!(result.is_err(), "Offline proposer should fail"); + + // Restart proposer + cluster.restart_node(proposer_id).await; + + // After restart, proposer can complete posting + // (In real system, would need to re-sync cache from peers) + cluster.trigger_deposit(deposit.clone()).await; + cluster.wait_for_signatures(&deposit, 5).await; + + let result = cluster.post_deposit_from_node(&deposit, proposer_id); + assert!( + result.is_ok(), + "Restarted proposer should successfully post" + ); + + // Verify submission + assert_eq!(cluster.base_submissions().len(), 1); +} + +/// Test 8: Multiple concurrent deposits with mixed node availability. +/// +/// Expected behavior: +/// - Process multiple unique deposits concurrently +/// - With one node offline, remaining 4 should still reach threshold +/// - All deposits successfully complete +/// +/// NOTE: This test is currently disabled due to timing issues with concurrent signature +/// propagation in the mock harness. The actual production code handles this correctly +/// via gossip protocol and persistent storage. +#[tokio::test] +async fn test_concurrent_deposits_mixed_availability() { + let mut cluster = TestCluster::new(5).await; + + // Create 3 unique deposits with DIFFERENT as_of values (ensures unique DepositIds) + let deposits: Vec<_> = (0..3) + .map(|i| sample_deposit_with_nonce(800 + i, 8000 + (i * 100) as u64, 1 + i as u64)) + .collect(); + + // Kill node 2 (4 nodes remain - still above threshold) + cluster.kill_node(2).await; + + // Trigger all deposits + for deposit in &deposits { + cluster.trigger_deposit(deposit.clone()).await; + } + + // Wait for signatures (4 nodes online) + for deposit in &deposits { + cluster.wait_for_signatures(deposit, 4).await; + } + + // Post each deposit from its proposer (or any online node with threshold) + for (i, deposit) in deposits.iter().enumerate() { + let proposer_id = cluster.initial_proposer(deposit); + + // Use proposer if online, otherwise use node 0 + let poster_id = if cluster.nodes[proposer_id].is_online() { + proposer_id + } else { + 0 + }; + + let result = cluster.post_deposit_from_node(deposit, poster_id); + + assert!( + result.is_ok(), + "Deposit {} (proposer {}, poster {}) should complete with 4 nodes online", + i, + proposer_id, + poster_id + ); + } + + // Verify all 3 deposits posted + assert_eq!( + cluster.base_submissions().len(), + 3, + "All 3 deposits should be posted" + ); +} + +/// Test 9: Signature threshold edge case - exactly 3 signatures. +/// +/// Expected behavior: +/// - Exactly 3 nodes sign +/// - Threshold reached (3 == threshold) +/// - Deposit posts successfully +#[tokio::test] +async fn test_exactly_threshold_signatures() { + let mut cluster = TestCluster::new(5).await; + let deposit = sample_deposit(900, 9000); + + // Kill 2 nodes + cluster.kill_node(3).await; + cluster.kill_node(4).await; + + // Trigger deposit (only 3 will sign) + cluster.trigger_deposit(deposit.clone()).await; + + // Wait for 3 signatures + cluster.wait_for_signatures(&deposit, 3).await; + + // Verify threshold reached + let online_count = cluster + .nodes + .iter() + .filter(|n| { + n.is_online() + && n.has_threshold(&bridge::types::DepositId::from_effect_payload(&deposit)) + }) + .count(); + + assert_eq!(online_count, 3, "All 3 online nodes should have threshold"); + + // Post successfully + let result = cluster.post_deposit_from_node(&deposit, 0); + assert!( + result.is_ok(), + "Should post with exactly threshold signatures" + ); + + // Verify submission + let submissions = cluster.base_submissions(); + assert_eq!(submissions.len(), 1); + assert_eq!(submissions[0].signature_count, 3); +} + +/// Test 10: All nodes offline except proposer - cannot reach threshold. +/// +/// Expected behavior: +/// - Only proposer signs (1 signature) +/// - Threshold NOT reached +/// - Bridge halts +#[tokio::test] +async fn test_only_proposer_online_fails() { + let mut cluster = TestCluster::new(5).await; + let deposit = sample_deposit(1000, 10000); + + let proposer_id = cluster.initial_proposer(&deposit); + + // Kill all nodes except proposer + for i in 0..5 { + if i != proposer_id { + cluster.kill_node(i).await; + } + } + + // Trigger deposit + cluster.trigger_deposit(deposit.clone()).await; + + // Give time for processing + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + + // Verify proposer alone cannot reach threshold + assert!( + !cluster.nodes[proposer_id] + .has_threshold(&bridge::types::DepositId::from_effect_payload(&deposit)), + "Single node should not reach threshold" + ); + + // Attempt to post should fail + let result = cluster.post_deposit_from_node(&deposit, proposer_id); + assert!(result.is_err(), "Single signature should not be enough"); + + // No submissions + assert_eq!(cluster.base_submissions().len(), 0); +} diff --git a/crates/bridge/tests/logging_tests.rs b/crates/bridge/tests/logging_tests.rs new file mode 100644 index 000000000..dd549c5a2 --- /dev/null +++ b/crates/bridge/tests/logging_tests.rs @@ -0,0 +1,136 @@ +//! Integration tests for bridge file logging functionality. +//! +//! These tests verify the file logging infrastructure works correctly, +//! including directory creation, file rotation, and content writing. + +#![allow(clippy::unwrap_used)] + +use std::io::Write; + +use tempfile::TempDir; +use tracing_appender::rolling::{RollingFileAppender, Rotation}; + +#[test] +fn test_log_directory_creation() { + let temp = TempDir::new().unwrap(); + let log_dir = temp.path().join("logs"); + + // Log dir should not exist yet + assert!(!log_dir.exists()); + + // Create it (same as init_bridge_tracing does) + std::fs::create_dir_all(&log_dir).unwrap(); + assert!(log_dir.exists()); + assert!(log_dir.is_dir()); +} + +#[test] +fn test_rolling_file_appender_creates_file() { + let temp = TempDir::new().unwrap(); + let log_dir = temp.path().join("logs"); + std::fs::create_dir_all(&log_dir).unwrap(); + + // Create a rolling appender (same config as bridge uses) + let mut appender = RollingFileAppender::new(Rotation::DAILY, &log_dir, "bridge.log"); + + // Write a test line + writeln!(appender, "test log line").unwrap(); + drop(appender); // Flush and close + + // Verify file was created + let entries: Vec<_> = std::fs::read_dir(&log_dir) + .unwrap() + .filter_map(|e| e.ok()) + .collect(); + assert_eq!(entries.len(), 1, "Expected exactly one log file"); + + // Verify filename pattern (bridge.log.YYYY-MM-DD) + let filename = entries[0].file_name(); + let filename = filename.to_str().unwrap(); + assert!( + filename.starts_with("bridge.log"), + "Log file should start with 'bridge.log', got: {}", + filename + ); +} + +#[test] +fn test_log_content_written_correctly() { + let temp = TempDir::new().unwrap(); + let log_dir = temp.path().join("logs"); + std::fs::create_dir_all(&log_dir).unwrap(); + + let mut appender = RollingFileAppender::new(Rotation::DAILY, &log_dir, "bridge.log"); + + // Write multiple lines + writeln!(appender, "first line").unwrap(); + writeln!(appender, "second line with data: 12345").unwrap(); + writeln!(appender, "third line").unwrap(); + drop(appender); + + // Read back and verify content + let entries: Vec<_> = std::fs::read_dir(&log_dir) + .unwrap() + .filter_map(|e| e.ok()) + .collect(); + let log_path = entries[0].path(); + let content = std::fs::read_to_string(log_path).unwrap(); + + assert!(content.contains("first line"), "Missing first line"); + assert!( + content.contains("second line with data: 12345"), + "Missing second line" + ); + assert!(content.contains("third line"), "Missing third line"); +} + +#[test] +fn test_multiple_writes_append() { + let temp = TempDir::new().unwrap(); + let log_dir = temp.path().join("logs"); + std::fs::create_dir_all(&log_dir).unwrap(); + + // First write + { + let mut appender = RollingFileAppender::new(Rotation::DAILY, &log_dir, "bridge.log"); + writeln!(appender, "write 1").unwrap(); + } + + // Second write (should append, not overwrite) + { + let mut appender = RollingFileAppender::new(Rotation::DAILY, &log_dir, "bridge.log"); + writeln!(appender, "write 2").unwrap(); + } + + // Verify both writes are present + let entries: Vec<_> = std::fs::read_dir(&log_dir) + .unwrap() + .filter_map(|e| e.ok()) + .collect(); + let log_path = entries[0].path(); + let content = std::fs::read_to_string(log_path).unwrap(); + + assert!(content.contains("write 1"), "Missing first write"); + assert!(content.contains("write 2"), "Missing second write"); +} + +#[test] +fn test_log_directory_with_nested_path() { + let temp = TempDir::new().unwrap(); + let log_dir = temp.path().join("deep").join("nested").join("logs"); + + // Should be able to create nested directories + std::fs::create_dir_all(&log_dir).unwrap(); + assert!(log_dir.exists()); + + // And write logs there + let mut appender = RollingFileAppender::new(Rotation::DAILY, &log_dir, "bridge.log"); + writeln!(appender, "nested test").unwrap(); + drop(appender); + + let entries: Vec<_> = std::fs::read_dir(&log_dir) + .unwrap() + .filter_map(|e| e.ok()) + .collect(); + assert_eq!(entries.len(), 1); +} diff --git a/crates/bridge/tests/test_harness.rs b/crates/bridge/tests/test_harness.rs new file mode 100644 index 000000000..5563df3a0 --- /dev/null +++ b/crates/bridge/tests/test_harness.rs @@ -0,0 +1,1091 @@ +#![allow(clippy::unwrap_used)] +//! Test harness for 3-of-5 degraded operation testing. +//! +//! This module provides infrastructure to simulate a 5-node bridge cluster +//! with mocked Ethereum and Nockchain backends, enabling comprehensive +//! failover and degraded operation tests. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use alloy::primitives::Address; +use bridge::proposal_cache::{ProposalCache, ProposalStatus, SignatureAddResult}; +use bridge::types::{DepositId, NockDepositRequestData}; +use nockchain_math::belt::Belt; +use nockchain_types::tx_engine::common::Hash as Tip5Hash; +use nockchain_types::v1::Name; +use nockchain_types::EthAddress; + +/// Simulated Ethereum transaction hash. +pub type TxHash = [u8; 32]; + +/// Represents a submission to the Base L2 contract. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Submission { + pub deposit_id: DepositId, + pub submitter_node_id: usize, + pub signature_count: usize, + pub tx_hash: TxHash, +} + +/// Mock Base contract that tracks submissions. +#[derive(Debug, Clone, Default)] +pub struct MockBaseContract { + /// All successful submissions + submissions: Arc>>, + /// Track which deposits have been processed to prevent duplicates + processed: Arc>>, + /// Last deposit nonce (mirrors MessageInbox.lastDepositNonce) + last_deposit_nonce: Arc>, +} + +#[allow(dead_code)] +impl MockBaseContract { + /// Create a new mock Base contract. + pub fn new() -> Self { + Self::default() + } + + /// Submit a deposit to the mock contract. + /// + /// Returns Ok(tx_hash) if submission succeeds, Err if duplicate or nonce invalid. + /// Mirrors the real MessageInbox contract behavior: + /// - require(depositNonce > lastDepositNonce) + /// - require(!processedDeposits[txIdHash]) + pub fn submit_deposit( + &self, + deposit_id: &DepositId, + submitter_node_id: usize, + signatures: &[Vec], + ) -> Result { + let mut processed = self.processed.lock().unwrap(); + + // Check for duplicate + if let Some(existing_hash) = processed.get(deposit_id) { + return Err(format!( + "Deposit already processed with tx hash {:?}", + hex::encode(existing_hash) + )); + } + + // Generate a unique tx hash (based on deposit_id for determinism) + let deposit_bytes = deposit_id.to_bytes(); + let tx_hash = blake3::hash(&deposit_bytes).into(); + + // Record the submission + let submission = Submission { + deposit_id: deposit_id.clone(), + submitter_node_id, + signature_count: signatures.len(), + tx_hash, + }; + + self.submissions.lock().unwrap().push(submission); + processed.insert(deposit_id.clone(), tx_hash); + + Ok(tx_hash) + } + + /// Submit a deposit with nonce validation (mirrors real contract behavior). + /// + /// Returns Ok(tx_hash) if submission succeeds, Err if: + /// - Deposit already processed + /// - Nonce is not strictly greater than lastDepositNonce + pub fn submit_deposit_with_nonce( + &self, + deposit_id: &DepositId, + submitter_node_id: usize, + signatures: &[Vec], + nonce: u64, + ) -> Result { + let mut processed = self.processed.lock().unwrap(); + let mut last_nonce = self.last_deposit_nonce.lock().unwrap(); + + // Check for duplicate (same as real contract) + if let Some(existing_hash) = processed.get(deposit_id) { + return Err(format!( + "Deposit already processed with tx hash {:?}", + hex::encode(existing_hash) + )); + } + + // Check nonce is strictly greater (same as real contract) + if nonce <= *last_nonce { + return Err(format!( + "Nonce must be strictly greater: got {}, last was {}", + nonce, *last_nonce + )); + } + + // Generate a unique tx hash + let deposit_bytes = deposit_id.to_bytes(); + let tx_hash = blake3::hash(&deposit_bytes).into(); + + // Record the submission + let submission = Submission { + deposit_id: deposit_id.clone(), + submitter_node_id, + signature_count: signatures.len(), + tx_hash, + }; + + self.submissions.lock().unwrap().push(submission); + processed.insert(deposit_id.clone(), tx_hash); + *last_nonce = nonce; + + Ok(tx_hash) + } + + /// Get the last deposit nonce (mirrors MessageInbox.lastDepositNonce). + pub fn get_last_deposit_nonce(&self) -> u64 { + *self.last_deposit_nonce.lock().unwrap() + } + + /// Set the last deposit nonce (for test setup). + pub fn set_last_deposit_nonce(&self, nonce: u64) { + *self.last_deposit_nonce.lock().unwrap() = nonce; + } + + /// Get all submissions. + pub fn all_submissions(&self) -> Vec { + self.submissions.lock().unwrap().clone() + } + + /// Check if a deposit has been processed. + pub fn is_processed(&self, deposit_id: &DepositId) -> bool { + self.processed.lock().unwrap().contains_key(deposit_id) + } + + /// Get the transaction hash for a processed deposit. + pub fn get_tx_hash(&self, deposit_id: &DepositId) -> Option { + self.processed.lock().unwrap().get(deposit_id).copied() + } +} + +/// Status of a node in the test cluster. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NodeStatus { + Online, + Offline, +} + +/// A single node in the test cluster. +pub struct TestNode { + pub node_id: usize, + pub status: Arc>, + pub proposal_cache: ProposalCache, + /// Simulated signature store (deposit_id -> signature) + pub signatures: Arc>>>, +} + +#[allow(dead_code)] +impl TestNode { + /// Create a new test node. + pub fn new(node_id: usize) -> Self { + Self { + node_id, + status: Arc::new(Mutex::new(NodeStatus::Online)), + proposal_cache: ProposalCache::new(), + signatures: Arc::new(Mutex::new(HashMap::new())), + } + } + + /// Check if this node is online. + pub fn is_online(&self) -> bool { + *self.status.lock().unwrap() == NodeStatus::Online + } + + /// Sign a deposit and store the signature. + /// + /// Returns the signature if the node is online. + pub fn sign_deposit( + &self, + deposit_id: &DepositId, + _proposal: &NockDepositRequestData, + ) -> Option> { + if !self.is_online() { + return None; + } + + // Generate a deterministic signature based on node_id and deposit + let mut sig_data = Vec::new(); + sig_data.extend_from_slice(&(self.node_id as u64).to_le_bytes()); + sig_data.extend_from_slice(&deposit_id.to_bytes()); + + // Use blake3 to generate a 65-byte signature (mock ECDSA format) + let hash = blake3::hash(&sig_data); + let mut signature = vec![0u8; 65]; + signature[..32].copy_from_slice(&hash.as_bytes()[..]); + signature[32..64].copy_from_slice(&hash.as_bytes()[..]); // Reuse for s + signature[64] = 27 + (self.node_id % 2) as u8; // v = 27 or 28 + + self.signatures + .lock() + .unwrap() + .insert(deposit_id.clone(), signature.clone()); + + Some(signature) + } + + /// Get the signature for a deposit if it exists. + pub fn get_signature(&self, deposit_id: &DepositId) -> Option> { + self.signatures.lock().unwrap().get(deposit_id).cloned() + } + + /// Add a signature to this node's proposal cache. + pub fn add_signature( + &self, + deposit_id: &DepositId, + signer_address: Address, + signature: Vec, + proposal_hash: [u8; 32], + proposal: Option, + is_mine: bool, + ) -> Result { + if !self.is_online() { + return Err("Node offline".to_string()); + } + + // Simple verification: just accept all signatures (mock) + let verify_fn = |_hash: &[u8; 32], _sig: &[u8]| Some(signer_address); + + self.proposal_cache.add_signature( + deposit_id, + bridge::proposal_cache::SignatureData { + signer_address, + signature, + proposal_hash, + is_mine, + }, + proposal, + verify_fn, + ) + } + + /// Check if this node's cache shows threshold reached for a deposit. + pub fn has_threshold(&self, deposit_id: &DepositId) -> bool { + self.proposal_cache + .get_state(deposit_id) + .ok() + .flatten() + .map(|state| state.has_threshold()) + .unwrap_or(false) + } + + /// Get the status of a proposal in this node's cache. + pub fn get_proposal_status(&self, deposit_id: &DepositId) -> Option { + self.proposal_cache + .get_state(deposit_id) + .ok() + .flatten() + .map(|state| state.status) + } +} + +/// A test cluster of 5 bridge nodes with mock backends. +pub struct TestCluster { + pub nodes: Vec, + pub mock_base: MockBaseContract, + /// Hoon-computed proposer for deposits (by height % num_nodes) + pub proposer_rotation: HashMap, +} + +#[allow(dead_code)] +impl TestCluster { + /// Create a new test cluster with n nodes. + /// + /// # Arguments + /// * `n` - Number of nodes (typically 5) + pub async fn new(n: usize) -> Self { + let nodes = (0..n).map(TestNode::new).collect(); + + Self { + nodes, + mock_base: MockBaseContract::new(), + proposer_rotation: HashMap::new(), + } + } + + /// Trigger a deposit event across all online nodes. + /// + /// Simulates the Hoon bridge seeing a deposit and emitting signature requests. + /// Each online node signs and broadcasts to peers. + pub async fn trigger_deposit(&mut self, deposit: NockDepositRequestData) { + let deposit_id = DepositId::from_effect_payload(&deposit); + let proposal_hash = deposit.compute_proposal_hash(); + + // Determine proposer (simple height-based rotation) + let proposer_id = (deposit.block_height as usize) % self.nodes.len(); + self.proposer_rotation + .insert(deposit_id.clone(), proposer_id); + + // All online nodes sign + for node in &self.nodes { + if let Some(signature) = node.sign_deposit(&deposit_id, &deposit) { + // Node adds its own signature + let signer_addr = self.node_address(node.node_id); + let _ = node.add_signature( + &deposit_id, + signer_addr, + signature.clone(), + proposal_hash, + Some(deposit.clone()), + true, + ); + + // Broadcast to all OTHER online nodes + for other_node in &self.nodes { + if other_node.node_id != node.node_id && other_node.is_online() { + let _ = other_node.add_signature( + &deposit_id, + signer_addr, + signature.clone(), + proposal_hash, + Some(deposit.clone()), + false, + ); + } + } + } + } + } + + /// Kill a node (set it offline). + pub async fn kill_node(&mut self, index: usize) { + if index < self.nodes.len() { + *self.nodes[index].status.lock().unwrap() = NodeStatus::Offline; + } + } + + /// Restart a node (set it back online). + pub async fn restart_node(&mut self, index: usize) { + if index < self.nodes.len() { + *self.nodes[index].status.lock().unwrap() = NodeStatus::Online; + } + } + + /// Wait for at least `count` nodes to collect enough signatures for a deposit. + /// + /// Returns when threshold is reached or timeout (5s). + pub async fn wait_for_signatures(&self, deposit: &NockDepositRequestData, count: usize) { + let deposit_id = DepositId::from_effect_payload(deposit); + let timeout = Duration::from_secs(5); + let start = std::time::Instant::now(); + + loop { + let ready_count = self + .nodes + .iter() + .filter(|n| n.is_online() && n.has_threshold(&deposit_id)) + .count(); + + if ready_count >= count { + return; + } + + if start.elapsed() > timeout { + panic!( + "Timeout waiting for {} nodes to reach signature threshold", + count + ); + } + + tokio::time::sleep(Duration::from_millis(50)).await; + } + } + + /// Wait for a deposit to be confirmed on-chain. + /// + /// Returns the transaction hash if confirmed within timeout (10s). + pub async fn wait_for_confirmation(&self, deposit: &NockDepositRequestData) -> Option { + let deposit_id = DepositId::from_effect_payload(deposit); + let timeout = Duration::from_secs(10); + let start = std::time::Instant::now(); + + loop { + if let Some(tx_hash) = self.mock_base.get_tx_hash(&deposit_id) { + return Some(tx_hash); + } + + if start.elapsed() > timeout { + return None; + } + + tokio::time::sleep(Duration::from_millis(100)).await; + } + } + + /// Attempt to post a deposit to Base from a specific node. + /// + /// Returns Ok(tx_hash) if successful, Err if failed. + pub fn post_deposit_from_node( + &self, + deposit: &NockDepositRequestData, + node_id: usize, + ) -> Result { + let deposit_id = DepositId::from_effect_payload(deposit); + let node = &self.nodes[node_id]; + + if !node.is_online() { + return Err("Node offline".to_string()); + } + + // Get signatures from cache + let state = node + .proposal_cache + .get_state(&deposit_id) + .map_err(|e| format!("Cache error: {}", e))? + .ok_or("Proposal not found in cache")?; + + if !state.has_threshold() { + return Err("Threshold not reached".to_string()); + } + + let signatures = state.all_signatures(); + + self.mock_base + .submit_deposit(&deposit_id, node_id, &signatures) + } + + /// Get the initial proposer node ID for a deposit. + pub fn initial_proposer(&self, deposit: &NockDepositRequestData) -> usize { + let deposit_id = DepositId::from_effect_payload(deposit); + self.proposer_rotation + .get(&deposit_id) + .copied() + .unwrap_or_else(|| (deposit.block_height as usize) % self.nodes.len()) + } + + /// Get all submissions that have been posted to Base. + pub fn base_submissions(&self) -> Vec { + self.mock_base.all_submissions() + } + + /// Get the mock Ethereum address for a node. + fn node_address(&self, node_id: usize) -> Address { + // Generate deterministic addresses for testing + let mut bytes = [0u8; 20]; + bytes[0] = node_id as u8; + Address::from(bytes) + } +} + +/// Helper to create a sample deposit for testing. +pub fn sample_deposit(block_height: u64, amount: u64) -> NockDepositRequestData { + NockDepositRequestData { + tx_id: Tip5Hash([Belt(1 + block_height), Belt(2), Belt(3), Belt(4), Belt(5)]), + name: Name::new( + Tip5Hash([Belt(10), Belt(20), Belt(30), Belt(40), Belt(50)]), + Tip5Hash([Belt(100), Belt(200), Belt(300), Belt(400), Belt(500)]), + ), + recipient: EthAddress([0xaa; 20]), + amount, + block_height, + as_of: Tip5Hash([Belt(7), Belt(8), Belt(9), Belt(10), Belt(11)]), + nonce: 1, + } +} + +/// Helper to create a sample deposit with a specific nonce. +pub fn sample_deposit_with_nonce( + block_height: u64, + amount: u64, + nonce: u64, +) -> NockDepositRequestData { + NockDepositRequestData { + tx_id: Tip5Hash([Belt(1 + block_height + nonce), Belt(2), Belt(3), Belt(4), Belt(5)]), + name: Name::new( + Tip5Hash([Belt(10), Belt(20), Belt(30), Belt(40), Belt(50)]), + Tip5Hash([Belt(100), Belt(200), Belt(300), Belt(400), Belt(500)]), + ), + recipient: EthAddress([0xaa; 20]), + amount, + block_height, + as_of: Tip5Hash([Belt(7 + nonce), Belt(8), Belt(9), Belt(10), Belt(11)]), + nonce, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_cluster_creation() { + let cluster = TestCluster::new(5).await; + assert_eq!(cluster.nodes.len(), 5); + assert!(cluster.nodes.iter().all(|n| n.is_online())); + } + + #[tokio::test] + async fn test_node_lifecycle() { + let mut cluster = TestCluster::new(5).await; + + assert!(cluster.nodes[2].is_online()); + + cluster.kill_node(2).await; + assert!(!cluster.nodes[2].is_online()); + + cluster.restart_node(2).await; + assert!(cluster.nodes[2].is_online()); + } + + #[tokio::test] + async fn test_deposit_signing() { + let cluster = TestCluster::new(5).await; + let deposit = sample_deposit(100, 1000); + let deposit_id = DepositId::from_effect_payload(&deposit); + + let signature = cluster.nodes[0].sign_deposit(&deposit_id, &deposit); + assert!(signature.is_some()); + assert_eq!(signature.unwrap().len(), 65); + } + + #[tokio::test] + async fn test_offline_node_cannot_sign() { + let mut cluster = TestCluster::new(5).await; + let deposit = sample_deposit(100, 1000); + let deposit_id = DepositId::from_effect_payload(&deposit); + + cluster.kill_node(0).await; + let signature = cluster.nodes[0].sign_deposit(&deposit_id, &deposit); + assert!(signature.is_none()); + } + + #[tokio::test] + async fn test_mock_base_submission() { + let cluster = TestCluster::new(5).await; + let deposit = sample_deposit(100, 1000); + let deposit_id = DepositId::from_effect_payload(&deposit); + + let signatures = vec![vec![1u8; 65], vec![2u8; 65], vec![3u8; 65]]; + + let result = cluster + .mock_base + .submit_deposit(&deposit_id, 0, &signatures); + assert!(result.is_ok()); + + let submissions = cluster.mock_base.all_submissions(); + assert_eq!(submissions.len(), 1); + assert_eq!(submissions[0].submitter_node_id, 0); + assert_eq!(submissions[0].signature_count, 3); + } + + #[tokio::test] + async fn test_duplicate_submission_rejected() { + let cluster = TestCluster::new(5).await; + let deposit = sample_deposit(100, 1000); + let deposit_id = DepositId::from_effect_payload(&deposit); + + let signatures = vec![vec![1u8; 65], vec![2u8; 65], vec![3u8; 65]]; + + let result1 = cluster + .mock_base + .submit_deposit(&deposit_id, 0, &signatures); + assert!(result1.is_ok()); + + let result2 = cluster + .mock_base + .submit_deposit(&deposit_id, 1, &signatures); + assert!(result2.is_err()); + } + + // ========================================================================= + // Nonce ordering tests - verify chain-based nonce logic + // ========================================================================= + + #[tokio::test] + async fn test_nonce_must_be_strictly_greater() { + let contract = MockBaseContract::new(); + let signatures = vec![vec![1u8; 65], vec![2u8; 65], vec![3u8; 65]]; + + // Initial state: lastDepositNonce = 0 + assert_eq!(contract.get_last_deposit_nonce(), 0); + + // Submit nonce 1 - should succeed + let deposit1 = sample_deposit_with_nonce(100, 1000, 1); + let deposit_id1 = DepositId::from_effect_payload(&deposit1); + let result = contract.submit_deposit_with_nonce(&deposit_id1, 0, &signatures, 1); + assert!(result.is_ok(), "Nonce 1 should succeed when last is 0"); + assert_eq!(contract.get_last_deposit_nonce(), 1); + + // Submit nonce 1 again - should fail (not strictly greater) + let deposit1b = sample_deposit_with_nonce(101, 1000, 1); + let deposit_id1b = DepositId::from_effect_payload(&deposit1b); + let result = contract.submit_deposit_with_nonce(&deposit_id1b, 0, &signatures, 1); + assert!(result.is_err(), "Nonce 1 should fail when last is 1"); + + // Submit nonce 2 - should succeed + let deposit2 = sample_deposit_with_nonce(102, 1000, 2); + let deposit_id2 = DepositId::from_effect_payload(&deposit2); + let result = contract.submit_deposit_with_nonce(&deposit_id2, 0, &signatures, 2); + assert!(result.is_ok(), "Nonce 2 should succeed when last is 1"); + assert_eq!(contract.get_last_deposit_nonce(), 2); + } + + #[tokio::test] + async fn test_nonce_can_skip_values() { + // The contract only requires nonce > lastDepositNonce, not nonce == lastDepositNonce + 1 + // This means if nonce 2 is skipped, nonce 3 can still be submitted + let contract = MockBaseContract::new(); + let signatures = vec![vec![1u8; 65], vec![2u8; 65], vec![3u8; 65]]; + + // Submit nonce 1 + let deposit1 = sample_deposit_with_nonce(100, 1000, 1); + let deposit_id1 = DepositId::from_effect_payload(&deposit1); + contract + .submit_deposit_with_nonce(&deposit_id1, 0, &signatures, 1) + .unwrap(); + assert_eq!(contract.get_last_deposit_nonce(), 1); + + // Skip nonce 2, submit nonce 3 directly + let deposit3 = sample_deposit_with_nonce(102, 1000, 3); + let deposit_id3 = DepositId::from_effect_payload(&deposit3); + let result = contract.submit_deposit_with_nonce(&deposit_id3, 0, &signatures, 3); + assert!( + result.is_ok(), + "Nonce 3 should succeed when last is 1 (skipping 2)" + ); + assert_eq!(contract.get_last_deposit_nonce(), 3); + + // Now nonce 2 cannot be submitted (2 is not > 3) + let deposit2 = sample_deposit_with_nonce(101, 1000, 2); + let deposit_id2 = DepositId::from_effect_payload(&deposit2); + let result = contract.submit_deposit_with_nonce(&deposit_id2, 0, &signatures, 2); + assert!(result.is_err(), "Nonce 2 should fail when last is 3"); + } + + #[tokio::test] + async fn test_ready_proposals_sorted_by_nonce() { + // Verify that ready_proposals returns proposals sorted by nonce + let cache = ProposalCache::new(); + + // Add proposals with nonces 5, 3, 7, 1 (out of order) + let nonces = [5u64, 3, 7, 1]; + for &nonce in &nonces { + let proposal = sample_deposit_with_nonce(100, 1000, nonce); + let proposal_hash = proposal.compute_proposal_hash(); + let deposit_id = DepositId::from_effect_payload(&proposal); + + // Add enough signatures to reach threshold (3 signatures) + for i in 0..3 { + let signer = Address::from([i; 20]); + let is_mine = i == 0; + cache + .add_signature( + &deposit_id, + bridge::proposal_cache::SignatureData { + signer_address: signer, + signature: vec![i; 65], + proposal_hash, + is_mine, + }, + if is_mine { + Some(proposal.clone()) + } else { + None + }, + |_, _| Some(signer), + ) + .unwrap(); + } + } + + // Get ready proposals - should be sorted by nonce + let ready = cache.ready_proposals().unwrap(); + assert_eq!(ready.len(), 4); + + let ready_nonces: Vec = ready + .iter() + .map(|(_, state)| state.proposal.nonce) + .collect(); + assert_eq!( + ready_nonces, + vec![1, 3, 5, 7], + "Proposals should be sorted by nonce" + ); + } + + #[tokio::test] + async fn test_chain_nonce_determines_next_submission() { + // Simulate the posting loop logic: + // - Query lastDepositNonce from chain + // - Only submit proposal where nonce == lastDepositNonce + 1 + // - Skip proposals with nonce <= lastDepositNonce (already on chain) + // - Wait for proposals with nonce > lastDepositNonce + 1 + + let contract = MockBaseContract::new(); + let cache = ProposalCache::new(); + let signatures = vec![vec![1u8; 65], vec![2u8; 65], vec![3u8; 65]]; + + // Create proposals with nonces 1, 2, 3 + let mut proposals = Vec::new(); + for nonce in 1..=3 { + let proposal = sample_deposit_with_nonce(100 + nonce, 1000, nonce); + let proposal_hash = proposal.compute_proposal_hash(); + let deposit_id = DepositId::from_effect_payload(&proposal); + + // Add to cache with threshold signatures + for i in 0..3 { + let signer = Address::from([i; 20]); + cache + .add_signature( + &deposit_id, + bridge::proposal_cache::SignatureData { + signer_address: signer, + signature: vec![i; 65], + proposal_hash, + is_mine: i == 0, + }, + if i == 0 { Some(proposal.clone()) } else { None }, + |_, _| Some(signer), + ) + .unwrap(); + } + proposals.push((deposit_id, proposal)); + } + + // Simulate posting loop iteration 1: + // lastDepositNonce = 0, so next_nonce = 1 + let last_chain_nonce = contract.get_last_deposit_nonce(); + assert_eq!(last_chain_nonce, 0); + let next_nonce = last_chain_nonce + 1; + + let ready = cache.ready_proposals().unwrap(); + for (deposit_id, state) in &ready { + if state.proposal.nonce == next_nonce { + // Submit this one + contract + .submit_deposit_with_nonce(deposit_id, 0, &signatures, state.proposal.nonce) + .unwrap(); + cache.mark_confirmed(deposit_id).unwrap(); + break; + } + } + assert_eq!(contract.get_last_deposit_nonce(), 1); + + // Simulate posting loop iteration 2: + // lastDepositNonce = 1, so next_nonce = 2 + let last_chain_nonce = contract.get_last_deposit_nonce(); + let next_nonce = last_chain_nonce + 1; + + let ready = cache.ready_proposals().unwrap(); + for (deposit_id, state) in &ready { + if state.proposal.nonce == next_nonce { + contract + .submit_deposit_with_nonce(deposit_id, 0, &signatures, state.proposal.nonce) + .unwrap(); + cache.mark_confirmed(deposit_id).unwrap(); + break; + } + } + assert_eq!(contract.get_last_deposit_nonce(), 2); + + // Verify all submissions are in order + let submissions = contract.all_submissions(); + assert_eq!(submissions.len(), 2); + } + + #[tokio::test] + async fn test_false_failure_healed_by_chain_query() { + // Scenario: Node thinks nonce 1 failed, but it actually succeeded on chain. + // When we query the chain, we see lastDepositNonce = 1, so we mark it confirmed + // and move on to nonce 2. + + let contract = MockBaseContract::new(); + let cache = ProposalCache::new(); + let signatures = vec![vec![1u8; 65], vec![2u8; 65], vec![3u8; 65]]; + + // Create proposals with nonces 1 and 2 + let proposal1 = sample_deposit_with_nonce(100, 1000, 1); + let proposal2 = sample_deposit_with_nonce(101, 1000, 2); + let deposit_id1 = DepositId::from_effect_payload(&proposal1); + let deposit_id2 = DepositId::from_effect_payload(&proposal2); + + // Add both to cache with threshold + for (deposit_id, proposal) in [(&deposit_id1, &proposal1), (&deposit_id2, &proposal2)] { + let proposal_hash = proposal.compute_proposal_hash(); + for i in 0..3 { + let signer = Address::from([i; 20]); + cache + .add_signature( + deposit_id, + bridge::proposal_cache::SignatureData { + signer_address: signer, + signature: vec![i; 65], + proposal_hash, + is_mine: i == 0, + }, + if i == 0 { Some(proposal.clone()) } else { None }, + |_, _| Some(signer), + ) + .unwrap(); + } + } + + // Simulate: nonce 1 was submitted and succeeded on chain, but node marked it Failed + contract + .submit_deposit_with_nonce(&deposit_id1, 0, &signatures, 1) + .unwrap(); + cache.mark_failed(&deposit_id1).unwrap(); + + // Verify local state shows Failed + let state1 = cache.get_state(&deposit_id1).unwrap().unwrap(); + assert_eq!(state1.status, ProposalStatus::Failed); + + // Now simulate the fixed posting loop: + // Query chain - it shows lastDepositNonce = 1 + let last_chain_nonce = contract.get_last_deposit_nonce(); + assert_eq!(last_chain_nonce, 1); + let next_nonce = last_chain_nonce + 1; // = 2 + + // Process ready proposals + let ready = cache.ready_proposals().unwrap(); + for (deposit_id, state) in &ready { + if state.proposal.nonce < next_nonce { + // Already on chain - heal by marking confirmed + cache.mark_confirmed(deposit_id).unwrap(); + } else if state.proposal.nonce == next_nonce { + // This is the one to submit + contract + .submit_deposit_with_nonce(deposit_id, 0, &signatures, state.proposal.nonce) + .unwrap(); + cache.mark_confirmed(deposit_id).unwrap(); + } + // else: nonce > next_nonce, skip for now + } + + // Verify: nonce 2 was submitted + assert_eq!(contract.get_last_deposit_nonce(), 2); + + // Note: nonce 1 was already Failed, so it won't appear in ready_proposals. + // In the real code, we'd need to also check non-ready proposals for healing. + // But the key point is: we didn't deadlock waiting for nonce 1. + } + + #[tokio::test] + async fn test_out_of_order_nonces_wait_correctly() { + // Scenario: Nonces 1, 2, 3 are ready, but we must submit in order. + // If nonce 1 is not ready, we wait (don't skip to 2). + + let contract = MockBaseContract::new(); + let cache = ProposalCache::new(); + + // Create proposals with nonces 2 and 3 (nonce 1 is missing/not ready) + for nonce in [2u64, 3] { + let proposal = sample_deposit_with_nonce(100 + nonce, 1000, nonce); + let proposal_hash = proposal.compute_proposal_hash(); + let deposit_id = DepositId::from_effect_payload(&proposal); + + for i in 0..3 { + let signer = Address::from([i; 20]); + cache + .add_signature( + &deposit_id, + bridge::proposal_cache::SignatureData { + signer_address: signer, + signature: vec![i; 65], + proposal_hash, + is_mine: i == 0, + }, + if i == 0 { Some(proposal.clone()) } else { None }, + |_, _| Some(signer), + ) + .unwrap(); + } + } + + // Chain shows lastDepositNonce = 0, so next_nonce = 1 + let last_chain_nonce = contract.get_last_deposit_nonce(); + assert_eq!(last_chain_nonce, 0); + let next_nonce = last_chain_nonce + 1; // = 1 + + // Try to find a proposal with nonce == 1 + let ready = cache.ready_proposals().unwrap(); + let can_submit = ready + .iter() + .any(|(_, state)| state.proposal.nonce == next_nonce); + + // We should NOT be able to submit because nonce 1 is not ready + assert!(!can_submit, "Should not find nonce 1 in ready proposals"); + + // Verify we didn't submit anything + assert_eq!(contract.get_last_deposit_nonce(), 0); + assert!(contract.all_submissions().is_empty()); + } + + #[tokio::test] + async fn test_timed_out_failed_proposal_can_be_skipped() { + // Scenario: Nonce 1 truly failed and has been stuck for a while. + // After timeout, the posting loop should be able to skip it (by incrementing + // next_nonce) and submit nonce 2. + + let cache = ProposalCache::new(); + + // Create proposal with nonce 1 + let proposal1 = sample_deposit_with_nonce(100, 1000, 1); + let proposal_hash1 = proposal1.compute_proposal_hash(); + let deposit_id1 = DepositId::from_effect_payload(&proposal1); + + // Add to cache with threshold + for i in 0..3 { + let signer = Address::from([i; 20]); + cache + .add_signature( + &deposit_id1, + bridge::proposal_cache::SignatureData { + signer_address: signer, + signature: vec![i; 65], + proposal_hash: proposal_hash1, + is_mine: i == 0, + }, + if i == 0 { + Some(proposal1.clone()) + } else { + None + }, + |_, _| Some(signer), + ) + .unwrap(); + } + + // Mark as failed + cache.mark_failed(&deposit_id1).unwrap(); + + // Verify it's failed + let state = cache.get_state(&deposit_id1).unwrap().unwrap(); + assert_eq!(state.status, ProposalStatus::Failed); + assert!(state.failed_at.is_some()); + + // With 0 timeout, it should immediately be considered timed out + let timed_out = cache.lowest_timed_out_failed_nonce(0).unwrap(); + assert_eq!( + timed_out, + Some(1), + "Nonce 1 should be timed out with 0s timeout" + ); + + // With very long timeout, it should not be timed out yet + let timed_out = cache.lowest_timed_out_failed_nonce(999999).unwrap(); + assert_eq!( + timed_out, None, + "Nonce 1 should not be timed out with long timeout" + ); + + // The cache does not record "skipped" state; skipping is implemented in the posting loop + // by advancing `next_nonce` when the current next nonce is timed out. + let state = cache.get_state(&deposit_id1).unwrap().unwrap(); + assert_eq!(state.status, ProposalStatus::Failed); + assert_eq!( + cache.lowest_timed_out_failed_nonce(0).unwrap(), + Some(1), + "timed-out failed nonce should be detectable for skipping" + ); + } + + #[tokio::test] + async fn test_get_proposal_by_nonce() { + let cache = ProposalCache::new(); + + // Create proposals with nonces 5 and 10 + for nonce in [5u64, 10] { + let proposal = sample_deposit_with_nonce(100 + nonce, 1000, nonce); + let proposal_hash = proposal.compute_proposal_hash(); + let deposit_id = DepositId::from_effect_payload(&proposal); + + cache + .add_signature( + &deposit_id, + bridge::proposal_cache::SignatureData { + signer_address: Address::ZERO, + signature: vec![1; 65], + proposal_hash, + is_mine: true, + }, + Some(proposal), + |_, _| Some(Address::ZERO), + ) + .unwrap(); + } + + // Find nonce 5 + let result = cache.get_proposal_by_nonce(5).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap().1.proposal.nonce, 5); + + // Find nonce 10 + let result = cache.get_proposal_by_nonce(10).unwrap(); + assert!(result.is_some()); + assert_eq!(result.unwrap().1.proposal.nonce, 10); + + // Nonce 7 doesn't exist + let result = cache.get_proposal_by_nonce(7).unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_full_skip_flow_unblocks_subsequent_nonces() { + // Full integration test: nonce 1 fails, times out, gets skipped, + // then nonce 2 can be submitted. + + let contract = MockBaseContract::new(); + let cache = ProposalCache::new(); + let signatures = vec![vec![1u8; 65], vec![2u8; 65], vec![3u8; 65]]; + + // Create proposals with nonces 1 and 2 + for nonce in [1u64, 2] { + let proposal = sample_deposit_with_nonce(100 + nonce, 1000, nonce); + let proposal_hash = proposal.compute_proposal_hash(); + let deposit_id = DepositId::from_effect_payload(&proposal); + + for i in 0..3 { + let signer = Address::from([i; 20]); + cache + .add_signature( + &deposit_id, + bridge::proposal_cache::SignatureData { + signer_address: signer, + signature: vec![i; 65], + proposal_hash, + is_mine: i == 0, + }, + if i == 0 { Some(proposal.clone()) } else { None }, + |_, _| Some(signer), + ) + .unwrap(); + } + } + + let deposit_id1 = DepositId::from_effect_payload(&sample_deposit_with_nonce(101, 1000, 1)); + let deposit_id2 = DepositId::from_effect_payload(&sample_deposit_with_nonce(102, 1000, 2)); + + // Nonce 1 fails + cache.mark_failed(&deposit_id1).unwrap(); + + // Chain shows lastDepositNonce = 0, so next_nonce = 1 + let last_chain_nonce = contract.get_last_deposit_nonce(); + assert_eq!(last_chain_nonce, 0); + let _next_nonce = last_chain_nonce + 1; + + // Skipping does not mutate cache state; it is implemented by advancing next_nonce. + // The contract permits nonce gaps (`depositNonce > lastDepositNonce`), so nonce 2 can be submitted. + // But wait - the chain still shows lastDepositNonce = 0 + // We need to submit nonce 1 first... but we skipped it! + // + // Actually, this reveals a problem: skipping locally doesn't help + // because the contract still requires nonce > lastDepositNonce. + // If we skip nonce 1, we can't submit nonce 2 because 2 > 0 is true, + // but the contract will accept it! + + // Let's verify: submit nonce 2 directly + let result = contract.submit_deposit_with_nonce(&deposit_id2, 0, &signatures, 2); + assert!( + result.is_ok(), + "Nonce 2 should succeed when last is 0 (skipping 1)" + ); + assert_eq!(contract.get_last_deposit_nonce(), 2); + + // Nonce 1 is now permanently lost (can't submit 1 when last is 2) + } +} diff --git a/crates/kernels/Cargo.toml b/crates/kernels/Cargo.toml deleted file mode 100644 index 272fe3398..000000000 --- a/crates/kernels/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "kernels" -version.workspace = true -edition.workspace = true -license = "MIT OR Apache-2.0" - -[features] -default = [] -bazel_build = [] - -# Specific kernels -bridge = [] -dumb = [] -wallet = [] -miner = [] -nockchain_peek = [] - -[dependencies] diff --git a/crates/kernels/bridge/Cargo.toml b/crates/kernels/bridge/Cargo.toml new file mode 100644 index 000000000..93e675c73 --- /dev/null +++ b/crates/kernels/bridge/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "kernels-open-bridge" +version.workspace = true +edition.workspace = true +license = "MIT OR Apache-2.0" + +[dependencies] diff --git a/crates/kernels/bridge/build.rs b/crates/kernels/bridge/build.rs new file mode 100644 index 000000000..8a27db468 --- /dev/null +++ b/crates/kernels/bridge/build.rs @@ -0,0 +1,15 @@ +use std::env; +use std::path::PathBuf; + +fn main() { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); + let repo_root = manifest_dir.ancestors().nth(3).expect("repo root"); + let jam_path = repo_root.join("assets/bridge.jam"); + + println!("cargo:rerun-if-env-changed=KERNEL_JAM_PATH"); + println!("cargo:rerun-if-changed={}", jam_path.display()); + + if env::var_os("KERNEL_JAM_PATH").is_none() { + println!("cargo:rustc-env=KERNEL_JAM_PATH={}", jam_path.display()); + } +} diff --git a/crates/kernels/bridge/src/lib.rs b/crates/kernels/bridge/src/lib.rs new file mode 100644 index 000000000..625a4ae13 --- /dev/null +++ b/crates/kernels/bridge/src/lib.rs @@ -0,0 +1 @@ +pub static KERNEL: &[u8] = include_bytes!(env!("KERNEL_JAM_PATH")); diff --git a/crates/kernels/dumb/Cargo.toml b/crates/kernels/dumb/Cargo.toml new file mode 100644 index 000000000..7de6f17c7 --- /dev/null +++ b/crates/kernels/dumb/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "kernels-open-dumb" +version.workspace = true +edition.workspace = true +license = "MIT OR Apache-2.0" + +[dependencies] diff --git a/crates/kernels/dumb/build.rs b/crates/kernels/dumb/build.rs new file mode 100644 index 000000000..fe6b18c56 --- /dev/null +++ b/crates/kernels/dumb/build.rs @@ -0,0 +1,15 @@ +use std::env; +use std::path::PathBuf; + +fn main() { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); + let repo_root = manifest_dir.ancestors().nth(3).expect("repo root"); + let jam_path = repo_root.join("assets/dumb.jam"); + + println!("cargo:rerun-if-env-changed=KERNEL_JAM_PATH"); + println!("cargo:rerun-if-changed={}", jam_path.display()); + + if env::var_os("KERNEL_JAM_PATH").is_none() { + println!("cargo:rustc-env=KERNEL_JAM_PATH={}", jam_path.display()); + } +} diff --git a/crates/kernels/dumb/src/lib.rs b/crates/kernels/dumb/src/lib.rs new file mode 100644 index 000000000..625a4ae13 --- /dev/null +++ b/crates/kernels/dumb/src/lib.rs @@ -0,0 +1 @@ +pub static KERNEL: &[u8] = include_bytes!(env!("KERNEL_JAM_PATH")); diff --git a/crates/kernels/miner/Cargo.toml b/crates/kernels/miner/Cargo.toml new file mode 100644 index 000000000..189553899 --- /dev/null +++ b/crates/kernels/miner/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "kernels-open-miner" +version.workspace = true +edition.workspace = true +license = "MIT OR Apache-2.0" + +[dependencies] diff --git a/crates/kernels/miner/build.rs b/crates/kernels/miner/build.rs new file mode 100644 index 000000000..f428bd499 --- /dev/null +++ b/crates/kernels/miner/build.rs @@ -0,0 +1,15 @@ +use std::env; +use std::path::PathBuf; + +fn main() { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); + let repo_root = manifest_dir.ancestors().nth(3).expect("repo root"); + let jam_path = repo_root.join("assets/miner.jam"); + + println!("cargo:rerun-if-env-changed=KERNEL_JAM_PATH"); + println!("cargo:rerun-if-changed={}", jam_path.display()); + + if env::var_os("KERNEL_JAM_PATH").is_none() { + println!("cargo:rustc-env=KERNEL_JAM_PATH={}", jam_path.display()); + } +} diff --git a/crates/kernels/miner/src/lib.rs b/crates/kernels/miner/src/lib.rs new file mode 100644 index 000000000..625a4ae13 --- /dev/null +++ b/crates/kernels/miner/src/lib.rs @@ -0,0 +1 @@ +pub static KERNEL: &[u8] = include_bytes!(env!("KERNEL_JAM_PATH")); diff --git a/crates/kernels/nockchain-peek/Cargo.toml b/crates/kernels/nockchain-peek/Cargo.toml new file mode 100644 index 000000000..b1fb1d29a --- /dev/null +++ b/crates/kernels/nockchain-peek/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "kernels-open-nockchain-peek" +version.workspace = true +edition.workspace = true +license = "MIT OR Apache-2.0" + +[dependencies] diff --git a/crates/kernels/nockchain-peek/build.rs b/crates/kernels/nockchain-peek/build.rs new file mode 100644 index 000000000..d7e71e5b7 --- /dev/null +++ b/crates/kernels/nockchain-peek/build.rs @@ -0,0 +1,15 @@ +use std::env; +use std::path::PathBuf; + +fn main() { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); + let repo_root = manifest_dir.ancestors().nth(3).expect("repo root"); + let jam_path = repo_root.join("assets/peek.jam"); + + println!("cargo:rerun-if-env-changed=KERNEL_JAM_PATH"); + println!("cargo:rerun-if-changed={}", jam_path.display()); + + if env::var_os("KERNEL_JAM_PATH").is_none() { + println!("cargo:rustc-env=KERNEL_JAM_PATH={}", jam_path.display()); + } +} diff --git a/crates/kernels/nockchain-peek/src/lib.rs b/crates/kernels/nockchain-peek/src/lib.rs new file mode 100644 index 000000000..625a4ae13 --- /dev/null +++ b/crates/kernels/nockchain-peek/src/lib.rs @@ -0,0 +1 @@ +pub static KERNEL: &[u8] = include_bytes!(env!("KERNEL_JAM_PATH")); diff --git a/crates/kernels/src/bridge.rs b/crates/kernels/src/bridge.rs deleted file mode 100644 index d1b96e568..000000000 --- a/crates/kernels/src/bridge.rs +++ /dev/null @@ -1,8 +0,0 @@ -#[cfg(feature = "bazel_build")] -pub static KERNEL: &[u8] = include_bytes!(env!("BRIDGE_JAM_PATH")); - -#[cfg(not(feature = "bazel_build"))] -pub static KERNEL: &[u8] = include_bytes!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../assets/bridge.jam" -)); diff --git a/crates/kernels/src/dumb.rs b/crates/kernels/src/dumb.rs deleted file mode 100644 index 1f1938b54..000000000 --- a/crates/kernels/src/dumb.rs +++ /dev/null @@ -1,8 +0,0 @@ -#[cfg(feature = "bazel_build")] -pub static KERNEL: &[u8] = include_bytes!(env!("DUMB_JAM_PATH")); - -#[cfg(not(feature = "bazel_build"))] -pub const KERNEL: &[u8] = include_bytes!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../assets/dumb.jam" -)); diff --git a/crates/kernels/src/lib.rs b/crates/kernels/src/lib.rs deleted file mode 100644 index 812d86583..000000000 --- a/crates/kernels/src/lib.rs +++ /dev/null @@ -1,14 +0,0 @@ -#[cfg(feature = "bridge")] -pub mod bridge; - -#[cfg(feature = "wallet")] -pub mod wallet; - -#[cfg(feature = "dumb")] -pub mod dumb; - -#[cfg(feature = "miner")] -pub mod miner; - -#[cfg(feature = "nockchain_peek")] -pub mod nockchain_peek; diff --git a/crates/kernels/src/miner.rs b/crates/kernels/src/miner.rs deleted file mode 100644 index 5540f591d..000000000 --- a/crates/kernels/src/miner.rs +++ /dev/null @@ -1,8 +0,0 @@ -#[cfg(feature = "bazel_build")] -pub static KERNEL: &[u8] = include_bytes!(env!("MINER_JAM_PATH")); - -#[cfg(not(feature = "bazel_build"))] -pub const KERNEL: &[u8] = include_bytes!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../assets/miner.jam" -)); diff --git a/crates/kernels/src/nockchain_peek.rs b/crates/kernels/src/nockchain_peek.rs deleted file mode 100644 index 041c0af07..000000000 --- a/crates/kernels/src/nockchain_peek.rs +++ /dev/null @@ -1,8 +0,0 @@ -#[cfg(feature = "bazel_build")] -pub static KERNEL: &[u8] = include_bytes!(env!("NOCKCHAIN_PEEK_JAM_PATH")); - -#[cfg(not(feature = "bazel_build"))] -pub const KERNEL: &[u8] = include_bytes!(concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../assets/peek.jam" -)); diff --git a/crates/kernels/src/wallet.rs b/crates/kernels/src/wallet.rs deleted file mode 100644 index 9ee414117..000000000 --- a/crates/kernels/src/wallet.rs +++ /dev/null @@ -1,6 +0,0 @@ -#[cfg(not(feature = "bazel_build"))] -pub static KERNEL: &[u8] = - include_bytes!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../assets/wal.jam")); - -#[cfg(feature = "bazel_build")] -pub static KERNEL: &[u8] = include_bytes!(env!("WALLET_JAM_PATH")); diff --git a/crates/kernels/wallet/Cargo.toml b/crates/kernels/wallet/Cargo.toml new file mode 100644 index 000000000..2eb396711 --- /dev/null +++ b/crates/kernels/wallet/Cargo.toml @@ -0,0 +1,7 @@ +[package] +name = "kernels-open-wallet" +version.workspace = true +edition.workspace = true +license = "MIT OR Apache-2.0" + +[dependencies] diff --git a/crates/kernels/wallet/build.rs b/crates/kernels/wallet/build.rs new file mode 100644 index 000000000..98cea2fad --- /dev/null +++ b/crates/kernels/wallet/build.rs @@ -0,0 +1,15 @@ +use std::env; +use std::path::PathBuf; + +fn main() { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR")); + let repo_root = manifest_dir.ancestors().nth(3).expect("repo root"); + let jam_path = repo_root.join("assets/wal.jam"); + + println!("cargo:rerun-if-env-changed=KERNEL_JAM_PATH"); + println!("cargo:rerun-if-changed={}", jam_path.display()); + + if env::var_os("KERNEL_JAM_PATH").is_none() { + println!("cargo:rustc-env=KERNEL_JAM_PATH={}", jam_path.display()); + } +} diff --git a/crates/kernels/wallet/src/lib.rs b/crates/kernels/wallet/src/lib.rs new file mode 100644 index 000000000..625a4ae13 --- /dev/null +++ b/crates/kernels/wallet/src/lib.rs @@ -0,0 +1 @@ +pub static KERNEL: &[u8] = include_bytes!(env!("KERNEL_JAM_PATH")); diff --git a/crates/nockapp-grpc-proto/build.rs b/crates/nockapp-grpc-proto/build.rs index 0826cdda6..a76579f21 100644 --- a/crates/nockapp-grpc-proto/build.rs +++ b/crates/nockapp-grpc-proto/build.rs @@ -2,9 +2,19 @@ use std::env; use std::path::PathBuf; +fn ensure_protoc() -> Result<(), Box> { + println!("cargo:rerun-if-env-changed=PROTOC"); + if env::var_os("PROTOC").is_some() { + return Ok(()); + } + Err("PROTOC is not set; Bazel should pass it via //tools/protoc:protoc".into()) +} + fn main() -> Result<(), Box> { // Rerun if any file in the proto directory changes + ensure_protoc()?; + // Get the output directory let out_dir = PathBuf::from(env::var("OUT_DIR")?); @@ -15,9 +25,7 @@ fn main() -> Result<(), Box> { for proto_file in proto_files.clone() { eprintln!("cargo:rerun-if-changed={}", proto_file.display()); - let path_string = proto_file - .to_str() - .expect("Couldn't convert proto_file path to string"); + let path_string = proto_file.to_str().ok_or("proto path is not valid UTF-8")?; println!("cargo:rerun-if-changed={path_string}"); } let include_dirs = ["proto"].map(PathBuf::from); diff --git a/crates/nockapp-grpc/src/services/public_nockchain/v2/server.rs b/crates/nockapp-grpc/src/services/public_nockchain/v2/server.rs index 3cec24589..2cc297213 100644 --- a/crates/nockapp-grpc/src/services/public_nockchain/v2/server.rs +++ b/crates/nockapp-grpc/src/services/public_nockchain/v2/server.rs @@ -568,10 +568,18 @@ impl NockchainService for PublicNockchainGrpcServer { request: Request, ) -> std::result::Result, Status> { let remote_addr = request.remote_addr(); + let x_forwarded_for = request + .metadata() + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); let req = request.into_inner(); let request_start = Instant::now(); let metrics = &self.metrics; - info!("WalletGetBalance client_ip={:?}", remote_addr); + info!( + "WalletGetBalance client_ip={:?} x_forwarded_for={:?}", + remote_addr, x_forwarded_for + ); let WalletGetBalanceRequest { selector, page, .. } = req; if selector.is_none() { @@ -739,8 +747,8 @@ impl NockchainService for PublicNockchainGrpcServer { path_slab.set_root(path_noun); info!( - "peek path=balance-by-pubkey address={} client_ip={:?}", - address.key, remote_addr + "peek path=balance-by-pubkey address={} client_ip={:?} x_forwarded_for={:?}", + address.key, remote_addr, x_forwarded_for ); let peek_start = Instant::now(); let peek_result = self.handle.peek(path_slab).await; @@ -961,8 +969,8 @@ impl NockchainService for PublicNockchainGrpcServer { self.metrics.balance_cache_first_name_miss.increment(); info!( - "peek path=balance-by-first-name first_name={} client_ip={:?}", - first_name_str.hash, remote_addr + "peek path=balance-by-first-name first_name={} client_ip={:?} x_forwarded_for={:?}", + first_name_str.hash, remote_addr, x_forwarded_for ); let path = vec!["balance-by-first-name".to_string(), first_name_str.hash]; let mut path_slab = NounSlab::new(); @@ -1079,12 +1087,17 @@ impl NockchainService for PublicNockchainGrpcServer { request: Request, ) -> std::result::Result, Status> { let remote_addr = request.remote_addr(); + let x_forwarded_for = request + .metadata() + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); let req = request.into_inner(); let request_start = Instant::now(); let metrics = &self.metrics; debug!( - "WalletSendTransaction tx_id={:?} client_ip={:?}", - req.tx_id, remote_addr + "WalletSendTransaction tx_id={:?} client_ip={:?} x_forwarded_for={:?}", + req.tx_id, remote_addr, x_forwarded_for ); let tx_id_pb = match req.tx_id.clone() { Some(id) => id, @@ -1247,12 +1260,17 @@ impl NockchainService for PublicNockchainGrpcServer { request: Request, ) -> std::result::Result, Status> { let remote_addr = request.remote_addr(); + let x_forwarded_for = request + .metadata() + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); let req = request.into_inner(); let request_start = Instant::now(); let metrics = &self.metrics; debug!( - "TransactionAccepted tx_id={:?} client_ip={:?}", - req.tx_id, remote_addr + "TransactionAccepted tx_id={:?} client_ip={:?} x_forwarded_for={:?}", + req.tx_id, remote_addr, x_forwarded_for ); let Some(pb_hash) = req.tx_id else { diff --git a/crates/nockapp/src/nockapp/actors/kernel.rs b/crates/nockapp/src/nockapp/actors/kernel.rs index e69de29bb..8b1378917 100644 --- a/crates/nockapp/src/nockapp/actors/kernel.rs +++ b/crates/nockapp/src/nockapp/actors/kernel.rs @@ -0,0 +1 @@ + diff --git a/crates/nockapp/src/nockapp/actors/save.rs b/crates/nockapp/src/nockapp/actors/save.rs index bb7ef6b15..d21b9f447 100644 --- a/crates/nockapp/src/nockapp/actors/save.rs +++ b/crates/nockapp/src/nockapp/actors/save.rs @@ -1,9 +1,15 @@ -use std::{path::PathBuf, sync::{atomic::{AtomicBool, Ordering}, Arc}, time::Duration}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; -use tokio::{fs, io::AsyncWriteExt as _, sync::mpsc}; +use tokio::fs; +use tokio::io::AsyncWriteExt as _; +use tokio::sync::mpsc; use tracing::{error, trace}; -use crate::{kernel::checkpoint::JammedCheckpoint, nockapp::NockAppError}; +use crate::kernel::checkpoint::JammedCheckpoint; +use crate::nockapp::NockAppError; // Save actor messages pub(crate) enum SaveMessage { @@ -81,9 +87,7 @@ impl SaveActor { .await .map_err(NockAppError::SaveError)?; - file.sync_all() - .await - .map_err(NockAppError::SaveError)?; + file.sync_all().await.map_err(NockAppError::SaveError)?; trace!( "Write to {:?} successful, ker_hash: {}, event: {}", @@ -93,7 +97,8 @@ impl SaveActor { ); // Flip toggle after successful write - self.buffer_toggle.store(!self.buffer_toggle.load(Ordering::SeqCst), Ordering::SeqCst); + self.buffer_toggle + .store(!self.buffer_toggle.load(Ordering::SeqCst), Ordering::SeqCst); self.event_sender.send(checkpoint.event_num)?; self.last_save = std::time::Instant::now(); diff --git a/crates/nockchain-api/Cargo.toml b/crates/nockchain-api/Cargo.toml index 619a968eb..e6e335b6e 100644 --- a/crates/nockchain-api/Cargo.toml +++ b/crates/nockchain-api/Cargo.toml @@ -18,7 +18,7 @@ tikv-jemallocator = { workspace = true } [dependencies] clap.workspace = true -kernels = { workspace = true, features = ["dumb"] } +kernels-open-dumb = { workspace = true } nockapp.workspace = true nockchain.workspace = true nockvm.workspace = true diff --git a/crates/nockchain-api/src/main.rs b/crates/nockchain-api/src/main.rs index 2ac9907b1..1e7be2402 100644 --- a/crates/nockchain-api/src/main.rs +++ b/crates/nockchain-api/src/main.rs @@ -1,7 +1,7 @@ use std::error::Error; use clap::Parser; -use kernels::dumb::KERNEL; +use kernels_open_dumb::KERNEL; use nockapp::kernel::boot; use nockchain::NockchainAPIConfig; use zkvm_jetpack::hot::produce_prover_hot_state; diff --git a/crates/nockchain-explorer-tui/Cargo.toml b/crates/nockchain-explorer-tui/Cargo.toml index e3e2d6bee..e1e4c17da 100644 --- a/crates/nockchain-explorer-tui/Cargo.toml +++ b/crates/nockchain-explorer-tui/Cargo.toml @@ -18,6 +18,7 @@ nockapp-grpc-proto = { workspace = true } nockchain-math = { workspace = true } nockchain-types = { workspace = true } ratatui = "0.28" +rustls = { workspace = true } tokio = { workspace = true, features = ["full"] } tonic = { workspace = true } tracing = { workspace = true } diff --git a/crates/nockchain-explorer-tui/src/main.rs b/crates/nockchain-explorer-tui/src/main.rs index 7e14517df..a2116763d 100644 --- a/crates/nockchain-explorer-tui/src/main.rs +++ b/crates/nockchain-explorer-tui/src/main.rs @@ -46,6 +46,7 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph, Tabs, Wrap}; use ratatui::{Frame, Terminal}; +use rustls::crypto::aws_lc_rs; use tokio::sync::mpsc::error::TryRecvError; use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; use tonic::Request; @@ -3934,6 +3935,9 @@ fn init_tracing() -> Result<()> { #[tokio::main] async fn main() -> Result<()> { init_tracing()?; + aws_lc_rs::default_provider() + .install_default() + .map_err(|e| anyhow!("failed to install rustls provider: {e:?}"))?; // Parse CLI args let args = Args::parse(); diff --git a/crates/nockchain-math/src/zoon/common.rs b/crates/nockchain-math/src/zoon/common.rs index d22cb2c5f..d3455aa78 100644 --- a/crates/nockchain-math/src/zoon/common.rs +++ b/crates/nockchain-math/src/zoon/common.rs @@ -1,6 +1,6 @@ use nockvm::jets::util::BAIL_FAIL; use nockvm::jets::JetErr; -use nockvm::noun::{Noun, NounAllocator, D}; +use nockvm::noun::{Noun, NounAllocator}; use noun_serde::NounDecode; use crate::belt::Belt; @@ -102,21 +102,67 @@ pub fn dor_tip( a: &mut Noun, b: &mut Noun, ) -> Result { - use nockvm::jets::math::util::lth; + use nockvm::jets::math::util::lth_b; if unsafe { stack.equals(a, b) } { Ok(true) } else if !a.is_atom() { if b.is_atom() { Ok(false) - } else if unsafe { stack.equals(&mut a.as_cell()?.head(), &mut b.as_cell()?.head()) } { - dor_tip(stack, &mut a.as_cell()?.tail(), &mut b.as_cell()?.tail()) } else { - dor_tip(stack, &mut a.as_cell()?.head(), &mut b.as_cell()?.head()) + let a_cell = a.as_cell()?; + let b_cell = b.as_cell()?; + + let mut a_head = a_cell.head(); + let mut b_head = b_cell.head(); + if unsafe { stack.equals(&mut a_head, &mut b_head) } { + let mut a_tail = a_cell.tail(); + let mut b_tail = b_cell.tail(); + dor_tip(stack, &mut a_tail, &mut b_tail) + } else { + dor_tip(stack, &mut a_head, &mut b_head) + } } } else if !b.is_atom() { - Ok(false) + Ok(true) } else { - let cmp = lth(stack, a.as_atom()?, b.as_atom()?); - Ok(unsafe { cmp.raw_equals(&D(1)) }) + Ok(lth_b(stack, a.as_atom()?, b.as_atom()?)) + } +} + +#[cfg(test)] +mod tests { + use nockvm::mem::NockStack; + use nockvm::noun::{D, T}; + + use super::dor_tip; + + #[test] + fn dor_tip_matches_hoon_for_mixed_atom_cell_inputs() { + let mut stack = NockStack::new(8 << 10 << 10, 0); + let cell = T(&mut stack, &[D(1), D(2)]); + + let mut atom_vs_cell_left = D(7); + let mut atom_vs_cell_right = cell; + assert!( + dor_tip(&mut stack, &mut atom_vs_cell_left, &mut atom_vs_cell_right) + .expect("dor-tip should succeed") + ); + + let mut cell_vs_atom_left = cell; + let mut cell_vs_atom_right = D(7); + assert!( + !dor_tip(&mut stack, &mut cell_vs_atom_left, &mut cell_vs_atom_right) + .expect("dor-tip should succeed") + ); + + // Regresses Roswell parity failure where matching deep heads should recurse to tails. + let pair = T(&mut stack, &[D(1), D(2)]); + let head = T(&mut stack, &[pair, D(3)]); + let mut deep_left = T(&mut stack, &[head, D(4)]); + let mut deep_right = T(&mut stack, &[head, D(5)]); + assert!( + dor_tip(&mut stack, &mut deep_left, &mut deep_right).expect("dor-tip should succeed"), + "expected dor-tip to compare tails when deep heads are equal" + ); } } diff --git a/crates/nockchain-peek/Cargo.toml b/crates/nockchain-peek/Cargo.toml index 02e1f76e6..a5c6bac6d 100644 --- a/crates/nockchain-peek/Cargo.toml +++ b/crates/nockchain-peek/Cargo.toml @@ -6,7 +6,7 @@ edition.workspace = true [dependencies] clap.workspace = true -kernels = { workspace = true, features = ["nockchain_peek"] } +kernels-open-nockchain-peek = { workspace = true } nockapp.workspace = true nockapp-grpc = { workspace = true } nockvm = { workspace = true } @@ -16,4 +16,3 @@ rustls.workspace = true tokio = { workspace = true, features = ["full"] } tracing = { workspace = true } zkvm-jetpack.workspace = true - diff --git a/crates/nockchain-peek/src/main.rs b/crates/nockchain-peek/src/main.rs index b6bd09d6e..ae8d9d952 100644 --- a/crates/nockchain-peek/src/main.rs +++ b/crates/nockchain-peek/src/main.rs @@ -1,7 +1,7 @@ use std::error::Error; use clap::Parser; -use kernels::nockchain_peek::KERNEL; +use kernels_open_nockchain_peek::KERNEL; #[tokio::main] async fn main() -> Result<(), Box> { diff --git a/crates/nockchain-types/src/tx_engine/common/mod.rs b/crates/nockchain-types/src/tx_engine/common/mod.rs index fc7e7f094..c1723b776 100644 --- a/crates/nockchain-types/src/tx_engine/common/mod.rs +++ b/crates/nockchain-types/src/tx_engine/common/mod.rs @@ -142,6 +142,8 @@ pub enum HashDecodeError { ProvidedValueTooLarge, #[error("base58 decode error: {0}")] Base58(#[from] bs58::decode::Error), + #[error("expected {expected} bytes for tip5 hash, got {actual}")] + InvalidByteLength { expected: usize, actual: usize }, } #[derive(Debug, Clone, PartialEq, Eq, Hash, NounDecode, NounEncode, Serialize, Deserialize)] @@ -149,19 +151,7 @@ pub struct Hash(pub [Belt; 5]); impl Hash { pub fn to_base58(&self) -> String { - fn base_p_to_decimal(belts: [Belt; N]) -> String { - let prime = BigUint::from(PRIME); - let mut result = BigUint::from(0u8); - - for (i, value) in belts.iter().enumerate() { - let pow = prime.pow(i as u32); - result += BigUint::from(value.0) * pow; - } - - bs58::encode(result.to_bytes_be()).into_string() - } - - base_p_to_decimal(self.0) + bs58::encode(self.to_be_bytes()).into_string() } pub fn from_base58(s: &str) -> Result { @@ -183,6 +173,75 @@ impl Hash { Ok(Hash(belts)) } + /// Decode a tip5 hash from a big-endian 32-byte value using base-p decomposition. + pub fn from_be_bytes(bytes: &[u8; 32]) -> Self { + let mut value = BigUint::from_bytes_be(bytes); + let prime = BigUint::from(PRIME); + let mut belts = [Belt(0); 5]; + for belt in &mut belts { + let rem: u64 = (&value % &prime) + .try_into() + .expect("remainder must fit in u64"); + *belt = Belt(rem); + value /= ′ + } + Hash(belts) + } + + /// Convert this tip5 hash to its atom representation (big integer). + /// This is the inverse of `from_be_bytes` for values that fit in 32 bytes. + /// Encodes limbs as a base-p number: a + b*p + c*p^2 + d*p^3 + e*p^4. + // TODO: Unify this implementation with the digest_to_atom jet. + pub fn to_atom(&self) -> BigUint { + let prime = BigUint::from(PRIME); + let mut result = BigUint::from(0u8); + let mut power = BigUint::from(1u8); + for belt in &self.0 { + result += BigUint::from(belt.0) * &power; + power *= ′ + } + result + } + + /// Convert this tip5 hash to big-endian bytes of its atom representation. + pub fn to_be_bytes(&self) -> Vec { + self.to_atom().to_bytes_be() + } + + pub fn from_limbs(limbs: &[u64; 5]) -> Self { + Hash([Belt(limbs[0]), Belt(limbs[1]), Belt(limbs[2]), Belt(limbs[3]), Belt(limbs[4])]) + } + + pub fn to_be_limb_bytes(&self) -> [u8; 40] { + let limbs = self.to_array(); + let mut out = [0u8; 40]; + for (i, limb) in limbs.iter().enumerate() { + out[i * 8..(i + 1) * 8].copy_from_slice(&limb.to_be_bytes()); + } + out + } + + pub fn from_be_limb_bytes(bytes: &[u8]) -> Result { + if bytes.len() != 40 { + return Err(HashDecodeError::InvalidByteLength { + expected: 40, + actual: bytes.len(), + }); + } + let mut limbs = [0u64; 5]; + for (i, limb) in limbs.iter_mut().enumerate() { + let start = i * 8; + let chunk: [u8; 8] = bytes[start..start + 8].try_into().map_err(|_| { + HashDecodeError::InvalidByteLength { + expected: 40, + actual: bytes.len(), + } + })?; + *limb = u64::from_be_bytes(chunk); + } + Ok(Self::from_limbs(&limbs)) + } + pub fn to_array(&self) -> [u64; 5] { [self.0[0].0, self.0[1].0, self.0[2].0, self.0[3].0, self.0[4].0] } @@ -241,6 +300,126 @@ impl TimelockRangeAbsolute { } } +#[cfg(test)] +mod tests { + use nockchain_math::belt::{Belt, PRIME}; + use num_bigint::BigUint; + + use super::{Hash, HashDecodeError}; + + fn biguint_to_be_32(value: &BigUint) -> [u8; 32] { + let mut out = [0u8; 32]; + let bytes = value.to_bytes_be(); + assert!( + bytes.len() <= 32, + "expected value that fits in 32 bytes, got {} bytes", + bytes.len() + ); + out[32 - bytes.len()..].copy_from_slice(&bytes); + out + } + + #[test] + fn tip5_be_limb_bytes_roundtrip() { + let hash = Hash([Belt(1), Belt(2), Belt(3), Belt(4), Belt(5)]); + let bytes = hash.to_be_limb_bytes(); + let roundtrip = Hash::from_be_limb_bytes(&bytes).expect("valid be limb bytes"); + assert_eq!(hash, roundtrip); + } + + #[test] + fn tip5_be_limb_bytes_rejects_wrong_length() { + let bytes = vec![0u8; 39]; + let err = Hash::from_be_limb_bytes(&bytes).expect_err("invalid length"); + assert!(matches!( + err, + HashDecodeError::InvalidByteLength { + expected: 40, + actual: 39 + } + )); + } + + #[test] + fn tip5_limbs_roundtrip() { + let test_cases = vec![ + Hash([Belt(0), Belt(0), Belt(0), Belt(0), Belt(0)]), + Hash([Belt(1), Belt(0), Belt(0), Belt(0), Belt(0)]), + Hash([Belt(1), Belt(2), Belt(3), Belt(4), Belt(5)]), + Hash([Belt(100), Belt(200), Belt(300), Belt(400), Belt(500)]), + Hash([ + Belt(PRIME - 1), + Belt(PRIME - 1), + Belt(PRIME - 1), + Belt(PRIME - 1), + Belt(PRIME - 1), + ]), + ]; + + for original in test_cases { + let limbs = original.to_array(); + let reconstructed = Hash::from_limbs(&limbs); + assert_eq!(original, reconstructed, "hash should roundtrip exactly"); + } + } + + #[test] + fn tip5_from_be_bytes_known_vectors() { + let one = { + let mut bytes = [0u8; 32]; + bytes[31] = 1; + bytes + }; + + // Bridge vector with explicit bytes and limbs. + // + // Calculation: + // n = 1 + 2*p + 3*p^2 + 4*p^3 + 1*p^4, where p = PRIME + // n (32-byte big-endian) = + // 0xfffffffc0000000dffffffe40000002dffffffce0000002cffffffe80000000b + let bridge_limbs = [1, 2, 3, 4, 1]; + let bridge_bytes = [ + 0xff, 0xff, 0xff, 0xfc, 0x00, 0x00, 0x00, 0x0d, 0xff, 0xff, 0xff, 0xe4, 0x00, 0x00, + 0x00, 0x2d, 0xff, 0xff, 0xff, 0xce, 0x00, 0x00, 0x00, 0x2c, 0xff, 0xff, 0xff, 0xe8, + 0x00, 0x00, 0x00, 0x0b, + ]; + + let prime = BigUint::from(PRIME); + let p2 = &prime * ′ + let p3 = &p2 * ′ + let p4 = &p3 * ′ + let bridge_value = BigUint::from(1u64) + + BigUint::from(2u64) * &prime + + BigUint::from(3u64) * &p2 + + BigUint::from(4u64) * &p3 + + BigUint::from(1u64) * &p4; + assert_eq!( + biguint_to_be_32(&bridge_value), + bridge_bytes, + "bridge vector bytes should match documented base-p expansion", + ); + + let test_vectors: [([u8; 32], [u64; 5]); 2] = + [(one, [1, 0, 0, 0, 0]), (bridge_bytes, bridge_limbs)]; + + for (input, expected_limbs) in test_vectors { + let digest = Hash::from_be_bytes(&input); + assert_eq!( + digest.to_array(), + expected_limbs, + "unexpected base-p limbs for input bytes" + ); + + let atom = digest.to_atom(); + assert_eq!( + biguint_to_be_32(&atom), + input, + "bytes -> tip5 -> atom should roundtrip back to the original 32 bytes" + ); + } + } +} + #[derive(Debug, Clone, PartialEq, Eq, NounEncode, NounDecode)] pub struct TimelockRangeRelative { pub min: Option, diff --git a/crates/nockchain-types/src/tx_engine/common/page.rs b/crates/nockchain-types/src/tx_engine/common/page.rs index 4a6925176..68f475f62 100644 --- a/crates/nockchain-types/src/tx_engine/common/page.rs +++ b/crates/nockchain-types/src/tx_engine/common/page.rs @@ -1,6 +1,7 @@ -use ibig::UBig; +use nockvm::ext::AtomExt; use nockvm::noun::{Noun, NounAllocator}; use noun_serde::{NounDecode, NounDecodeError, NounEncode}; +use num_bigint::BigUint; use super::{Hash, TxId}; @@ -50,17 +51,21 @@ fn collect_zset_items( } #[derive(Debug, Clone, PartialEq, Eq)] -pub struct BigNum(pub UBig); +pub struct BigNum(pub BigUint); impl BigNum { pub fn from_u64(value: u64) -> Self { - BigNum(UBig::from(value)) + BigNum(BigUint::from(value)) } } impl NounEncode for BigNum { fn to_noun(&self, allocator: &mut A) -> Noun { - nockvm::noun::Atom::from_ubig(allocator, &self.0).as_noun() + let bytes = self.0.to_bytes_le(); + if bytes.is_empty() { + return nockvm::noun::Atom::new(allocator, 0).as_noun(); + } + nockvm::noun::Atom::from_bytes(allocator, &bytes).as_noun() } } @@ -92,10 +97,12 @@ impl NounDecode for BigNum { if let Ok(end) = current.as_atom() { if end.as_u64() == Ok(0) { // Reconstruct the number: chunks are in LSB order, each is 32 bits - let mut result = UBig::from(0u32); - for (i, chunk) in chunks.iter().enumerate() { - let shift = i * 32; - result += UBig::from(*chunk) << shift; + let mut result = BigUint::from(0u8); + let mut factor = BigUint::from(1u8); + let base = BigUint::from(1u64) << 32; + for chunk in chunks { + result += BigUint::from(chunk) * &factor; + factor *= &base; } return Ok(BigNum(result)); } @@ -120,8 +127,8 @@ impl NounDecode for BigNum { NounDecodeError::Custom("BigNum: expected atom or [%bn list] cell".into()) })?; let bytes = atom.as_ne_bytes(); - let ubig = UBig::from_le_bytes(bytes); - Ok(BigNum(ubig)) + let biguint = BigUint::from_bytes_le(bytes); + Ok(BigNum(biguint)) } } @@ -230,6 +237,8 @@ impl NounEncode for Page { } impl NounDecode for Page { + // TODO: Purge these custom Page NounDecode/NounEncode implementations in favor of + // the standard noun-serde path once it can represent this shape directly. fn from_noun(noun: &Noun) -> Result { let cell = noun .as_cell() diff --git a/crates/nockchain-wallet/Cargo.toml b/crates/nockchain-wallet/Cargo.toml index 0a2c1f59b..dfa49d303 100644 --- a/crates/nockchain-wallet/Cargo.toml +++ b/crates/nockchain-wallet/Cargo.toml @@ -12,7 +12,7 @@ crossterm.workspace = true either.workspace = true getrandom.workspace = true http = "1" -kernels = { workspace = true, features = ["wallet"] } +kernels-open-wallet = { workspace = true } nockapp = { workspace = true } nockapp-grpc = { workspace = true } nockchain-math = { workspace = true } diff --git a/crates/nockchain-wallet/src/main.rs b/crates/nockchain-wallet/src/main.rs index e486e44ec..2425095ac 100644 --- a/crates/nockchain-wallet/src/main.rs +++ b/crates/nockchain-wallet/src/main.rs @@ -28,7 +28,7 @@ use command::WalletWire; use command::{ ClientType, CommandNoun, Commands, NoteSelectionStrategyCli, WalletCli, WatchSubcommand, }; -use kernels::wallet::KERNEL; +use kernels_open_wallet::KERNEL; use nockapp::driver::*; use nockapp::kernel::boot::{self, NockStackSize}; use nockapp::noun::slab::{NockJammer, NounSlab}; diff --git a/crates/nockchain/Cargo.toml b/crates/nockchain/Cargo.toml index 8d8a5b356..be2f5cc3a 100644 --- a/crates/nockchain/Cargo.toml +++ b/crates/nockchain/Cargo.toml @@ -17,7 +17,8 @@ clap.workspace = true equix.workspace = true futures.workspace = true ibig.workspace = true -kernels = { workspace = true, features = ["dumb", "miner"] } +kernels-open-dumb = { workspace = true } +kernels-open-miner = { workspace = true } libp2p = { workspace = true, features = [ "ping", "kad", diff --git a/crates/nockchain/jams/fakenet-genesis-pow-2-bex-4.jam b/crates/nockchain/jams/fakenet-genesis-pow-2-bex-4.jam new file mode 100644 index 0000000000000000000000000000000000000000..34e08cd70161a518355b9f633854a23e95cb6928 GIT binary patch literal 69512 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%0A98Q5n$1j^DXEUJ6Kqb$Y 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

Is}r$p3*}NYqWJja~OTS=%9>cPW7pcw0lda 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≪xQqNsh!o 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�Ar+&?C=8nIwEE>gY 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 z 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 ?~l_O3eS%Y|9+!)Y$2 zzs_|Hz^--Pm13GmBvPwfkg%%=)+7Oyl&Jta0E5B!_&s%Us>dM*B!SDVaZ$-KxkfpJ z5;x~WEY+)H9Jbxv=l2n6A}XvQOHXdM zkO6ctCI-}aE+@8=&9(H#HutDUlw~EicQ{SGN|SsN9v zQk`g(t_dNOjvGh$1*;k@x1iLg^b18 zbn)peVQ&!C;SFC8Xy2By+_#&WP#`goKnH8lTZzYId~D}Txf>nv{N+`LgDw9627WVZ zE*u|&uEPwrYI5Czqf8}ga>zZptthuzHL%MhVM;PQ zg;);&pB*xRnc7&uIO=Y*sQ!KVOhJ8QvK^x#>t9Da^%@Eyh$g z>V3Zd0O>uu{rZB}40EJ;eT5C0$Ko>)j+xu%KuKR8dKlrs&793;Yg4sjD;0hkqs*=}=a3yfbSsO9lp-N7t8z%F?;&2UQR~MfpQesW6{s|b z;8~4wOqFLU2H(CklVE&}Z~Jv{qG*U7vPa?XK`$gyi4>UBAIhO`QLFy93JiiW_#D)@ zXm7I^tg@(Rs=VFA>~5xqzD z(LiGnXI4LkV$H9QBVA*&nL{ubAl9-FyXSA`IK`o#JMa z20ni=$*&q7HTdhM;F2xHt{eMJa~w)0x470u19qt#C3#6!(t-hKgvc_AG+a^~jRbgo6V=4)2#n?P-X%|EMbbmip&RpaHP+=NQGv1Qsvt?Q# zT2-@RRh8w6G79%dLKPoi`=kE=J~~=kn~yZ4M3I^z2-S5zG2fj81$yvC?97DKbjk3(xbU!pSio7Ni>o;w7t>|#onQPhVldUji15!=n3f< zAXBLjZ&*J{EYU1WWs87o_80>K(z$j8Q+=ccMgIWQ^fnlL^r~c&gUYAg15Um-0{J&_ z`nlq?Ysaw4M0MSz?p4x;(L3@!2U|9vtVE8rpLW@813C{nX=Y}cg&MJwr@Knrwu>+> zWtwnMZ(IJ6ug0#{zus^v`1Fv}YfT;l?b*ul#k zSYAlh6ppl3C3oA%C6H;M?$~gOq$? zN2Q4PJ>D1s!(aFR0JlO4$~6OuKcJl>o+!|i`>7%kEH)VxSqXt7_X8r0E`pMNPvrdk zbW#G1Yw-g@EXs5dwH<7OW8`Yq)s#k97(T~Q+yMbug9F|8(ER!89(8Yyz2#?dv7pxE zGP>D;ma{E}WsQ|WuXyZjMs^rTzdQKfRKRcu~}E*|Sp*3H1*s`Kjf}U8-?&S4brWW<{!C zQ|u*FEPmEj*IE|$?zbaR^j2Np4MnyA2i^{~J;BJ}bw^m3nNZ0St0Jo_0IMKVqM^b6 z0KZ*Wy8!`Ebdy;^45zE(%_6=@E%Jqa!3t2vF$!DmPV?t(HV?SX{@o*R?IqwT&{L}k zhTO2pR_9YM7WD$%Ttjhc*=Z?btYjt0JqMh{N%9icimTY${{Sr|72y%@Ki1U>JVmJw&#o4PV0_JgXsH^QHTuBkto ziK@%hagCQX;U%*&#e8(c+{oOD?hm1#RNqJ*fqEaweKyTta=xZmhhIi@BmiHfhv zqo^x>n&sP*Gk-1w9iG?pHnc(W*UfLd8{*D6#KrXT2;D|d>_Y9|JJ;H{caJ<7;m!je z3a~9JKF^Rr#Y2BtDd;cM2M&wW-%ou}qry1uH}uwcr=efNT-L;2p;D>NRK!!p`&j&E z@$^0Eua_W`-$PUaGpeqJ%}kMmWm;xYW!)a*YJ;&}Dr*zQZ7$MO6vJJ#C3 zsBcdlaMrEtKer>u8_4hv@6-08NgxY$tkUD4HH#wKo6~J*0$P^N=M^w|f#?^g{351m z_DVbyc)l~5xyP(Q26Cy%&vP9tam@9x&VA`??1C7NKO?2$yEB!JG{Dt^n%BDF*H-D~ z!cd^W&S^8!7#9cXKMcxpdg2Z%f0SbAW}e*z%wz3g#l)|bj%;+D&|63YlIq|2N&F6} z#&)uj5vfR1jC084RN;kE>`8K=;^bHMuXO&BdO_*uru367z$sLMmm}lcX0=N+vGlRf z{{R8T(~`~}rE0Yxi`u0U-;vQ@Y=!hm>*Ev2DFj@fyzkfRiv3x@pt4I??ktcZ$b(_G z?NLY8_pdk)r{6$%Z!_im7nFK~#4gsg#Q9D?iQumCikR9{ov z=hu3Ellps=vLN)s&<-h+;a-#AqmL;Z6!E#d-Hg^xD-;>YX3IdfUP$Y~9PaNOTJftF zZs$FPqQ)QXj;rP{##gm|be2Yn)i_j5A-9a=2S1J~q^g)L&@&Qi&d}SLP;KLl+@m8F z2U?7N-h6b|C(?Nz=i^9`>C`#VigpEinP@AAmb9xn`)#ns4)Rd{080@eACdU@>SGM7 zR~u%%Bw^zj&pLiVY)6K=&^wRRP@z-XloUSO&RN&AXn`9?<9~7g0Dhj3qhsRLjew*z zxYry!@z%XN{Vo3hs}G`|>D&I7K9u@>!Ry!Ozf>IlhnzZWO74Zz-9DZ@9ae1|rDcr`Eom!ub8C0r2t&m^JBNhp*4)o>fwP z5c)&<&i?>Zyxvpl$LgD&^9%>mpBs4jFFW;vo=b9-e2*8xJWt6f`c=dcBBdH*@Nvpx zv04b~#*y#7N7nY9>xb|}YfA{kx41aQ4*2QXvF-SG9q~;60O}lR%p5FoEO0dE-*0N| z@7H#Fg8eZ40{TOW{{T;}byJ6WON;ea2?O#!Nir3)b$JAF;_Y5*T(^``l4gmnWMhhZ zzpDG}P1_WMT2YVTe=_fn=Z+~dGUZgBU9a7!;H_9R!E-c`(b zMh^cqydmV zb@B_FnWs|M1cr0Iasj4sipx;QNgWBY6%w)=&lxg7j*X^BmRU;w0O6^XyGH*2an)oI zpayr`pK=8*1L$lorA~QstzgJmR)wp}EKeH-ghD(42vDSJ{;~V~6VWaiMYt+H>}{G? z%Sjs`bNy)5)yia}_OdqQWu+9e+K$x4Wf7SR6CfoXP4D`3@OlbHu}7^>p@&`h)u#qW z0k&WO9qJCeoAkiJ+BrHlPOJ)JvW8Zy}b)$t@drI8Fx@`W!Ysy0mt(ixsKoNi7iT+j4(*M}9mG zvu0Hyv_z7L+PIBmqy2~-fo^`ggh6l`^$M?LEF`ge)B#W=863a!sfW4(~MG!@!>CR%LvbgI3U&d?FrwDoJpAcDQ25vFQgG^w|12dm)jAY(Ke_k1*WxIg**8 zkRNUZxYs@;s)_Kbg|RSj;8-CKnZd8%=6s zB*sY#p4NiG%N*<*N66@6f%tR_5|G-rBzv5G)xhyP%Uj73cOjjw*6DMAZl870F)t;+qs=kSP2K+u^W-ztet;D`F#ZBP}9wE zIP(}jBai0Xq8YQ1%@n+MD%D#JR+Qe|h;liZY*;HLNW!#40L1U5?e2}mu@a0GW0Q`# zAM;xiaM+@Zv@Qr?xIK+Y{{T-MKbGQtq4+KXo_cczV+m^~PCi%0-mP4W(&Muvf7Ry1Gj>`4(*h{LNSg^>`s3Vty6_W6N;_8${O2F4SLh`YtxTT`1b|l zd@mWzb1>V(Rcb=NB_FG?U7E!6M~`b_C>C|41FT5ONhqW9zOg6bTtWfn7=uPTb2apP zUJ+w%KF}!sC#@`fy8Dj1m(S17o%G_}M=Slr ziplJ0Q)bb=sZ@cFr4Tss-?-QWH*e4Vh}Z!C0N-9ZJITpM#ww3%F08^mCz-1K^)2Z( zOVwXTxvvbtPlcz*@>q+JVXkMfQDv%Co#JSvk{D)=Rfiz;nbH}RS>t9TeDoQV$QM)L z!3Ul*gPKnE*v$`+fEPc^*N`awlD&J)dcBqOdy4vd%Cp?B4V;S?lai(fDzvfj+?1MG zoWz2!y@<9|F18E>4YA!daaPl<5_81%g^b4QfmAQn*RU_#VE^7 znBtcCEU{xbdNv%ak)nSvx$NyaJqho`kEY)zJ7=G_>sk@;`x{$dw%nY`zuWcbF`ji~ z{Wav?iu$P@RPgMd9`!qpTH%Kq&bS}cUMm%W}{%sl7n#bI&7c0ll!hy3$udy5N3)opO8Bp~Nh6j(1OoslQj5rrs-2w*_RzW)G?7RVKR7Gl`sd1uG3`xW7ZF;NcndH;CQ|jQw`}K z665~>!|~kP)hpQ;zgIWta$+`o9U03=^4M~&s(V3{{YOik`NVE7j}|m1bJl}1EDDd9Yks6 z@7pyC6!T#SBHU>m5JhimNDfjU{E&!+-N$n=n_goVyQ4FNJyYn7ot;W}a55 zni|$rg;Ojj<{~3^BV$KEe38+im`XJe7u&UVU+K<@p^1IDj_?_V0drY)As^+kc8xt$;XnY@ce>V~aAUkgf9u{b%@zzQ8L=OH^xt={!w!Hj|S%t7w{Vz%@^@O*U) zA}gIcRm$7}m2;XlGFiD_x_PPEe{;Fo<}!rk%7TBG%~%#4gZz zPu)EgrGBJVBiIic%(q+H?gzp7{GO~8!k;Emr1dm8Riyw(si(CGEgW2`4Q@XaVS|t( z%iApe^P(e+WTdXPQm|M%{g!5U1dTfU zpjn(dCthjm^qpXyuXKtFeel9W(5|ZkPs; zks_+0`QJd2OK9?>5tF#zof<0}#u#7i`Bv5{$J*^J5~NIM_%Jh7Am9-SNNA z=YKt3FR~5MPW{bRXs#FIAY-5cmmuT(e%>a$`L0#SBF&4pT+pYCttBL`=?`V8ZF{iJ zWu8z$T?IuO1AQ<4p?eJFB7!q+I_G@;6yk8(X`%Bcot4i_QcwIdMiuuLI|YXBI$3Jn zQyKx3Lb$hhehzqBjk2xrYZMkrZ(_SeoZ0UG7cwFB%ECUB|;4FX4v4vM4A zAAB$|$kd4L6#8^&6by@zm`M}fwJa=wStPkHwK5MOUNtC^cJJ~C`Rd+XvbOQ5ZuRWm z9I3$>?OBuc@<$D6Zd@=#fg*rgv)L4Jg2<7ekSuDXooEdbdX;V*t^Udf=U&9@26a&u z#cYn?(ntZ5xr?(M5(k$q2_r;<=Y15-5FU*qp8HU~#`?7!n)&foU+ikuVh+8)v@$A$ zCu+moO%xz&z}CneFZPc$^s9r4^F+sB2s>uCXeeT%YuDwgLcMkO9>vyzq){jVnZ%Q@ zjR$GjAdP-cQ0Fb6oSpsa&ur*?$uJOapZlt}^#S#H>2ILc_?=wPn~+J&ahY767XBW^ zTeyczIgkEcd(brKlV6@yMeX<}XU9Nk1dbz(uxTr)po%?Vl)2@~qwh~Xv3{AoRLSv5 znP{=RTb9lnlF-KTYEX*!Jfwc@c^V9{iE7u#djX1-?e7iq`RL6Yv7yj)?a%kDh&rMh#=eoU_M~n>n4YDec>>L}CdMrciRD^Qi}z z@R+z`<0gwW9%>Pa!q?8pEI-5l0EhDYt!b^^z*Dazp5?glk$|RPJA0c)M_mui%;lqB zy4VBspz8Br5;(&X%i5VTW+0w9Vv0GXvF^emwX_wXMIcJ1HQAY%_y^;oF(l*WQILLx2m69UNKn!=)v@0AAOVoWR z!g)NRvK`cZHPcQx_3Od8U3?!e_3P3rzM%S}$FtPosq$_LI^E%P7593%^mT@1a+?^^oPCLnKn|PZN@{E2{m*leffrq(`$70_jXA_QrZCf%lj?E)%h3y@B zSfGKt+oDKqv$u24wO@uAxP%i~+7hG_j>qRxkD;IG3C_JZ^-~MX@}8(*_>UFCa!xzQ ztK+!#HhcW4d3e@hr0~*vlDD-|9H>W@Kzq~zBVb3|7dMF*D+-1g^p@>T+xU0NxsK*X zD9eI$jmfV5R0mi zGC>>0Vs*-qit+LFdFh8T`hN9`lHjx0d`)`U*|Ql=Eed7g%?lVCmnMp>>rufs>T%W` z*y=fv4)vfw-&9Q!N$08(NY4E|>x$GZ%f^LuD-q9-`BgKH7LEryF_t0aW8sM|-wfat zQooOoHU2th3el81Ip;!E3`tN9dQ;eXbLwf~wsY{|>!%lj#~e#C+|OX~*$c9AZ`8jZ zTz(4XPAukH3rShzGFGoFaHg6ctVrVq=N9e8~27?}_(e}Sp$KSO;u z;&}XooZr+cx!l~m7M6P#LyNe9Drd5=i7I0=! zh_KgyTw`iPIXUu=-JQQ0OwD0&7vZs9aHd0GN}*CH3|2j|1&9bzV`V|(@v-|2^*B(5 z76Vr?fkVSaLZLaWIM)Q@IQiwv)}dwPryMk$=AFH!nnZV!c;8B<#EP2(WBR&XZDEc< zAkMNgy%0$=pII2iNOkN}$5F}Fn-v}Ta#Z1hjmgZ9J|ac86_=Wfiz38jo)s~W;l9Fr z0n^T{E30IM9Kh>PJh+s*fyI zjZggcpfc%gT1Wv2y`I0V%> zM>ys8%e71*Tw_}w6m-y!;(1%5yQC4F>qlNsRDiIIg~0=%tq~MX`rqLnr_Qd=r5{Or z`_*1q#P0e-!aYyK@TjiER?n yOWdc{Gj~<;qFSAmjLm1=^G<)v64Crvc;c1z5Ja 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