From a94765415d6af7089be4248644d0ec18f24d0ad7 Mon Sep 17 00:00:00 2001 From: TheNewAutonomy Date: Sun, 8 Feb 2026 20:53:22 +0000 Subject: [PATCH 1/8] protocol: add chain identity and deterministic genesis hash Add protocol.chain_id/network_id to node config, stamp chain identity into storage, and compute a deterministic genesis hash (fresh DB applies faucet funding; legacy DBs stamp identity without state reset). Wire EVM execution to use the configured chain_id and expose chainId/networkId/genesisHash over RPC. Co-authored-by: Cursor --- crates/catalyst-cli/src/config.rs | 23 ++++ crates/catalyst-cli/src/evm_revm.rs | 6 +- crates/catalyst-cli/src/node.rs | 160 +++++++++++++++++++++++----- crates/catalyst-rpc/src/lib.rs | 60 +++++++++++ 4 files changed, 223 insertions(+), 26 deletions(-) diff --git a/crates/catalyst-cli/src/config.rs b/crates/catalyst-cli/src/config.rs index ffd3abd..486c535 100644 --- a/crates/catalyst-cli/src/config.rs +++ b/crates/catalyst-cli/src/config.rs @@ -5,6 +5,9 @@ use anyhow::Result; /// Complete node configuration #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NodeConfig { + /// Protocol / chain identity configuration + pub protocol: ProtocolConfig, + /// Node identity and role pub node: NodeIdentityConfig, @@ -36,6 +39,15 @@ pub struct NodeConfig { pub validator: bool, } +/// Protocol and chain identity configuration. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProtocolConfig { + /// Stable chain id (used for EVM domain separation and client tooling). + pub chain_id: u64, + /// Human-readable network id (e.g. "tna_testnet", "local", "mainnet"). + pub network_id: String, +} + /// Node identity and basic settings #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NodeIdentityConfig { @@ -328,6 +340,10 @@ pub struct LoggingConfig { impl Default for NodeConfig { fn default() -> Self { Self { + protocol: ProtocolConfig { + chain_id: 31337, + network_id: "tna_testnet".to_string(), + }, node: NodeIdentityConfig { name: "catalyst-node".to_string(), private_key_file: PathBuf::from("node.key"), @@ -507,6 +523,13 @@ impl NodeConfig { /// Validate configuration pub fn validate(&self) -> Result<()> { + if self.protocol.chain_id == 0 { + return Err(anyhow::anyhow!("protocol.chain_id must be > 0")); + } + if self.protocol.network_id.trim().is_empty() { + return Err(anyhow::anyhow!("protocol.network_id must be non-empty")); + } + // Validate network configuration if self.network.max_peers < self.network.min_peers { return Err(anyhow::anyhow!("max_peers must be >= min_peers")); diff --git a/crates/catalyst-cli/src/evm_revm.rs b/crates/catalyst-cli/src/evm_revm.rs index 06a91f0..fe7815c 100644 --- a/crates/catalyst-cli/src/evm_revm.rs +++ b/crates/catalyst-cli/src/evm_revm.rs @@ -225,6 +225,7 @@ fn persist_state(store: &StorageManager, state: EvmState) -> impl std::future::F pub async fn execute_deploy_and_persist( store: &StorageManager, from: Address, + chain_id: u64, protocol_nonce: u64, init_code: Vec, gas_limit: u64, @@ -252,7 +253,7 @@ pub async fn execute_deploy_and_persist( .gas_price(0u128) .kind(TxKind::Create) .data(Bytes::from(init_code)) - .chain_id(Some(31337)) + .chain_id(Some(chain_id)) .build_fill(); let out = evm @@ -325,6 +326,7 @@ pub async fn execute_call_and_persist( store: &StorageManager, from: Address, to: Address, + chain_id: u64, protocol_nonce: u64, input: Vec, gas_limit: u64, @@ -352,7 +354,7 @@ pub async fn execute_call_and_persist( .gas_price(0u128) .kind(TxKind::Call(to)) .data(Bytes::from(input)) - .chain_id(Some(31337)) + .chain_id(Some(chain_id)) .build_fill(); evm.transact(tx) diff --git a/crates/catalyst-cli/src/node.rs b/crates/catalyst-cli/src/node.rs index c9bdcd2..8e24985 100644 --- a/crates/catalyst-cli/src/node.rs +++ b/crates/catalyst-cli/src/node.rs @@ -11,6 +11,7 @@ use catalyst_network::{NetworkConfig as P2pConfig, NetworkService as P2pService, use catalyst_utils::{ CatalystDeserialize, CatalystSerialize, MessageType, MessageEnvelope, utils::current_timestamp_ms, + impl_catalyst_serialize, }; use catalyst_consensus::types::hash_data; use catalyst_utils::state::StateManager; @@ -35,6 +36,10 @@ const DEFAULT_EVM_GAS_LIMIT: u64 = 8_000_000; const FAUCET_PRIVATE_KEY_BYTES: [u8; 32] = [0xFA; 32]; const FAUCET_INITIAL_BALANCE: i64 = 1_000_000; +const META_PROTOCOL_CHAIN_ID: &str = "protocol:chain_id"; +const META_PROTOCOL_NETWORK_ID: &str = "protocol:network_id"; +const META_PROTOCOL_GENESIS_HASH: &str = "protocol:genesis_hash"; + fn balance_key_for_pubkey(pubkey: &[u8; 32]) -> Vec { let mut k = b"bal:".to_vec(); k.extend_from_slice(pubkey); @@ -271,6 +276,8 @@ async fn apply_lsu_to_storage_without_root_check( let mut outcome_by_sig: std::collections::HashMap, ApplyOutcome> = std::collections::HashMap::new(); let mut executed_sigs: std::collections::HashSet> = std::collections::HashSet::new(); + let chain_id = load_chain_id_u64(store).await; + // Apply balance / worker / EVM updates. for e in &lsu.partial_update.transaction_entries { if is_worker_reg_marker(&e.signature) { @@ -291,7 +298,7 @@ async fn apply_lsu_to_storage_without_root_check( let gas_limit = DEFAULT_EVM_GAS_LIMIT.max(21_000); match payload.kind { EvmTxKind::Deploy { bytecode } => { - match execute_deploy_and_persist(store, from, payload.nonce, bytecode, gas_limit).await { + match execute_deploy_and_persist(store, from, chain_id, payload.nonce, bytecode, gas_limit).await { Ok((_created, info, _persisted)) => { outcome_by_sig.insert( e.signature.clone(), @@ -317,7 +324,7 @@ async fn apply_lsu_to_storage_without_root_check( } EvmTxKind::Call { to, input } => { let to_addr = EvmAddress::from_slice(&to); - match execute_call_and_persist(store, from, to_addr, payload.nonce, input, gas_limit).await { + match execute_call_and_persist(store, from, to_addr, chain_id, payload.nonce, input, gas_limit).await { Ok((info, _persisted)) => { outcome_by_sig.insert( e.signature.clone(), @@ -490,6 +497,124 @@ fn faucet_pubkey_bytes() -> [u8; 32] { .to_bytes() } +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct GenesisDescriptor { + mode: String, + chain_id: u64, + network_id: String, + faucet_pubkey: [u8; 32], + faucet_balance: i64, +} + +impl_catalyst_serialize!(GenesisDescriptor, mode, chain_id, network_id, faucet_pubkey, faucet_balance); + +async fn load_chain_id_u64(store: &StorageManager) -> u64 { + store + .get_metadata(META_PROTOCOL_CHAIN_ID) + .await + .ok() + .flatten() + .and_then(|b| { + if b.len() != 8 { + return None; + } + let mut arr = [0u8; 8]; + arr.copy_from_slice(&b); + Some(u64::from_le_bytes(arr)) + }) + .unwrap_or(31337) +} + +async fn ensure_chain_identity_and_genesis(store: &StorageManager, cfg: &crate::config::ProtocolConfig) { + // Chain id + let existing_chain_id = store.get_metadata(META_PROTOCOL_CHAIN_ID).await.ok().flatten(); + if existing_chain_id.is_none() { + let _ = store + .set_metadata(META_PROTOCOL_CHAIN_ID, &cfg.chain_id.to_le_bytes()) + .await; + } + + // Network id + let existing_net = store.get_metadata(META_PROTOCOL_NETWORK_ID).await.ok().flatten(); + if existing_net.is_none() { + let _ = store + .set_metadata(META_PROTOCOL_NETWORK_ID, cfg.network_id.as_bytes()) + .await; + } + + // Genesis hash: only apply genesis state on a fresh DB. + let existing_genesis = store.get_metadata(META_PROTOCOL_GENESIS_HASH).await.ok().flatten(); + if existing_genesis.is_some() { + return; + } + + let already = store + .get_metadata("consensus:last_applied_cycle") + .await + .ok() + .flatten() + .and_then(|b| { + if b.len() != 8 { + return None; + } + let mut arr = [0u8; 8]; + arr.copy_from_slice(&b); + Some(u64::from_le_bytes(arr)) + }) + .unwrap_or(0); + + let faucet_pk = faucet_pubkey_bytes(); + + if already == 0 { + // Fresh DB: apply a minimal deterministic genesis state (faucet funding). + let existing_balance = store + .get_state(&balance_key_for_pubkey(&faucet_pk)) + .await + .ok() + .flatten(); + if existing_balance.is_none() { + let _ = set_balance_i64(store, &faucet_pk, FAUCET_INITIAL_BALANCE).await; + let _ = set_nonce_u64(store, &faucet_pk, 0).await; + } + let _ = store.commit().await; + + let g = GenesisDescriptor { + mode: "fresh".to_string(), + chain_id: cfg.chain_id, + network_id: cfg.network_id.clone(), + faucet_pubkey: faucet_pk, + faucet_balance: FAUCET_INITIAL_BALANCE, + }; + let gh = hash_data(&g).unwrap_or([0u8; 32]); + let _ = store.set_metadata(META_PROTOCOL_GENESIS_HASH, &gh).await; + info!( + "Genesis initialized chain_id={} network_id={} genesis_hash=0x{} faucet_pk={} faucet_balance={}", + cfg.chain_id, + cfg.network_id, + hex_encode(&gh), + hex_encode(&faucet_pk), + FAUCET_INITIAL_BALANCE + ); + } else { + // Legacy DB: do not mutate balances/consensus head; just stamp an identity hash. + let g = GenesisDescriptor { + mode: "legacy".to_string(), + chain_id: cfg.chain_id, + network_id: cfg.network_id.clone(), + faucet_pubkey: faucet_pk, + faucet_balance: FAUCET_INITIAL_BALANCE, + }; + let gh = hash_data(&g).unwrap_or([0u8; 32]); + let _ = store.set_metadata(META_PROTOCOL_GENESIS_HASH, &gh).await; + info!( + "Stamped legacy genesis identity chain_id={} network_id={} genesis_hash=0x{} (no state reset)", + cfg.chain_id, + cfg.network_id, + hex_encode(&gh), + ); + } +} + fn verify_protocol_tx_signature(tx: &catalyst_core::protocol::Transaction) -> bool { use catalyst_crypto::signatures::SignatureScheme; @@ -1168,6 +1293,8 @@ async fn apply_lsu_to_storage( let mut outcome_by_sig: std::collections::HashMap, ApplyOutcome> = std::collections::HashMap::new(); let mut executed_sigs: std::collections::HashSet> = std::collections::HashSet::new(); + let chain_id = load_chain_id_u64(store).await; + // Apply balance deltas from the LSU's ordered transaction entries. // Also apply worker registry markers into state under `workers:`. for e in &lsu.partial_update.transaction_entries { @@ -1199,7 +1326,7 @@ async fn apply_lsu_to_storage( let gas_limit = DEFAULT_EVM_GAS_LIMIT.max(21_000); match payload.kind { EvmTxKind::Deploy { bytecode } => { - match execute_deploy_and_persist(store, from, payload.nonce, bytecode, gas_limit).await { + match execute_deploy_and_persist(store, from, chain_id, payload.nonce, bytecode, gas_limit).await { Ok((created, info, _persisted)) => { info!("EVM deploy applied addr=0x{} ok={} gas_used={}", hex::encode(created.as_slice()), info.success, info.gas_used); outcome_by_sig.insert( @@ -1227,7 +1354,7 @@ async fn apply_lsu_to_storage( } EvmTxKind::Call { to, input } => { let to_addr = EvmAddress::from_slice(&to); - match execute_call_and_persist(store, from, to_addr, payload.nonce, input, gas_limit).await { + match execute_call_and_persist(store, from, to_addr, chain_id, payload.nonce, input, gas_limit).await { Ok((info, _persisted)) => { info!( "EVM call applied to=0x{} ok={} gas_used={} ret_len={}", @@ -1654,6 +1781,11 @@ impl CatalystNode { None }; + // Ensure chain identity + genesis are initialized (one-time, idempotent). + if let Some(store) = &storage { + ensure_chain_identity_and_genesis(store.as_ref(), &self.config.protocol).await; + } + // Auto-register as a worker (on-chain) for validator nodes. if self.config.validator { if let Some(store) = &storage { @@ -1713,26 +1845,6 @@ impl CatalystNode { rehydrate_mempool_from_storage(store.as_ref(), &mempool).await; } - // Initialize faucet (dev/testnet): create a deterministic funded account if missing. - if let Some(store) = &storage { - let faucet_pk = faucet_pubkey_bytes(); - let existing = store - .get_state(&balance_key_for_pubkey(&faucet_pk)) - .await - .ok() - .flatten(); - if existing.is_none() { - let _ = set_balance_i64(store.as_ref(), &faucet_pk, FAUCET_INITIAL_BALANCE).await; - let _ = set_nonce_u64(store.as_ref(), &faucet_pk, 0).await; - let _ = store.commit().await; - info!( - "Initialized faucet pubkey={} balance={}", - hex_encode(&faucet_pk), - FAUCET_INITIAL_BALANCE - ); - } - } - // --- RPC (HTTP JSON-RPC) --- if self.config.rpc.enabled { if let Some(store) = storage.clone() { diff --git a/crates/catalyst-rpc/src/lib.rs b/crates/catalyst-rpc/src/lib.rs index eba805d..baf0ac0 100644 --- a/crates/catalyst-rpc/src/lib.rs +++ b/crates/catalyst-rpc/src/lib.rs @@ -16,6 +16,9 @@ use std::sync::Arc; use thiserror::Error; use tokio::sync::mpsc; +const META_PROTOCOL_CHAIN_ID: &str = "protocol:chain_id"; +const META_PROTOCOL_NETWORK_ID: &str = "protocol:network_id"; +const META_PROTOCOL_GENESIS_HASH: &str = "protocol:genesis_hash"; // Note: The initial scaffold referenced sub-modules (`methods`, `server`, `types`) that // aren't present yet. Keeping the RPC types and traits in this file for now so the @@ -90,6 +93,18 @@ impl Default for RpcConfig { /// Main RPC API trait defining all available methods #[rpc(server)] pub trait CatalystRpc { + /// Get the chain id (EVM domain separation; tooling identity). + #[method(name = "catalyst_chainId")] + async fn chain_id(&self) -> RpcResult; + + /// Get the network id (human-readable network identity). + #[method(name = "catalyst_networkId")] + async fn network_id(&self) -> RpcResult; + + /// Get the genesis hash (stable chain identity hash; best-effort for legacy DBs). + #[method(name = "catalyst_genesisHash")] + async fn genesis_hash(&self) -> RpcResult; + /// Get the current block number #[method(name = "catalyst_blockNumber")] async fn block_number(&self) -> RpcResult; @@ -521,6 +536,45 @@ impl CatalystRpcImpl { #[async_trait] impl CatalystRpcServer for CatalystRpcImpl { + async fn chain_id(&self) -> RpcResult { + let cid = self + .storage + .get_metadata(META_PROTOCOL_CHAIN_ID) + .await + .ok() + .flatten() + .map(|b| decode_u64_le(&b)) + .unwrap_or(31337); + Ok(format!("0x{:x}", cid)) + } + + async fn network_id(&self) -> RpcResult { + let nid = self + .storage + .get_metadata(META_PROTOCOL_NETWORK_ID) + .await + .ok() + .flatten() + .map(|b| String::from_utf8_lossy(&b).to_string()) + .unwrap_or_else(|| "unknown".to_string()); + Ok(nid) + } + + async fn genesis_hash(&self) -> RpcResult { + let gh = self + .storage + .get_metadata(META_PROTOCOL_GENESIS_HASH) + .await + .ok() + .flatten() + .unwrap_or_default(); + if gh.len() == 32 { + Ok(format!("0x{}", hex::encode(gh))) + } else { + Ok("0x0".to_string()) + } + } + async fn block_number(&self) -> RpcResult { let n = self .storage @@ -1482,4 +1536,10 @@ mod tests { assert!(parts.contains(&sender)); assert!(parts.contains(&recv)); } + + #[test] + fn chain_id_formats_as_hex() { + assert_eq!(format!("0x{:x}", 31337u64), "0x7a69"); + assert_eq!(format!("0x{:x}", 1u64), "0x1"); + } } \ No newline at end of file From b1037c2f0e62989135e8cf27940c598d3140fc72 Mon Sep 17 00:00:00 2001 From: TheNewAutonomy Date: Sun, 8 Feb 2026 21:05:55 +0000 Subject: [PATCH 2/8] wallet: add canonical tx v1 encoding and signing domain Define wallet-facing standards: canonical 32-byte pubkey addresses, a v1 tx wire format (CTX1 + deterministic serialization), and a domain-separated signing payload bound to chain_id + genesis_hash. RPC and node now accept v1 wire txs, verify v1 signatures (with legacy fallback), and derive tx_id from the v1 bytes. CLI signs and submits v1 by default and docs include the interop spec + vectors. Co-authored-by: Cursor --- Cargo.lock | 2 + crates/catalyst-cli/src/commands.rs | 88 ++++++++-- crates/catalyst-cli/src/node.rs | 103 ++++++++++-- crates/catalyst-core/Cargo.toml | 4 + crates/catalyst-core/src/protocol.rs | 238 +++++++++++++++++++++++++++ crates/catalyst-rpc/src/lib.rs | 72 +++++--- docs/README.md | 4 + docs/builder-guide.md | 27 ++- docs/wallet-interop.md | 79 +++++++++ testdata/wallet/v1_vectors.json | 21 +++ 10 files changed, 582 insertions(+), 56 deletions(-) create mode 100644 docs/wallet-interop.md create mode 100644 testdata/wallet/v1_vectors.json diff --git a/Cargo.lock b/Cargo.lock index f597671..9aa90bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1209,6 +1209,8 @@ dependencies = [ "anyhow", "async-trait", "bincode", + "blake2", + "catalyst-utils", "futures", "hex", "serde", diff --git a/crates/catalyst-cli/src/commands.rs b/crates/catalyst-cli/src/commands.rs index cb9f678..9895796 100644 --- a/crates/catalyst-cli/src/commands.rs +++ b/crates/catalyst-cli/src/commands.rs @@ -22,6 +22,26 @@ fn parse_hex_32(s: &str) -> anyhow::Result<[u8; 32]> { Ok(out) } +fn parse_u64_hex(s: &str) -> Option { + let s = s.trim().strip_prefix("0x").unwrap_or(s); + u64::from_str_radix(s, 16).ok() +} + +async fn fetch_chain_domain(rpc_url: &str) -> Option<(u64, [u8; 32])> { + let client = HttpClientBuilder::default().build(rpc_url).ok()?; + let chain_id_hex: String = client + .request("catalyst_chainId", jsonrpsee::rpc_params![]) + .await + .ok()?; + let genesis_hex: String = client + .request("catalyst_genesisHash", jsonrpsee::rpc_params![]) + .await + .ok()?; + let chain_id = parse_u64_hex(&chain_id_hex)?; + let genesis_hash = parse_hex_32(&genesis_hex).ok().unwrap_or([0u8; 32]); + Some((chain_id, genesis_hash)) +} + fn hash_leaf(key: &[u8], value: &[u8]) -> [u8; 32] { use sha2::Digest; let mut h = sha2::Sha256::new(); @@ -262,8 +282,13 @@ pub async fn send_transaction( }; tx.core.fees = catalyst_core::protocol::min_fee(&tx); - // Real signature: Schnorr over canonical payload. - let payload = tx.signing_payload().map_err(anyhow::Error::msg)?; + // Real signature: prefer v1 domain-separated payload; fall back to legacy. + let payload = if let Some((chain_id, genesis_hash)) = fetch_chain_domain(rpc_url).await { + tx.signing_payload_v1(chain_id, genesis_hash) + .map_err(anyhow::Error::msg)? + } else { + tx.signing_payload().map_err(anyhow::Error::msg)? + }; let mut rng = rand::rngs::OsRng; let scheme = SignatureScheme::new(); let sig: Signature = scheme.sign(&mut rng, &sk, &payload)?; @@ -278,8 +303,13 @@ pub async fn send_transaction( } } - let bytes = bincode::serialize(&tx)?; - let hex_data = format!("0x{}", hex::encode(bytes)); + let hex_data = match catalyst_core::protocol::encode_wire_tx_v1(&tx) { + Ok(bytes) => format!("0x{}", hex::encode(bytes)), + Err(_) => { + let bytes = bincode::serialize(&tx)?; + format!("0x{}", hex::encode(bytes)) + } + }; let tx_id: String = client .request("catalyst_sendRawTransaction", jsonrpsee::rpc_params![hex_data]) @@ -323,15 +353,25 @@ pub async fn register_worker(key_file: &Path, rpc_url: &str) -> Result<()> { }; tx.core.fees = catalyst_core::protocol::min_fee(&tx); - // Real signature: Schnorr over canonical payload. - let payload = tx.signing_payload().map_err(anyhow::Error::msg)?; + // Real signature: prefer v1 domain-separated payload; fall back to legacy. + let payload = if let Some((chain_id, genesis_hash)) = fetch_chain_domain(rpc_url).await { + tx.signing_payload_v1(chain_id, genesis_hash) + .map_err(anyhow::Error::msg)? + } else { + tx.signing_payload().map_err(anyhow::Error::msg)? + }; let mut rng = rand::rngs::OsRng; let scheme = SignatureScheme::new(); let sig: Signature = scheme.sign(&mut rng, &sk, &payload)?; tx.signature = catalyst_core::protocol::AggregatedSignature(sig.to_bytes().to_vec()); - let bytes = bincode::serialize(&tx)?; - let hex_data = format!("0x{}", hex::encode(bytes)); + let hex_data = match catalyst_core::protocol::encode_wire_tx_v1(&tx) { + Ok(bytes) => format!("0x{}", hex::encode(bytes)), + Err(_) => { + let bytes = bincode::serialize(&tx)?; + format!("0x{}", hex::encode(bytes)) + } + }; let tx_id: String = client .request("catalyst_sendRawTransaction", jsonrpsee::rpc_params![hex_data]) .await?; @@ -404,14 +444,24 @@ pub async fn deploy_contract( }; tx.core.fees = catalyst_core::protocol::min_fee(&tx); - let payload = tx.signing_payload().map_err(anyhow::Error::msg)?; + let payload = if let Some((chain_id, genesis_hash)) = fetch_chain_domain(rpc_url).await { + tx.signing_payload_v1(chain_id, genesis_hash) + .map_err(anyhow::Error::msg)? + } else { + tx.signing_payload().map_err(anyhow::Error::msg)? + }; let mut rng = rand::rngs::OsRng; let scheme = SignatureScheme::new(); let sig: Signature = scheme.sign(&mut rng, &sk, &payload)?; tx.signature = catalyst_core::protocol::AggregatedSignature(sig.to_bytes().to_vec()); - let bytes = bincode::serialize(&tx)?; - let hex_data = format!("0x{}", hex::encode(bytes)); + let hex_data = match catalyst_core::protocol::encode_wire_tx_v1(&tx) { + Ok(bytes) => format!("0x{}", hex::encode(bytes)), + Err(_) => { + let bytes = bincode::serialize(&tx)?; + format!("0x{}", hex::encode(bytes)) + } + }; let tx_id: String = client .request("catalyst_sendRawTransaction", jsonrpsee::rpc_params![hex_data]) .await?; @@ -484,14 +534,24 @@ pub async fn call_contract( }; tx.core.fees = catalyst_core::protocol::min_fee(&tx); - let payload = tx.signing_payload().map_err(anyhow::Error::msg)?; + let payload = if let Some((chain_id, genesis_hash)) = fetch_chain_domain(rpc_url).await { + tx.signing_payload_v1(chain_id, genesis_hash) + .map_err(anyhow::Error::msg)? + } else { + tx.signing_payload().map_err(anyhow::Error::msg)? + }; let mut rng = rand::rngs::OsRng; let scheme = SignatureScheme::new(); let sig: Signature = scheme.sign(&mut rng, &sk, &payload)?; tx.signature = catalyst_core::protocol::AggregatedSignature(sig.to_bytes().to_vec()); - let bytes = bincode::serialize(&tx)?; - let hex_data = format!("0x{}", hex::encode(bytes)); + let hex_data = match catalyst_core::protocol::encode_wire_tx_v1(&tx) { + Ok(bytes) => format!("0x{}", hex::encode(bytes)), + Err(_) => { + let bytes = bincode::serialize(&tx)?; + format!("0x{}", hex::encode(bytes)) + } + }; let tx_id: String = client .request("catalyst_sendRawTransaction", jsonrpsee::rpc_params![hex_data]) .await?; diff --git a/crates/catalyst-cli/src/node.rs b/crates/catalyst-cli/src/node.rs index 8e24985..69df782 100644 --- a/crates/catalyst-cli/src/node.rs +++ b/crates/catalyst-cli/src/node.rs @@ -671,6 +671,86 @@ fn verify_protocol_tx_signature(tx: &catalyst_core::protocol::Transaction) -> bo SignatureScheme::new().verify(&payload, &sig, &sender_pk).unwrap_or(false) } +async fn load_genesis_hash_32(store: &StorageManager) -> [u8; 32] { + let Some(bytes) = store + .get_metadata(META_PROTOCOL_GENESIS_HASH) + .await + .ok() + .flatten() + else { + return [0u8; 32]; + }; + if bytes.len() != 32 { + return [0u8; 32]; + } + let mut out = [0u8; 32]; + out.copy_from_slice(&bytes); + out +} + +async fn verify_protocol_tx_signature_with_domain(store: &StorageManager, tx: &catalyst_core::protocol::Transaction) -> bool { + use catalyst_crypto::signatures::SignatureScheme; + + // Determine sender: + // - transfers: the (single) pubkey with negative NonConfidential amount + // - worker registration: entry[0].public_key + // - smart contract: entry[0].public_key + let sender_pk_bytes: [u8; 32] = match tx.core.tx_type { + catalyst_core::protocol::TransactionType::WorkerRegistration => { + let Some(e0) = tx.core.entries.get(0) else { return false }; + e0.public_key + } + catalyst_core::protocol::TransactionType::SmartContract => { + let Some(e0) = tx.core.entries.get(0) else { return false }; + e0.public_key + } + _ => { + let mut sender: Option<[u8; 32]> = None; + for e in &tx.core.entries { + if let catalyst_core::protocol::EntryAmount::NonConfidential(v) = e.amount { + if v < 0 { + match sender { + None => sender = Some(e.public_key), + Some(pk) if pk == e.public_key => {} + Some(_) => return false, // multi-sender not supported yet + } + } + } + } + let Some(sender) = sender else { return false }; + sender + } + }; + + if tx.signature.0.len() != 64 { + return false; + } + let mut sig_bytes = [0u8; 64]; + sig_bytes.copy_from_slice(&tx.signature.0); + let sig = match catalyst_crypto::signatures::Signature::from_bytes(sig_bytes) { + Ok(s) => s, + Err(_) => return false, + }; + let sender_pk = match catalyst_crypto::PublicKey::from_bytes(sender_pk_bytes) { + Ok(pk) => pk, + Err(_) => return false, + }; + + // Prefer v1 domain-separated payload; fall back to legacy for backward compatibility. + let chain_id = load_chain_id_u64(store).await; + let genesis_hash = load_genesis_hash_32(store).await; + if let Ok(p) = tx.signing_payload_v1(chain_id, genesis_hash) { + if SignatureScheme::new().verify(&p, &sig, &sender_pk).unwrap_or(false) { + return true; + } + } + let legacy = match tx.signing_payload() { + Ok(p) => p, + Err(_) => return false, + }; + SignatureScheme::new().verify(&legacy, &sig, &sender_pk).unwrap_or(false) +} + async fn get_balance_i64(store: &StorageManager, pubkey: &[u8; 32]) -> i64 { let k = balance_key_for_pubkey(pubkey); store @@ -978,14 +1058,7 @@ async fn validate_and_select_protocol_txs_for_construction( } fn mempool_txid(tx: &catalyst_core::protocol::Transaction) -> Option<[u8; 32]> { - let bytes = bincode::serialize(tx).ok()?; - let mut hasher = blake2::Blake2b512::new(); - use blake2::Digest; - hasher.update(&bytes); - let result = hasher.finalize(); - let mut out = [0u8; 32]; - out.copy_from_slice(&result[..32]); - Some(out) + catalyst_core::protocol::tx_id_v1(tx).ok() } fn mempool_tx_key(txid: &[u8; 32]) -> String { @@ -1147,7 +1220,7 @@ async fn prune_persisted_mempool(store: &StorageManager) { }; // Drop invalid or already-applied txs. - if tx.validate_basic().is_err() || !verify_protocol_tx_signature(&tx) { + if tx.validate_basic().is_err() || !verify_protocol_tx_signature_with_domain(store, &tx).await { delete_persisted_mempool_tx(store, &txid).await; continue; } @@ -1181,7 +1254,7 @@ async fn rehydrate_mempool_from_storage(store: &StorageManager, mempool: &tokio: }; // Basic gate: must still validate/signature-check. - if tx.validate_basic().is_err() || !verify_protocol_tx_signature(&tx) { + if tx.validate_basic().is_err() || !verify_protocol_tx_signature_with_domain(store, &tx).await { delete_persisted_mempool_tx(store, &txid).await; continue; } @@ -1236,7 +1309,7 @@ async fn rebroadcast_persisted_mempool( continue; }; // Only broadcast txs that still look valid (cheap checks). - if tx.validate_basic().is_err() || !verify_protocol_tx_signature(&tx) { + if tx.validate_basic().is_err() || !verify_protocol_tx_signature_with_domain(store, &tx).await { continue; } if tx.core.lock_time as u64 > now_secs { @@ -1870,7 +1943,7 @@ impl CatalystNode { let now = current_timestamp_ms(); let now_secs = now / 1000; if let Ok(msg) = ProtocolTxGossip::new(tx, now) { - if !verify_protocol_tx_signature(&msg.tx) { + if !verify_protocol_tx_signature_with_domain(storage.as_ref(), &msg.tx).await { continue; } // Nonce check (single-sender only). @@ -2059,11 +2132,11 @@ impl CatalystNode { // Prefer protocol-shaped txs; fall back to legacy TxGossip. let now_secs = current_timestamp_ms() / 1000; if let Ok(tx) = envelope.extract_message::() { - if !verify_protocol_tx_signature(&tx.tx) { - continue; - } // Nonce check (single-sender only, requires storage). if let Some(store) = &storage { + if !verify_protocol_tx_signature_with_domain(store.as_ref(), &tx.tx).await { + continue; + } let Some(sender_pk) = tx.sender_pubkey() else { continue }; let pending_max = { let mp = mempool.read().await; diff --git a/crates/catalyst-core/Cargo.toml b/crates/catalyst-core/Cargo.toml index de72239..d621032 100644 --- a/crates/catalyst-core/Cargo.toml +++ b/crates/catalyst-core/Cargo.toml @@ -16,6 +16,9 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" bincode = "1.3" +# Canonical protocol serialization +catalyst-utils = { path = "../catalyst-utils" } + # Error handling thiserror = "1.0" anyhow = "1.0" @@ -26,6 +29,7 @@ uuid = { version = "1.0", features = ["v4", "serde"] } # Cryptographic utilities hex = "0.4" sha2 = "0.10" +blake2 = "0.10" # Utilities tracing = "0.1" diff --git a/crates/catalyst-core/src/protocol.rs b/crates/catalyst-core/src/protocol.rs index ab2ec17..0d43104 100644 --- a/crates/catalyst-core/src/protocol.rs +++ b/crates/catalyst-core/src/protocol.rs @@ -12,6 +12,14 @@ use serde::{Deserialize, Serialize}; use crate::types::{BlockHash, NodeId, Timestamp}; +use catalyst_utils::{ + CatalystDeserialize, CatalystSerialize, impl_catalyst_serialize, + error::{CatalystError, CatalystResult}, +}; + +pub const TX_WIRE_MAGIC_V1: [u8; 4] = *b"CTX1"; +pub const TX_SIG_DOMAIN_V1: &[u8] = b"CATALYST_SIG_V1"; + /// Canonical signing payload for a transaction: `bincode(TransactionCore) || timestamp_le`. /// /// This is a temporary “real signature validation” step while aggregated signatures and @@ -22,6 +30,31 @@ pub fn transaction_signing_payload(core: &TransactionCore, timestamp: u64) -> Re Ok(out) } +/// V1 signing payload for external wallets: deterministic and domain-separated. +/// +/// Format: +/// - domain = "CATALYST_SIG_V1" +/// - chain_id (u64 le) +/// - genesis_hash (32 bytes) +/// - core (CatalystSerialize) +/// - timestamp (u64 le) +pub fn transaction_signing_payload_v1( + core: &TransactionCore, + timestamp: u64, + chain_id: u64, + genesis_hash: [u8; 32], +) -> Result, String> { + let mut out = Vec::new(); + out.extend_from_slice(TX_SIG_DOMAIN_V1); + out.extend_from_slice(&chain_id.to_le_bytes()); + out.extend_from_slice(&genesis_hash); + let core_bytes = CatalystSerialize::serialize(core) + .map_err(|e| format!("serialize core v1: {e}"))?; + out.extend_from_slice(&core_bytes); + out.extend_from_slice(×tamp.to_le_bytes()); + Ok(out) +} + /// Ledger cycle number. pub type CycleNumber = u64; @@ -46,6 +79,54 @@ pub enum TransactionType { WorkerRegistration, } +impl CatalystSerialize for TransactionType { + fn serialize(&self) -> CatalystResult> { + let mut out = Vec::with_capacity(1); + self.serialize_to(&mut out)?; + Ok(out) + } + + fn serialize_to(&self, writer: &mut W) -> CatalystResult<()> { + let tag: u8 = match self { + TransactionType::NonConfidentialTransfer => 0, + TransactionType::ConfidentialTransfer => 1, + TransactionType::DataStorageRequest => 2, + TransactionType::DataStorageRetrieve => 3, + TransactionType::SmartContract => 4, + TransactionType::WorkerRegistration => 5, + }; + tag.serialize_to(writer) + } + + fn serialized_size(&self) -> usize { + 1 + } +} + +impl CatalystDeserialize for TransactionType { + fn deserialize(data: &[u8]) -> CatalystResult { + let mut cursor = std::io::Cursor::new(data); + Self::deserialize_from(&mut cursor) + } + + fn deserialize_from(reader: &mut R) -> CatalystResult { + let tag = u8::deserialize_from(reader)?; + Ok(match tag { + 0 => TransactionType::NonConfidentialTransfer, + 1 => TransactionType::ConfidentialTransfer, + 2 => TransactionType::DataStorageRequest, + 3 => TransactionType::DataStorageRetrieve, + 4 => TransactionType::SmartContract, + 5 => TransactionType::WorkerRegistration, + _ => { + return Err(CatalystError::Serialization(format!( + "invalid TransactionType tag: {tag}" + ))) + } + }) + } +} + /// Amount component of an entry (§4.3). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub enum EntryAmount { @@ -61,6 +142,65 @@ pub enum EntryAmount { }, } +impl CatalystSerialize for EntryAmount { + fn serialize(&self) -> CatalystResult> { + let mut out = Vec::with_capacity(self.serialized_size()); + self.serialize_to(&mut out)?; + Ok(out) + } + + fn serialize_to(&self, writer: &mut W) -> CatalystResult<()> { + match self { + EntryAmount::NonConfidential(v) => { + 0u8.serialize_to(writer)?; + v.serialize_to(writer)?; + } + EntryAmount::Confidential { + commitment, + range_proof, + } => { + 1u8.serialize_to(writer)?; + commitment.serialize_to(writer)?; + range_proof.serialize_to(writer)?; + } + } + Ok(()) + } + + fn serialized_size(&self) -> usize { + match self { + EntryAmount::NonConfidential(_) => 1 + 8, + EntryAmount::Confidential { + commitment: _, + range_proof, + } => 1 + 32 + range_proof.serialized_size(), + } + } +} + +impl CatalystDeserialize for EntryAmount { + fn deserialize(data: &[u8]) -> CatalystResult { + let mut cursor = std::io::Cursor::new(data); + Self::deserialize_from(&mut cursor) + } + + fn deserialize_from(reader: &mut R) -> CatalystResult { + let tag = u8::deserialize_from(reader)?; + Ok(match tag { + 0 => EntryAmount::NonConfidential(i64::deserialize_from(reader)?), + 1 => EntryAmount::Confidential { + commitment: <[u8; 32]>::deserialize_from(reader)?, + range_proof: Vec::::deserialize_from(reader)?, + }, + _ => { + return Err(CatalystError::Serialization(format!( + "invalid EntryAmount tag: {tag}" + ))) + } + }) + } +} + /// Transaction entry (§4.3): public key + amount. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TransactionEntry { @@ -69,6 +209,8 @@ pub struct TransactionEntry { pub amount: EntryAmount, } +impl_catalyst_serialize!(TransactionEntry, public_key, amount); + /// Aggregated signature (§4.4). Opaque 64-byte signature for now. /// /// NOTE: We store this as `Vec` because `serde` doesn't implement (de)serialization @@ -76,6 +218,30 @@ pub struct TransactionEntry { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AggregatedSignature(pub Vec); +impl CatalystSerialize for AggregatedSignature { + fn serialize(&self) -> CatalystResult> { + CatalystSerialize::serialize(&self.0) + } + + fn serialize_to(&self, writer: &mut W) -> CatalystResult<()> { + self.0.serialize_to(writer) + } + + fn serialized_size(&self) -> usize { + self.0.serialized_size() + } +} + +impl CatalystDeserialize for AggregatedSignature { + fn deserialize(data: &[u8]) -> CatalystResult { + Ok(Self( as CatalystDeserialize>::deserialize(data)?)) + } + + fn deserialize_from(reader: &mut R) -> CatalystResult { + Ok(Self(Vec::::deserialize_from(reader)?)) + } +} + /// Transaction core message fields (§4.2) excluding signature and timestamp. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TransactionCore { @@ -91,6 +257,8 @@ pub struct TransactionCore { pub data: Vec, } +impl_catalyst_serialize!(TransactionCore, tx_type, entries, nonce, lock_time, fees, data); + /// Catalyst transaction (§4.2): core + aggregated signature + timestamp. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Transaction { @@ -100,6 +268,36 @@ pub struct Transaction { pub timestamp: u64, } +impl_catalyst_serialize!(Transaction, core, signature, timestamp); + +pub fn encode_wire_tx_v1(tx: &Transaction) -> Result, String> { + let mut out = Vec::new(); + out.extend_from_slice(&TX_WIRE_MAGIC_V1); + let body = CatalystSerialize::serialize(tx).map_err(|e| format!("serialize tx v1: {e}"))?; + out.extend_from_slice(&body); + Ok(out) +} + +pub fn decode_wire_tx_any(bytes: &[u8]) -> Result { + if bytes.len() >= 4 && bytes[0..4] == TX_WIRE_MAGIC_V1 { + return ::deserialize(&bytes[4..]) + .map_err(|e| format!("decode v1 tx: {e}")); + } + // Legacy: bincode(Transaction) + bincode::deserialize::(bytes).map_err(|e| format!("decode legacy tx: {e}")) +} + +pub fn tx_id_v1(tx: &Transaction) -> Result<[u8; 32], String> { + use blake2::{Blake2b512, Digest}; + let bytes = encode_wire_tx_v1(tx)?; + let mut h = Blake2b512::new(); + h.update(&bytes); + let out = h.finalize(); + let mut id = [0u8; 32]; + id.copy_from_slice(&out[..32]); + Ok(id) +} + /// Deterministic minimum fee schedule for the current implementation. /// /// This is intentionally simple and “testnet-ready”: @@ -207,6 +405,10 @@ impl Transaction { pub fn signing_payload(&self) -> Result, String> { transaction_signing_payload(&self.core, self.timestamp) } + + pub fn signing_payload_v1(&self, chain_id: u64, genesis_hash: [u8; 32]) -> Result, String> { + transaction_signing_payload_v1(&self.core, self.timestamp, chain_id, genesis_hash) + } } /// Partial ledger state update `ΔLn,j` (§5.2.1): sorted entries + tx-signature hash tree root. @@ -332,6 +534,42 @@ mod tests { let all = select_producers_for_next_cycle(&workers, &seed, 999); assert_eq!(all.len(), workers.len()); } + + #[test] + fn wallet_tx_v1_wire_roundtrip_and_txid_is_deterministic() { + let from = [1u8; 32]; + let to = [2u8; 32]; + let tx = Transaction { + core: TransactionCore { + tx_type: TransactionType::NonConfidentialTransfer, + entries: vec![ + TransactionEntry { + public_key: from, + amount: EntryAmount::NonConfidential(-7), + }, + TransactionEntry { + public_key: to, + amount: EntryAmount::NonConfidential(7), + }, + ], + nonce: 1, + lock_time: 0, + fees: 3, + data: Vec::new(), + }, + signature: AggregatedSignature(vec![0u8; 64]), + timestamp: 1_700_000_000_000u64, + }; + + let wire = encode_wire_tx_v1(&tx).unwrap(); + assert!(wire.starts_with(&TX_WIRE_MAGIC_V1)); + let decoded = decode_wire_tx_any(&wire).unwrap(); + assert_eq!(decoded, tx); + + let txid1 = tx_id_v1(&tx).unwrap(); + let txid2 = tx_id_v1(&tx).unwrap(); + assert_eq!(txid1, txid2); + } } fn xor32(a: [u8; 32], b: [u8; 32]) -> [u8; 32] { diff --git a/crates/catalyst-rpc/src/lib.rs b/crates/catalyst-rpc/src/lib.rs index baf0ac0..ecc981a 100644 --- a/crates/catalyst-rpc/src/lib.rs +++ b/crates/catalyst-rpc/src/lib.rs @@ -438,7 +438,11 @@ fn evm_storage_key(addr20: &[u8; 20], slot32: &[u8; 32]) -> Vec { k } -fn verify_tx_signature(tx: &catalyst_core::protocol::Transaction) -> bool { +fn verify_tx_signature_with_domain( + tx: &catalyst_core::protocol::Transaction, + chain_id: u64, + genesis_hash: [u8; 32], +) -> bool { use catalyst_crypto::signatures::SignatureScheme; // Determine sender: @@ -484,11 +488,18 @@ fn verify_tx_signature(tx: &catalyst_core::protocol::Transaction) -> bool { Ok(pk) => pk, Err(_) => return false, }; - let payload = match tx.signing_payload() { + + // Prefer v1 domain-separated payload; fall back to legacy. + if let Ok(p) = tx.signing_payload_v1(chain_id, genesis_hash) { + if SignatureScheme::new().verify(&p, &sig, &sender_pk).unwrap_or(false) { + return true; + } + } + let legacy = match tx.signing_payload() { Ok(p) => p, Err(_) => return false, }; - SignatureScheme::new().verify(&payload, &sig, &sender_pk).unwrap_or(false) + SignatureScheme::new().verify(&legacy, &sig, &sender_pk).unwrap_or(false) } fn tx_sender_pubkey(tx: &catalyst_core::protocol::Transaction) -> Option<[u8; 32]> { @@ -874,11 +885,11 @@ impl CatalystRpcServer for CatalystRpcImpl { } async fn send_raw_transaction(&self, _data: String) -> RpcResult { - // Accept hex-encoded bincode(Transaction) for now (dev transport). + // Accept hex-encoded legacy bincode(Transaction) OR v1 canonical wire tx (CTX1...). let bytes = parse_hex_bytes(&_data).map_err(ErrorObjectOwned::from)?; - let tx: catalyst_core::protocol::Transaction = bincode::deserialize(&bytes).map_err( - |e: bincode::Error| ErrorObjectOwned::from(RpcServerError::InvalidParams(e.to_string())), - )?; + let tx: catalyst_core::protocol::Transaction = + catalyst_core::protocol::decode_wire_tx_any(&bytes) + .map_err(|e| ErrorObjectOwned::from(RpcServerError::InvalidParams(e)))?; // Basic format + fee floor checks. tx.validate_basic() @@ -891,7 +902,31 @@ impl CatalystRpcServer for CatalystRpcImpl { )))); } - if !verify_tx_signature(&tx) { + let chain_id = self + .storage + .get_metadata(META_PROTOCOL_CHAIN_ID) + .await + .ok() + .flatten() + .map(|b| decode_u64_le(&b)) + .unwrap_or(31337); + let genesis_hash = self + .storage + .get_metadata(META_PROTOCOL_GENESIS_HASH) + .await + .ok() + .flatten() + .and_then(|b| { + if b.len() != 32 { + return None; + } + let mut out = [0u8; 32]; + out.copy_from_slice(&b); + Some(out) + }) + .unwrap_or([0u8; 32]); + + if !verify_tx_signature_with_domain(&tx, chain_id, genesis_hash) { return Err(ErrorObjectOwned::from(RpcServerError::InvalidParams( "Invalid transaction signature".to_string(), ))); @@ -917,8 +952,9 @@ impl CatalystRpcServer for CatalystRpcImpl { } } - // tx_id = blake2b512(bincode(tx))[..32] (must match node/mempool). - let txid = tx_id_blake2b_32(&bytes); + // tx_id = blake2b512(CTX1 || canonical_tx_bytes)[..32] + let txid = catalyst_core::protocol::tx_id_v1(&tx) + .map_err(|e| ErrorObjectOwned::from(RpcServerError::Server(e)))?; let tx_id = format!("0x{}", hex::encode(txid)); // Persist tx raw + meta so clients can poll receipt immediately (best-effort). @@ -942,7 +978,11 @@ impl CatalystRpcServer for CatalystRpcImpl { evm_gas_used: None, evm_return: None, }; - let _ = self.storage.set_metadata(&tx_raw_key(&txid), &bytes).await; + // Persist in internal format (bincode) for node/RPC decoding. + let ser = bincode::serialize(&tx).map_err(|e: bincode::Error| { + ErrorObjectOwned::from(RpcServerError::Server(e.to_string())) + })?; + let _ = self.storage.set_metadata(&tx_raw_key(&txid), &ser).await; if let Ok(mbytes) = bincode::serialize(&meta) { let _ = self.storage.set_metadata(&tx_meta_key(&txid), &mbytes).await; } @@ -1220,16 +1260,6 @@ fn tx_participants(tx: &catalyst_core::protocol::Transaction) -> Vec<[u8; 32]> { } } -fn tx_id_blake2b_32(bytes: &[u8]) -> [u8; 32] { - use blake2::{Blake2b512, Digest}; - let mut h = Blake2b512::new(); - h.update(bytes); - let out = h.finalize(); - let mut id = [0u8; 32]; - id.copy_from_slice(&out[..32]); - id -} - fn tx_raw_key(txid: &[u8; 32]) -> String { format!("tx:raw:{}", hex::encode(txid)) } diff --git a/docs/README.md b/docs/README.md index 8e038cc..62f5da9 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,10 @@ These docs are written against the current `catalyst-node-rust` implementation ( - RPC methods currently implemented - Contract/runtime notes and current limitations +- **Wallets / Integrators**: [`wallet-interop.md`](./wallet-interop.md) + - Canonical address format + - v1 transaction signing payload + wire encoding + ## Important implementation notes (current state) - **Consensus traffic is P2P on `30333/tcp`**, not RPC. RPC is only for client interaction. diff --git a/docs/builder-guide.md b/docs/builder-guide.md index e527092..66d8bb3 100644 --- a/docs/builder-guide.md +++ b/docs/builder-guide.md @@ -16,16 +16,29 @@ Core: - `catalyst_peerCount` - `catalyst_head` - `catalyst_blockNumber` (mapped to applied cycle) +- `catalyst_chainId` +- `catalyst_networkId` +- `catalyst_genesisHash` Accounts: - `catalyst_getBalance` (returns decimal string) - `catalyst_getBalanceProof` (balance + state root + Merkle proof steps) - `catalyst_getNonce` +- `catalyst_getAccount` Tx submission: - `catalyst_sendRawTransaction` - - payload is **hex-encoded bincode(Transaction)** (dev transport) - - returns a `tx_id` (sha256 over tx bytes) +- payload is either: + - **v1 canonical wire tx**: `0x` + hex(`"CTX1"` + canonical `Transaction` bytes) + - legacy dev transport: `0x` + hex(`bincode(Transaction)`) +- returns a `tx_id` (32-byte hex): `blake2b512(CTX1||tx_bytes)[..32]` + +Tx query / receipts: +- `catalyst_getTransactionByHash` +- `catalyst_getTransactionReceipt` +- `catalyst_getTransactionInclusionProof` +- `catalyst_getBlocksByNumberRange` +- `catalyst_getTransactionsByAddress` EVM helpers (dev/test): - `catalyst_getCode` (20-byte address) @@ -34,12 +47,14 @@ EVM helpers (dev/test): ## Transaction model (current) -The CLI constructs `catalyst_core::protocol::Transaction` objects and submits them as bincode bytes. +The CLI constructs `catalyst_core::protocol::Transaction` objects and submits them as **v1 canonical wire bytes**. Important behavior: - RPC verifies Schnorr signature and rejects “nonce too low”. - Inclusion happens via validator consensus cycles; acceptance at RPC does not guarantee immediate inclusion. +For external wallets/integrators, see [`wallet-interop.md`](./wallet-interop.md) for the v1 encoding + signing payload. + ## Faucet model (dev/test) The faucet is a deterministic, pre-funded account initialized by the node: @@ -72,14 +87,14 @@ See [`user-guide.md`](./user-guide.md). Current behavior is scaffolding-oriented: - balances are integer values stored under `bal:` - transfers are validated with a simple “no negative balances” rule -- transaction `fees` are set to `0` in the CLI -- EVM executes with `gas_price = 0` (no fee charging yet) +- transaction `fees` are enforced via a deterministic minimum fee schedule +- EVM executes with `gas_price = 0` (fee charging is at the protocol layer for now) There is no implemented issuance schedule, staking rewards, inflation, or burn mechanics in this repo snapshot. ## Known limitations -- RPC does not currently return rich transaction/block data (e.g. `getTransactionByHash` returns `None`). +- Explorer-grade indexing is still best-effort (indexers should use block ranges + receipts). - RPC peer count reflects the RPC node’s current network connections; it is not always a full picture of the validator mesh. - Logging config includes a `file_path`, but the node primarily logs to stdout/stderr via `tracing` in the current setup. diff --git a/docs/wallet-interop.md b/docs/wallet-interop.md new file mode 100644 index 0000000..10ea139 --- /dev/null +++ b/docs/wallet-interop.md @@ -0,0 +1,79 @@ +## Wallet interoperability (standards + v1 encoding) + +This document defines **stable, wallet-facing conventions** for Catalyst V2 testnets. + +### Address format (canonical) + +- **Address bytes**: 32-byte public key. +- **Text form**: lowercase hex, **0x-prefixed**, 32 bytes (64 hex chars). + +Example: + +```text +0x26f4404048d8bef3a36cd38775f8139e031689cf1041d793687c3d906da98a76 +``` + +### Chain identity (domain separation) + +Wallets should bind signatures to the target chain using: + +- `chain_id`: returned by RPC `catalyst_chainId` (hex string, e.g. `"0x7a69"`). +- `genesis_hash`: returned by RPC `catalyst_genesisHash` (32-byte hex string). + +### Transaction signing payload (v1) + +To sign a `Transaction` (Schnorr), wallets sign the **v1 signing payload**: + +- `domain` = ASCII `"CATALYST_SIG_V1"` +- `chain_id` = u64 little-endian +- `genesis_hash` = 32 bytes +- `core_bytes` = canonical serialization of `TransactionCore` (see below) +- `timestamp` = u64 little-endian + +### Transaction wire encoding (v1) + +RPC accepts a **canonical wire encoding** for `catalyst_sendRawTransaction`: + +- 4-byte magic prefix: ASCII `"CTX1"` +- followed by canonical serialization of `Transaction` (see below) + +### Transaction hash / tx_id (v1) + +The tx hash returned by the node is: + +\[ +\text{tx\_id} = \text{blake2b512}(\text{"CTX1"} \,\|\, \text{tx\_bytes})[0..32] +\] + +Returned in RPC as a 0x-prefixed 32-byte hex string. + +### Canonical serialization rules (v1) + +All integers are **little-endian**. + +`TransactionType` is encoded as a single u8 tag: + +- `0`: NonConfidentialTransfer +- `1`: ConfidentialTransfer +- `2`: DataStorageRequest +- `3`: DataStorageRetrieve +- `4`: SmartContract +- `5`: WorkerRegistration + +`EntryAmount` is encoded as: + +- `0x00` + i64 (le): NonConfidential +- `0x01` + 32-byte commitment + (len:u32 le + range_proof bytes): Confidential + +Vectors/bytes are encoded as: + +- `len:u32 le` followed by `len` items/bytes. + +### Legacy compatibility + +For now, the node/RPC also accepts the legacy dev transport: + +- hex-encoded `bincode(Transaction)` + +but wallets **should** use v1 wire encoding + v1 signing payload going forward. + diff --git a/testdata/wallet/v1_vectors.json b/testdata/wallet/v1_vectors.json new file mode 100644 index 0000000..7962d8c --- /dev/null +++ b/testdata/wallet/v1_vectors.json @@ -0,0 +1,21 @@ +{ + "note": "Deterministic wallet-facing test vectors for Catalyst TX v1 encoding/signing.", + "chain_id_hex": "0x7a69", + "genesis_hash_hex": "0x0000000000000000000000000000000000000000000000000000000000000000", + "from_pubkey_hex": "0x0101010101010101010101010101010101010101010101010101010101010101", + "to_pubkey_hex": "0x0202020202020202020202020202020202020202020202020202020202020202", + "tx": { + "tx_type": "NonConfidentialTransfer", + "nonce": 1, + "lock_time": 0, + "fees": 3, + "entries": [ + { "public_key_hex": "0x0101010101010101010101010101010101010101010101010101010101010101", "amount": -7 }, + { "public_key_hex": "0x0202020202020202020202020202020202020202020202020202020202020202", "amount": 7 } + ], + "data_hex": "0x", + "timestamp": 1700000000000, + "signature_hex": "0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } +} + From dcf5e5e7d4728aa819c7b54969a4de6d219935c1 Mon Sep 17 00:00:00 2001 From: TheNewAutonomy Date: Sun, 8 Feb 2026 21:36:56 +0000 Subject: [PATCH 3/8] anti-spam: rate limit RPC and cap mempool per sender Add global + per-sender RPC throttling (esp. sendRawTransaction and heavy listing calls) plus a per-sender mempool cap to prevent single-key spam. Also rebroadcast newly-seen txs on receipt to improve multi-hop propagation in WAN topologies. Co-authored-by: Cursor --- crates/catalyst-cli/src/node.rs | 19 ++++++-- crates/catalyst-cli/src/tx.rs | 28 +++++++++++ crates/catalyst-rpc/src/lib.rs | 84 ++++++++++++++++++++++++++++++++- 3 files changed, 127 insertions(+), 4 deletions(-) diff --git a/crates/catalyst-cli/src/node.rs b/crates/catalyst-cli/src/node.rs index 69df782..140c11e 100644 --- a/crates/catalyst-cli/src/node.rs +++ b/crates/catalyst-cli/src/node.rs @@ -1928,7 +1928,14 @@ impl CatalystNode { let bind: std::net::SocketAddr = format!("{}:{}", self.config.rpc.address, self.config.rpc.port) .parse() .map_err(|e| anyhow::anyhow!("invalid rpc bind addr: {e}"))?; - let handle = catalyst_rpc::start_rpc_http(bind, store.clone(), Some(network.clone()), Some(rpc_tx)) + let handle = catalyst_rpc::start_rpc_http( + bind, + store.clone(), + Some(network.clone()), + Some(rpc_tx), + self.config.rpc.rate_limit, + self.config.rpc.rate_limit, + ) .await .map_err(|e| anyhow::anyhow!("rpc start failed: {e}"))?; info!("RPC server listening on http://{}", bind); @@ -2155,15 +2162,21 @@ impl CatalystNode { let mut mp = mempool.write().await; if mp.insert_protocol(tx.clone(), now_secs) { persist_mempool_tx(store.as_ref(), &tx.tx).await; + // Help multi-hop propagation on WAN: rebroadcast when first seen. + let _ = net.broadcast_envelope(&envelope).await; } } } else { let mut mp = mempool.write().await; - let _ = mp.insert_protocol(tx, now_secs); + if mp.insert_protocol(tx, now_secs) { + let _ = net.broadcast_envelope(&envelope).await; + } } } else if let Ok(tx) = envelope.extract_message::() { let mut mp = mempool.write().await; - let _ = mp.insert(tx); + if mp.insert(tx) { + let _ = net.broadcast_envelope(&envelope).await; + } } } else if envelope.message_type == MessageType::TransactionBatch { // Prefer protocol tx batches (full signed txs), fallback to legacy entry batch. diff --git a/crates/catalyst-cli/src/tx.rs b/crates/catalyst-cli/src/tx.rs index 8b8b075..585a4dc 100644 --- a/crates/catalyst-cli/src/tx.rs +++ b/crates/catalyst-cli/src/tx.rs @@ -393,14 +393,24 @@ pub struct Mempool { by_id: HashMap, ttl: Duration, max_txs: usize, + max_per_sender: usize, + drops_full: u64, + drops_per_sender: u64, } impl Mempool { pub fn new(ttl: Duration, max_txs: usize) -> Self { + let max_per_sender = std::env::var("CATALYST_MEMPOOL_MAX_PER_SENDER") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or(64); Self { by_id: HashMap::new(), ttl, max_txs: max_txs.max(1), + max_per_sender: max_per_sender.max(1), + drops_full: 0, + drops_per_sender: 0, } } @@ -434,12 +444,26 @@ impl Mempool { self.evict_expired(); if self.by_id.len() >= self.max_txs { + self.drops_full = self.drops_full.saturating_add(1); return false; } if self.by_id.contains_key(&tx.tx_id) { return false; } + if let Some(sender) = tx.sender_pubkey() { + let mut count = 0usize; + for item in self.by_id.values() { + if item.sender_pubkey == Some(sender) { + count = count.saturating_add(1); + } + } + if count >= self.max_per_sender { + self.drops_per_sender = self.drops_per_sender.saturating_add(1); + return false; + } + } + self.by_id.insert( tx.tx_id, MempoolItem { @@ -518,5 +542,9 @@ impl Mempool { pub fn len(&self) -> usize { self.by_id.len() } + + pub fn drop_stats(&self) -> (u64, u64) { + (self.drops_full, self.drops_per_sender) + } } diff --git a/crates/catalyst-rpc/src/lib.rs b/crates/catalyst-rpc/src/lib.rs index ecc981a..51b8809 100644 --- a/crates/catalyst-rpc/src/lib.rs +++ b/crates/catalyst-rpc/src/lib.rs @@ -13,6 +13,8 @@ use jsonrpsee::{ use serde::{Deserialize, Serialize}; use std::net::SocketAddr; use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; +use tokio::sync::Mutex; use thiserror::Error; use tokio::sync::mpsc; @@ -20,6 +22,8 @@ const META_PROTOCOL_CHAIN_ID: &str = "protocol:chain_id"; const META_PROTOCOL_NETWORK_ID: &str = "protocol:network_id"; const META_PROTOCOL_GENESIS_HASH: &str = "protocol:genesis_hash"; +const RPC_ERR_RATE_LIMITED_CODE: i32 = -32029; + // Note: The initial scaffold referenced sub-modules (`methods`, `server`, `types`) that // aren't present yet. Keeping the RPC types and traits in this file for now so the // crate builds successfully. @@ -38,6 +42,8 @@ pub enum RpcServerError { AccountNotFound(String), #[error("Network error: {0}")] Network(String), + #[error("Rate limited")] + RateLimited, } impl From for ErrorObjectOwned { @@ -50,6 +56,9 @@ impl From for ErrorObjectOwned { RpcServerError::InvalidParams(msg) => { ErrorObjectOwned::owned(INVALID_PARAMS_CODE, msg, None::<()>) } + RpcServerError::RateLimited => { + ErrorObjectOwned::owned(RPC_ERR_RATE_LIMITED_CODE, err.to_string(), None::<()>) + } RpcServerError::TransactionNotFound(_) | RpcServerError::BlockNotFound(_) | RpcServerError::AccountNotFound(_) => { @@ -529,6 +538,49 @@ pub struct CatalystRpcImpl { storage: Arc, network: Option>, tx_submit: Option>, + limiter: Arc, + _global_rps: u32, + sender_rps: u32, +} + +struct RpcLimiters { + global: Mutex, + by_sender: Mutex>, + drops_global: AtomicU64, + drops_sender: AtomicU64, +} + +impl RpcLimiters { + fn new(global_rps: u32, sender_rps: u32) -> Self { + let global_rps = global_rps.max(1); + Self { + global: Mutex::new(catalyst_utils::utils::RateLimiter::new(global_rps, global_rps)), + by_sender: Mutex::new(std::collections::HashMap::new()), + drops_global: AtomicU64::new(0), + drops_sender: AtomicU64::new(0), + } + } + + async fn acquire_global(&self, cost: u32) -> bool { + let mut g = self.global.lock().await; + let ok = g.try_acquire(cost.max(1)); + if !ok { + self.drops_global.fetch_add(1, Ordering::Relaxed); + } + ok + } + + async fn acquire_sender(&self, sender: [u8; 32], cost: u32, sender_rps: u32) -> bool { + let mut map = self.by_sender.lock().await; + let lim = map + .entry(sender) + .or_insert_with(|| catalyst_utils::utils::RateLimiter::new(sender_rps.max(1), sender_rps.max(1))); + let ok = lim.try_acquire(cost.max(1)); + if !ok { + self.drops_sender.fetch_add(1, Ordering::Relaxed); + } + ok + } } impl CatalystRpcImpl { @@ -536,11 +588,16 @@ impl CatalystRpcImpl { storage: Arc, network: Option>, tx_submit: Option>, + global_rps: u32, + sender_rps: u32, ) -> Self { Self { storage, network, tx_submit, + limiter: Arc::new(RpcLimiters::new(global_rps, sender_rps)), + _global_rps: global_rps, + sender_rps, } } } @@ -631,6 +688,11 @@ impl CatalystRpcServer for CatalystRpcImpl { count: u64, full_transactions: bool, ) -> RpcResult> { + // Throttle proportional to requested work. + let cost = 1u32.saturating_add((count.min(10_000) / 100) as u32); + if !self.limiter.acquire_global(cost).await { + return Err(ErrorObjectOwned::from(RpcServerError::RateLimited)); + } let mut out: Vec = Vec::new(); let count = count.min(10_000); // hard cap to protect node for i in 0..count { @@ -764,6 +826,10 @@ impl CatalystRpcServer for CatalystRpcImpl { from_cycle: Option, limit: u64, ) -> RpcResult> { + let cost = 1u32.saturating_add((limit.min(5_000) / 10) as u32); + if !self.limiter.acquire_global(cost).await { + return Err(ErrorObjectOwned::from(RpcServerError::RateLimited)); + } let pk = parse_hex_32(&address).map_err(ErrorObjectOwned::from)?; let head = self.block_number().await.unwrap_or(0); let mut cycle = from_cycle.unwrap_or(head); @@ -885,6 +951,11 @@ impl CatalystRpcServer for CatalystRpcImpl { } async fn send_raw_transaction(&self, _data: String) -> RpcResult { + // Global throttling: protect CPU/memory under load. + if !self.limiter.acquire_global(1).await { + return Err(ErrorObjectOwned::from(RpcServerError::RateLimited)); + } + // Accept hex-encoded legacy bincode(Transaction) OR v1 canonical wire tx (CTX1...). let bytes = parse_hex_bytes(&_data).map_err(ErrorObjectOwned::from)?; let tx: catalyst_core::protocol::Transaction = @@ -936,6 +1007,15 @@ impl CatalystRpcServer for CatalystRpcImpl { // - reject nonce <= committed nonce for sender // (node/mempool also enforces sequential nonces) if let Some(sender_pk) = tx_sender_pubkey(&tx) { + // Per-sender throttling: stop single-key spam even behind NAT. + if !self + .limiter + .acquire_sender(sender_pk, 1, self.sender_rps) + .await + { + return Err(ErrorObjectOwned::from(RpcServerError::RateLimited)); + } + let mut k = b"nonce:".to_vec(); k.extend_from_slice(&sender_pk); let committed = self @@ -1465,13 +1545,15 @@ pub async fn start_rpc_http( storage: Arc, network: Option>, tx_submit: Option>, + global_rps: u32, + sender_rps: u32, ) -> Result { let server = jsonrpsee::server::ServerBuilder::default() .build(bind_address) .await .map_err(|e| RpcServerError::Server(e.to_string()))?; - let rpc = CatalystRpcImpl::new(storage, network, tx_submit).into_rpc(); + let rpc = CatalystRpcImpl::new(storage, network, tx_submit, global_rps, sender_rps).into_rpc(); let handle = server.start(rpc); Ok(handle) From 4fc5ef8edab328e264e64cb99dae166d57acc1d0 Mon Sep 17 00:00:00 2001 From: TheNewAutonomy Date: Sun, 8 Feb 2026 21:37:03 +0000 Subject: [PATCH 4/8] sync: add db backup/restore CLI commands Expose snapshot-based database backup/restore via catalyst-cli (db-backup/db-restore) to support snapshot-style syncing workflows for new nodes. Co-authored-by: Cursor --- crates/catalyst-cli/src/commands.rs | 21 +++++++++++++++++++++ crates/catalyst-cli/src/main.rs | 24 ++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/crates/catalyst-cli/src/commands.rs b/crates/catalyst-cli/src/commands.rs index 9895796..af269db 100644 --- a/crates/catalyst-cli/src/commands.rs +++ b/crates/catalyst-cli/src/commands.rs @@ -12,6 +12,7 @@ use jsonrpsee::core::client::ClientT; use catalyst_crypto::signatures::{Signature, SignatureScheme}; use crate::evm::EvmTxKind; use alloy_primitives::Address as EvmAddress; +use catalyst_storage::{StorageConfig as StorageConfigLib, StorageManager}; fn parse_hex_32(s: &str) -> anyhow::Result<[u8; 32]> { let s = s.trim().strip_prefix("0x").unwrap_or(s); @@ -216,6 +217,26 @@ pub async fn show_peers(rpc_url: &str) -> Result<()> { get_peers(rpc_url).await } +pub async fn db_backup(data_dir: &Path, out_dir: &Path) -> Result<()> { + let mut cfg = StorageConfigLib::default(); + cfg.data_dir = data_dir.to_path_buf(); + let store = StorageManager::new(cfg).await?; + store.backup_to_directory(out_dir).await?; + println!("backup_ok: true"); + println!("out_dir: {}", out_dir.display()); + Ok(()) +} + +pub async fn db_restore(data_dir: &Path, from_dir: &Path) -> Result<()> { + let mut cfg = StorageConfigLib::default(); + cfg.data_dir = data_dir.to_path_buf(); + let store = StorageManager::new(cfg).await?; + store.restore_from_directory(from_dir).await?; + println!("restore_ok: true"); + println!("from_dir: {}", from_dir.display()); + Ok(()) +} + pub async fn show_receipt(tx_hash: &str, rpc_url: &str) -> Result<()> { get_receipt(tx_hash, rpc_url).await } diff --git a/crates/catalyst-cli/src/main.rs b/crates/catalyst-cli/src/main.rs index b559479..87291a4 100644 --- a/crates/catalyst-cli/src/main.rs +++ b/crates/catalyst-cli/src/main.rs @@ -133,6 +133,24 @@ enum Commands { #[arg(long, default_value = "http://localhost:8545")] rpc_url: String, }, + /// Backup node database to a directory (snapshot export) + DbBackup { + /// Data directory (same as config.storage.data_dir) + #[arg(long)] + data_dir: PathBuf, + /// Output directory for the backup + #[arg(long)] + out_dir: PathBuf, + }, + /// Restore node database from a backup directory (snapshot import) + DbRestore { + /// Data directory (same as config.storage.data_dir) + #[arg(long)] + data_dir: PathBuf, + /// Backup directory to restore from + #[arg(long)] + from_dir: PathBuf, + }, /// Show a transaction receipt/status (and inclusion proof when applied) Receipt { /// Transaction hash (tx_id) @@ -357,6 +375,12 @@ async fn main() -> Result<()> { Commands::Peers { rpc_url } => { commands::show_peers(&rpc_url).await?; } + Commands::DbBackup { data_dir, out_dir } => { + commands::db_backup(&data_dir, &out_dir).await?; + } + Commands::DbRestore { data_dir, from_dir } => { + commands::db_restore(&data_dir, &from_dir).await?; + } Commands::Receipt { tx_hash, rpc_url } => { commands::show_receipt(&tx_hash, &rpc_url).await?; } From 7cdc09178c9c4f0bd19baaa3d5a8e49c5aaab2e8 Mon Sep 17 00:00:00 2001 From: TheNewAutonomy Date: Sun, 8 Feb 2026 21:56:49 +0000 Subject: [PATCH 5/8] sync: add snapshot metadata and RPC sync info Write chain identity + head metadata to exported snapshot directories and verify it on restore. Add catalyst_getSyncInfo RPC to expose chain identity + head for fast-sync verification. Document the snapshot-based sync workflow and explorer/indexer handoff. Co-authored-by: Cursor --- crates/catalyst-cli/src/commands.rs | 108 ++++++++++++++++++++++++++++ crates/catalyst-rpc/src/lib.rs | 22 ++++++ docs/README.md | 8 +++ docs/explorer-handoff.md | 97 +++++++++++++++++++++++++ docs/sync-guide.md | 73 +++++++++++++++++++ 5 files changed, 308 insertions(+) create mode 100644 docs/explorer-handoff.md create mode 100644 docs/sync-guide.md diff --git a/crates/catalyst-cli/src/commands.rs b/crates/catalyst-cli/src/commands.rs index af269db..12698e1 100644 --- a/crates/catalyst-cli/src/commands.rs +++ b/crates/catalyst-cli/src/commands.rs @@ -14,6 +14,29 @@ use crate::evm::EvmTxKind; use alloy_primitives::Address as EvmAddress; use catalyst_storage::{StorageConfig as StorageConfigLib, StorageManager}; +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +struct SnapshotMetaV1 { + version: u32, + created_at_ms: u64, + chain_id: u64, + network_id: String, + genesis_hash: String, + applied_cycle: u64, + applied_lsu_hash: String, + applied_state_root: String, + last_lsu_cid: Option, +} + +fn decode_u64_le_opt(bytes: Option>) -> u64 { + let Some(b) = bytes else { return 0 }; + if b.len() != 8 { + return 0; + } + let mut arr = [0u8; 8]; + arr.copy_from_slice(&b); + u64::from_le_bytes(arr) +} + fn parse_hex_32(s: &str) -> anyhow::Result<[u8; 32]> { let s = s.trim().strip_prefix("0x").unwrap_or(s); let bytes = hex::decode(s)?; @@ -222,16 +245,101 @@ pub async fn db_backup(data_dir: &Path, out_dir: &Path) -> Result<()> { cfg.data_dir = data_dir.to_path_buf(); let store = StorageManager::new(cfg).await?; store.backup_to_directory(out_dir).await?; + + // Write chain identity + head metadata to the snapshot directory for fast-sync verification. + let chain_id = decode_u64_le_opt(store.get_metadata("protocol:chain_id").await.ok().flatten()); + let network_id = store + .get_metadata("protocol:network_id") + .await + .ok() + .flatten() + .map(|b| String::from_utf8_lossy(&b).to_string()) + .unwrap_or_else(|| "unknown".to_string()); + let genesis_hash = store + .get_metadata("protocol:genesis_hash") + .await + .ok() + .flatten() + .map(|b| format!("0x{}", hex::encode(b))) + .unwrap_or_else(|| "0x0".to_string()); + let applied_cycle = + decode_u64_le_opt(store.get_metadata("consensus:last_applied_cycle").await.ok().flatten()); + let applied_lsu_hash = store + .get_metadata("consensus:last_applied_lsu_hash") + .await + .ok() + .flatten() + .map(|b| format!("0x{}", hex::encode(b))) + .unwrap_or_else(|| "0x0".to_string()); + let applied_state_root = store + .get_metadata("consensus:last_applied_state_root") + .await + .ok() + .flatten() + .map(|b| format!("0x{}", hex::encode(b))) + .unwrap_or_else(|| "0x0".to_string()); + let last_lsu_cid = store + .get_metadata("consensus:last_lsu_cid") + .await + .ok() + .flatten() + .and_then(|b| String::from_utf8(b).ok()); + + let meta = SnapshotMetaV1 { + version: 1, + created_at_ms: catalyst_utils::utils::current_timestamp_ms(), + chain_id, + network_id, + genesis_hash, + applied_cycle, + applied_lsu_hash, + applied_state_root, + last_lsu_cid, + }; + let meta_path = out_dir.join("catalyst_snapshot.json"); + std::fs::write(&meta_path, serde_json::to_string_pretty(&meta)?)?; + println!("meta_path: {}", meta_path.display()); + println!("backup_ok: true"); println!("out_dir: {}", out_dir.display()); Ok(()) } pub async fn db_restore(data_dir: &Path, from_dir: &Path) -> Result<()> { + // Optional pre-flight: if metadata is present, load it for post-restore verification. + let meta_path = from_dir.join("catalyst_snapshot.json"); + let meta: Option = std::fs::read_to_string(&meta_path) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()); + let mut cfg = StorageConfigLib::default(); cfg.data_dir = data_dir.to_path_buf(); let store = StorageManager::new(cfg).await?; store.restore_from_directory(from_dir).await?; + + if let Some(m) = meta { + let chain_id = decode_u64_le_opt(store.get_metadata("protocol:chain_id").await.ok().flatten()); + let genesis_hash = store + .get_metadata("protocol:genesis_hash") + .await + .ok() + .flatten() + .map(|b| format!("0x{}", hex::encode(b))) + .unwrap_or_else(|| "0x0".to_string()); + anyhow::ensure!( + chain_id == m.chain_id, + "restore verification failed: chain_id mismatch (snapshot={} restored={})", + m.chain_id, + chain_id + ); + anyhow::ensure!( + genesis_hash == m.genesis_hash, + "restore verification failed: genesis_hash mismatch (snapshot={} restored={})", + m.genesis_hash, + genesis_hash + ); + } + println!("restore_ok: true"); println!("from_dir: {}", from_dir.display()); Ok(()) diff --git a/crates/catalyst-rpc/src/lib.rs b/crates/catalyst-rpc/src/lib.rs index 51b8809..2cc7c8f 100644 --- a/crates/catalyst-rpc/src/lib.rs +++ b/crates/catalyst-rpc/src/lib.rs @@ -114,6 +114,10 @@ pub trait CatalystRpc { #[method(name = "catalyst_genesisHash")] async fn genesis_hash(&self) -> RpcResult; + /// Get sync/snapshot metadata needed for fast-sync verification. + #[method(name = "catalyst_getSyncInfo")] + async fn get_sync_info(&self) -> RpcResult; + /// Get the current block number #[method(name = "catalyst_blockNumber")] async fn block_number(&self) -> RpcResult; @@ -235,6 +239,15 @@ pub struct RpcHead { pub last_lsu_cid: Option, } +/// Sync metadata used by snapshot-based fast sync tooling. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RpcSyncInfo { + pub chain_id: String, + pub network_id: String, + pub genesis_hash: String, + pub head: RpcHead, +} + /// RPC transaction request structure #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RpcTransactionRequest { @@ -643,6 +656,15 @@ impl CatalystRpcServer for CatalystRpcImpl { } } + async fn get_sync_info(&self) -> RpcResult { + Ok(RpcSyncInfo { + chain_id: self.chain_id().await?, + network_id: self.network_id().await?, + genesis_hash: self.genesis_hash().await?, + head: self.head().await?, + }) + } + async fn block_number(&self) -> RpcResult { let n = self .storage diff --git a/docs/README.md b/docs/README.md index 62f5da9..a97cb15 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,6 +26,14 @@ These docs are written against the current `catalyst-node-rust` implementation ( - Canonical address format - v1 transaction signing payload + wire encoding +- **Explorer / Indexer handoff**: [`explorer-handoff.md`](./explorer-handoff.md) + - Chain model + compatibility notes + - Practical indexing loop (blocks-by-range + receipts) + +- **Sync / Fast sync**: [`sync-guide.md`](./sync-guide.md) + - Snapshot-based node restore workflow + - Verifying chain identity + head + ## Important implementation notes (current state) - **Consensus traffic is P2P on `30333/tcp`**, not RPC. RPC is only for client interaction. diff --git a/docs/explorer-handoff.md b/docs/explorer-handoff.md new file mode 100644 index 0000000..9dd1f2c --- /dev/null +++ b/docs/explorer-handoff.md @@ -0,0 +1,97 @@ +## Catalyst V2 explorer handoff (compatibility notes) + +This file is intended to be copied into a *fresh* Cursor workspace as context for building an external block explorer/indexer. +It focuses on the minimum you must know to be **compatible** with the current Catalyst testnet implementation. + +### Mental model (Catalyst vs Ethereum) + +- A Catalyst “block” is a **consensus cycle**. In RPC it is represented as a `RpcBlock` whose `number` equals the **applied cycle**. +- The canonical on-chain artifact per cycle is an **LSU** (`LedgerStateUpdate`), persisted by nodes under `consensus:lsu:` (internal). +- Transaction inclusion is “best-effort” until applied by your target node; do not assume immediate finality from submit. + +### Chain identity (required for correct signing) + +Explorers should show these (and wallets must sign against them): + +- `catalyst_chainId` → hex string (e.g. `"0x7a69"`) +- `catalyst_networkId` → string (e.g. `"tna_testnet"`) +- `catalyst_genesisHash` → `0x` + 32-byte hex + +### Addresses (canonical) + +- Address bytes are **32-byte public keys**. +- Text form is **lowercase** `0x`-prefixed hex (64 hex chars). + +### Transaction ID (tx hash) + +The tx id returned by RPC is: + +\[ +\text{tx\_id} = \text{blake2b512}(\text{"CTX1"} \,\|\, \text{tx\_bytes})[0..32] +\] + +Returned as `0x` + 32-byte hex. + +### Transaction submission (wire format) + +`catalyst_sendRawTransaction(data)` accepts: + +- **Preferred v1**: `0x` + hex(`"CTX1"` + canonical serialized `Transaction` bytes) +- **Legacy**: `0x` + hex(`bincode(Transaction)`) + +### Receipts (how explorers should track state) + +Use receipts as the primary truth for transaction state: + +- `catalyst_getTransactionReceipt(tx_hash)` returns: + - `status`: `"pending" | "selected" | "applied" | "dropped"` + - `selected_cycle`, `applied_cycle` + - optional execution info: + - `success` (bool) + - `error` (string) + - `gas_used` (u64, EVM best-effort) + - `return_data` (hex string, EVM best-effort) + +If `applied_cycle` is present, you can also query: + +- `catalyst_getTransactionInclusionProof(tx_hash)` → Merkle proof against the cycle’s tx Merkle root + +### Blocks / history indexing (practical loop) + +Recommended index loop: + +1) Read chain head: + - `catalyst_blockNumber` (applied cycle) + - or `catalyst_head` (cycle + lsu_hash + state_root) + +2) Fetch blocks in ranges: + - `catalyst_getBlocksByNumberRange(start, count, full_transactions)` + - Use `full_transactions=true` only when you need full objects; otherwise store summaries and fetch full txs on demand. + +3) For each block: + - persist block header fields + transaction summary list + - for each tx hash, also persist `catalyst_getTransactionReceipt` output (this is where `success/error/gas_used` lives) + +Notes: +- The server enforces caps/rate limiting; use paging and backoff. +- `catalyst_getTransactionsByAddress` exists but is best-effort and can be expensive; prefer block-range indexing. + +### Rate limiting / anti-spam behavior + +RPC may return a rate-limit error (code `-32029`). Your explorer should: + +- back off with jitter +- reduce batch sizes +- avoid tight polling loops on receipts + +### Known limitations (important for explorer UX) + +- “Finality” is node-local and pragmatic; treat `"applied"` as included in that node’s head. +- EVM is REVM-backed and persists `getCode/getStorageAt/getLastReturn` as dev helpers; it is not a full Ethereum RPC surface. + +### Where to look in this repo (reference) + +- RPC surface: `crates/catalyst-rpc/src/lib.rs` +- Wallet / tx v1 spec: `docs/wallet-interop.md` +- General integration notes: `docs/builder-guide.md` + diff --git a/docs/sync-guide.md b/docs/sync-guide.md new file mode 100644 index 0000000..b28073e --- /dev/null +++ b/docs/sync-guide.md @@ -0,0 +1,73 @@ +## Sync guide (snapshot-based fast sync) + +This guide describes a pragmatic way to bring up a **new node** without replaying from genesis: +use a **snapshot export** from an existing healthy node, verify chain identity + head, then start normally. + +### Prerequisites + +- You have at least one healthy node with storage enabled (validator or storage-enabled node). +- You can copy a directory from the source node to the destination node (e.g. `rsync`/SCP/object storage). + +### 1) Confirm chain identity on the source node (RPC) + +On the source node RPC: + +- `catalyst_chainId` +- `catalyst_networkId` +- `catalyst_genesisHash` +- `catalyst_head` +- `catalyst_getSyncInfo` (convenience bundle) + +Record these values. Your destination node should match them after restore. + +### 2) Create a snapshot export on the source node + +Run on the source node (adjust data dir path to your config): + +```bash +./target/release/catalyst-cli db-backup \ + --data-dir /var/lib/catalyst/eu/data \ + --out-dir /tmp/catalyst-snapshot +``` + +This writes: + +- a snapshot export (RocksDB snapshot directory structure) +- `catalyst_snapshot.json` containing: + - chain identity (`chain_id`, `network_id`, `genesis_hash`) + - applied head (`applied_cycle`, `applied_lsu_hash`, `applied_state_root`) + +### 3) Transfer the snapshot directory to the destination node + +Copy the full directory (including `catalyst_snapshot.json`) to the destination node, e.g.: + +```bash +rsync -av /tmp/catalyst-snapshot/ root@DEST:/tmp/catalyst-snapshot/ +``` + +### 4) Restore on the destination node + +Stop the node if it is running, then restore: + +```bash +./target/release/catalyst-cli db-restore \ + --data-dir /var/lib/catalyst/us/data \ + --from-dir /tmp/catalyst-snapshot +``` + +If `catalyst_snapshot.json` is present, the CLI verifies `chain_id` and `genesis_hash` after restore. + +### 5) Start the node normally and verify it is on the same chain + +Start the node with the same `protocol.chain_id` / `protocol.network_id` as the rest of the testnet. + +Verify: + +- `catalyst_getSyncInfo` matches the source chain id + genesis hash +- `catalyst_head.applied_cycle` is close to the source node’s head (it will advance as the node continues syncing/participating) + +### Notes / limitations + +- This is snapshot-style sync, not yet a full “trust-minimized” state sync. It is intended for **testnet ops**. +- For mainnet readiness, fast sync should be extended to verify state roots/proofs and/or multiple peers. + From 62f6566633fc39b7667be9743b12e4ec1c31ed18 Mon Sep 17 00:00:00 2001 From: TheNewAutonomy Date: Sun, 8 Feb 2026 22:11:49 +0000 Subject: [PATCH 6/8] sync: advertise and download snapshot archives Add RPC snapshot advertisement plus CLI helpers to create tar archives, publish snapshot metadata, and restore from a published archive. Co-authored-by: Cursor --- Cargo.lock | 385 +++++++++++++++++++++++++++- crates/catalyst-cli/Cargo.toml | 4 +- crates/catalyst-cli/src/commands.rs | 132 +++++++++- crates/catalyst-cli/src/main.rs | 40 ++- crates/catalyst-rpc/src/lib.rs | 44 +++- docs/sync-guide.md | 28 +- 6 files changed, 614 insertions(+), 19 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9aa90bd..70966a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -730,6 +730,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "axum" version = "0.7.9" @@ -1144,10 +1166,12 @@ dependencies = [ "hex", "jsonrpsee", "rand 0.8.5", + "reqwest 0.13.2", "revm", "serde", "serde_json", "sha2 0.10.9", + "tar", "thiserror 1.0.69", "tokio", "toml 0.8.2", @@ -1374,7 +1398,7 @@ dependencies = [ "governor", "jsonwebtoken", "parking_lot", - "reqwest", + "reqwest 0.11.27", "serde", "serde_json", "thiserror 1.0.69", @@ -1383,7 +1407,7 @@ dependencies = [ "tokio-tungstenite 0.21.0", "toml 0.8.2", "tower 0.4.13", - "tower-http", + "tower-http 0.5.2", "tracing", "uuid", "wiremock", @@ -1445,6 +1469,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + [[package]] name = "cexpr" version = "0.6.0" @@ -1607,6 +1637,15 @@ version = "0.7.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + [[package]] name = "colorchoice" version = "1.0.4" @@ -1707,6 +1746,16 @@ dependencies = [ "libc", ] +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -2279,6 +2328,12 @@ version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + [[package]] name = "dyn-clone" version = "1.0.20" @@ -2571,6 +2626,12 @@ 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 = "fsevent-sys" version = "4.1.0" @@ -3194,6 +3255,7 @@ dependencies = [ "pin-utils", "smallvec", "tokio", + "want", ] [[package]] @@ -3207,9 +3269,25 @@ dependencies = [ "hyper 0.14.32", "log", "rustls 0.21.12", - "rustls-native-certs", + "rustls-native-certs 0.6.3", + "tokio", + "tokio-rustls 0.24.1", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http 1.4.0", + "hyper 1.8.1", + "hyper-util", + "rustls 0.23.36", + "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.4", + "tower-service", ] [[package]] @@ -3231,14 +3309,22 @@ version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ + "base64 0.22.1", "bytes", + "futures-channel", "futures-core", + "futures-util", "http 1.4.0", "http-body 1.0.1", "hyper 1.8.1", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2 0.6.1", "tokio", "tower-service", + "tracing", ] [[package]] @@ -3396,7 +3482,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdf9d64cfcf380606e64f9a0bcf493616b65331199f984151a6fa11a7b3cde38" dependencies = [ "async-io", - "core-foundation", + "core-foundation 0.9.4", "fnv", "futures", "if-addrs", @@ -3536,6 +3622,16 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is-terminal" version = "0.4.17" @@ -3606,6 +3702,28 @@ dependencies = [ "cc", ] +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + [[package]] name = "jobserver" version = "0.1.34" @@ -3671,7 +3789,7 @@ checksum = "78b7de9f3219d95985eb77fd03194d7c1b56c19bce1abfcc9d07462574b15572" dependencies = [ "async-trait", "hyper 0.14.32", - "hyper-rustls", + "hyper-rustls 0.24.2", "jsonrpsee-core", "jsonrpsee-types", "serde", @@ -4640,10 +4758,10 @@ dependencies = [ "libc", "log", "openssl", - "openssl-probe", + "openssl-probe 0.1.6", "openssl-sys", "schannel", - "security-framework", + "security-framework 2.11.1", "security-framework-sys", "tempfile", ] @@ -4968,6 +5086,12 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "openssl-sys" version = "0.9.111" @@ -5504,6 +5628,7 @@ version = "0.11.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -5859,6 +5984,46 @@ dependencies = [ "winreg", ] +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-rustls 0.27.7", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls 0.23.36", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper 1.0.2", + "tokio", + "tokio-rustls 0.26.4", + "tokio-util", + "tower 0.5.3", + "tower-http 0.6.8", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + [[package]] name = "resolv-conf" version = "0.7.6" @@ -6303,6 +6468,7 @@ version = "0.23.36" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" dependencies = [ + "aws-lc-rs", "once_cell", "ring 0.17.14", "rustls-pki-types", @@ -6317,10 +6483,22 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" dependencies = [ - "openssl-probe", + "openssl-probe 0.1.6", "rustls-pemfile", "schannel", - "security-framework", + "security-framework 2.11.1", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe 0.2.1", + "rustls-pki-types", + "schannel", + "security-framework 3.5.1", ] [[package]] @@ -6342,6 +6520,33 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls 0.23.36", + "rustls-native-certs 0.8.3", + "rustls-platform-verifier-android", + "rustls-webpki 0.103.8", + "security-framework 3.5.1", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" version = "0.101.7" @@ -6358,6 +6563,7 @@ version = "0.103.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52" dependencies = [ + "aws-lc-rs", "ring 0.17.14", "rustls-pki-types", "untrusted 0.9.0", @@ -6497,7 +6703,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags 2.10.0", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -7042,6 +7261,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -7073,7 +7295,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" dependencies = [ "bitflags 1.3.2", - "core-foundation", + "core-foundation 0.9.4", "system-configuration-sys 0.5.0", ] @@ -7084,7 +7306,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ "bitflags 2.10.0", - "core-foundation", + "core-foundation 0.9.4", "system-configuration-sys 0.6.0", ] @@ -7114,6 +7336,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 = "target-lexicon" version = "0.12.16" @@ -7361,6 +7594,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.36", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -7542,6 +7785,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "iri-string", + "pin-project-lite", + "tower 0.5.3", + "tower-layer", + "tower-service", +] + [[package]] name = "tower-layer" version = "0.3.3" @@ -7956,6 +8217,19 @@ dependencies = [ "wasmparser 0.244.0", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.116.1" @@ -8303,6 +8577,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "widestring" version = "1.2.1" @@ -8428,6 +8711,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + [[package]] name = "windows-sys" version = "0.48.0" @@ -8464,6 +8756,21 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -8512,6 +8819,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -8530,6 +8843,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -8548,6 +8867,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -8578,6 +8903,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -8596,6 +8927,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -8614,6 +8951,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -8632,6 +8975,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -8767,6 +9116,16 @@ dependencies = [ "time 0.3.45", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix 1.1.3", +] + [[package]] name = "xml-rs" version = "0.8.28" diff --git a/crates/catalyst-cli/Cargo.toml b/crates/catalyst-cli/Cargo.toml index 5ad57a9..8a190f0 100644 --- a/crates/catalyst-cli/Cargo.toml +++ b/crates/catalyst-cli/Cargo.toml @@ -45,7 +45,9 @@ sha2 = "0.10" # Async runtime futures = "0.3" jsonrpsee = { version = "0.21", features = ["http-client"] } +reqwest = { version = "0.13.2", features = ["json", "rustls", "stream"], default-features = false } +tar = "0.4.44" [features] default = [] -dev = [] \ No newline at end of file +dev = [] diff --git a/crates/catalyst-cli/src/commands.rs b/crates/catalyst-cli/src/commands.rs index 12698e1..fcbe883 100644 --- a/crates/catalyst-cli/src/commands.rs +++ b/crates/catalyst-cli/src/commands.rs @@ -13,6 +13,7 @@ use catalyst_crypto::signatures::{Signature, SignatureScheme}; use crate::evm::EvmTxKind; use alloy_primitives::Address as EvmAddress; use catalyst_storage::{StorageConfig as StorageConfigLib, StorageManager}; +use tokio::io::AsyncWriteExt; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] struct SnapshotMetaV1 { @@ -240,7 +241,7 @@ pub async fn show_peers(rpc_url: &str) -> Result<()> { get_peers(rpc_url).await } -pub async fn db_backup(data_dir: &Path, out_dir: &Path) -> Result<()> { +pub async fn db_backup(data_dir: &Path, out_dir: &Path, archive: Option<&Path>) -> Result<()> { let mut cfg = StorageConfigLib::default(); cfg.data_dir = data_dir.to_path_buf(); let store = StorageManager::new(cfg).await?; @@ -300,6 +301,24 @@ pub async fn db_backup(data_dir: &Path, out_dir: &Path) -> Result<()> { std::fs::write(&meta_path, serde_json::to_string_pretty(&meta)?)?; println!("meta_path: {}", meta_path.display()); + if let Some(archive_path) = archive { + // Package the snapshot directory into a tar archive for distribution. + let f = std::fs::File::create(archive_path)?; + let mut builder = tar::Builder::new(f); + builder.append_dir_all(".", out_dir)?; + builder.finish()?; + + // Compute sha256 + bytes for operator publishing. + use sha2::Digest; + let bytes = std::fs::read(archive_path)?; + let mut h = sha2::Sha256::new(); + h.update(&bytes); + let sum = h.finalize(); + println!("archive_path: {}", archive_path.display()); + println!("archive_bytes: {}", bytes.len()); + println!("archive_sha256: 0x{}", hex::encode(sum)); + } + println!("backup_ok: true"); println!("out_dir: {}", out_dir.display()); Ok(()) @@ -345,6 +364,117 @@ pub async fn db_restore(data_dir: &Path, from_dir: &Path) -> Result<()> { Ok(()) } +pub async fn snapshot_publish( + data_dir: &Path, + snapshot_dir: &Path, + archive_url: &str, + archive_path: &Path, +) -> Result<()> { + // Load snapshot meta file produced by db-backup. + let meta_path = snapshot_dir.join("catalyst_snapshot.json"); + let meta: SnapshotMetaV1 = serde_json::from_str(&std::fs::read_to_string(&meta_path)?)?; + + // Compute sha256 + bytes of archive. + use sha2::Digest; + let bytes = std::fs::read(archive_path)?; + let mut h = sha2::Sha256::new(); + h.update(&bytes); + let sum = h.finalize(); + + let info = catalyst_rpc::RpcSnapshotInfo { + version: 1, + created_at_ms: meta.created_at_ms, + chain_id: format!("0x{:x}", meta.chain_id), + network_id: meta.network_id.clone(), + genesis_hash: meta.genesis_hash.clone(), + applied_cycle: meta.applied_cycle, + applied_lsu_hash: meta.applied_lsu_hash.clone(), + applied_state_root: meta.applied_state_root.clone(), + last_lsu_cid: meta.last_lsu_cid.clone(), + archive_url: archive_url.to_string(), + archive_sha256: Some(format!("0x{}", hex::encode(sum))), + archive_bytes: Some(bytes.len() as u64), + }; + + let mut cfg = StorageConfigLib::default(); + cfg.data_dir = data_dir.to_path_buf(); + let store = StorageManager::new(cfg).await?; + let payload = serde_json::to_vec(&info)?; + store.set_metadata("snapshot:latest", &payload).await?; + + println!("published: true"); + println!("archive_url: {}", info.archive_url); + println!("archive_sha256: {}", info.archive_sha256.clone().unwrap_or_else(|| "null".to_string())); + println!("archive_bytes: {}", info.archive_bytes.unwrap_or(0)); + Ok(()) +} + +pub async fn sync_from_snapshot( + rpc_url: &str, + data_dir: &Path, + work_dir: Option<&Path>, +) -> Result<()> { + let client = HttpClientBuilder::default().build(rpc_url)?; + let snap: Option = client + .request("catalyst_getSnapshotInfo", jsonrpsee::rpc_params![]) + .await?; + let Some(s) = snap else { + anyhow::bail!("no snapshot published by RPC node"); + }; + + let base = work_dir + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| std::env::temp_dir().join("catalyst-sync")); + std::fs::create_dir_all(&base)?; + let archive_path = base.join("snapshot.tar"); + let extract_dir = base.join("snapshot"); + if extract_dir.exists() { + std::fs::remove_dir_all(&extract_dir)?; + } + std::fs::create_dir_all(&extract_dir)?; + + // Download archive (stream to disk). + let resp = reqwest::get(&s.archive_url).await?; + anyhow::ensure!(resp.status().is_success(), "download failed: {}", resp.status()); + let mut file = tokio::fs::File::create(&archive_path).await?; + let mut stream = resp.bytes_stream(); + use futures::StreamExt; + while let Some(chunk) = stream.next().await { + let chunk = chunk?; + file.write_all(&chunk).await?; + } + file.flush().await?; + + // Verify sha256 if provided. + if let Some(expect) = &s.archive_sha256 { + use sha2::Digest; + let bytes = std::fs::read(&archive_path)?; + let mut h = sha2::Sha256::new(); + h.update(&bytes); + let got = format!("0x{}", hex::encode(h.finalize())); + anyhow::ensure!( + got.to_lowercase() == expect.to_lowercase(), + "sha256 mismatch: expected={} got={}", + expect, + got + ); + } + + // Extract tar to directory. + let f = std::fs::File::open(&archive_path)?; + let mut ar = tar::Archive::new(f); + ar.unpack(&extract_dir)?; + + // Restore into data_dir. + db_restore(data_dir, &extract_dir).await?; + + println!("restored: true"); + println!("expected_chain_id: {}", s.chain_id); + println!("expected_genesis_hash: {}", s.genesis_hash); + println!("snapshot_cycle: {}", s.applied_cycle); + Ok(()) +} + pub async fn show_receipt(tx_hash: &str, rpc_url: &str) -> Result<()> { get_receipt(tx_hash, rpc_url).await } diff --git a/crates/catalyst-cli/src/main.rs b/crates/catalyst-cli/src/main.rs index 87291a4..139900d 100644 --- a/crates/catalyst-cli/src/main.rs +++ b/crates/catalyst-cli/src/main.rs @@ -141,6 +141,9 @@ enum Commands { /// Output directory for the backup #[arg(long)] out_dir: PathBuf, + /// Optional tar archive output path for distribution + #[arg(long)] + archive: Option, }, /// Restore node database from a backup directory (snapshot import) DbRestore { @@ -151,6 +154,33 @@ enum Commands { #[arg(long)] from_dir: PathBuf, }, + /// Publish snapshot metadata into the node DB (served via RPC for fast-sync tooling) + SnapshotPublish { + /// Data directory (same as config.storage.data_dir) of the RPC node + #[arg(long)] + data_dir: PathBuf, + /// Snapshot directory created by `db-backup` (contains *.snapshot and *_data) + #[arg(long)] + snapshot_dir: PathBuf, + /// URL where the snapshot archive can be downloaded + #[arg(long)] + archive_url: String, + /// Path to the tar archive file (used to compute sha256/bytes) + #[arg(long)] + archive_path: PathBuf, + }, + /// Download the published snapshot archive and restore it locally + SyncFromSnapshot { + /// RPC endpoint to fetch snapshot info from + #[arg(long, default_value = "http://localhost:8545")] + rpc_url: String, + /// Data directory to restore into (same as config.storage.data_dir) + #[arg(long)] + data_dir: PathBuf, + /// Directory to download/extract into (defaults to /tmp) + #[arg(long)] + work_dir: Option, + }, /// Show a transaction receipt/status (and inclusion proof when applied) Receipt { /// Transaction hash (tx_id) @@ -375,12 +405,18 @@ async fn main() -> Result<()> { Commands::Peers { rpc_url } => { commands::show_peers(&rpc_url).await?; } - Commands::DbBackup { data_dir, out_dir } => { - commands::db_backup(&data_dir, &out_dir).await?; + Commands::DbBackup { data_dir, out_dir, archive } => { + commands::db_backup(&data_dir, &out_dir, archive.as_deref()).await?; } Commands::DbRestore { data_dir, from_dir } => { commands::db_restore(&data_dir, &from_dir).await?; } + Commands::SnapshotPublish { data_dir, snapshot_dir, archive_url, archive_path } => { + commands::snapshot_publish(&data_dir, &snapshot_dir, &archive_url, &archive_path).await?; + } + Commands::SyncFromSnapshot { rpc_url, data_dir, work_dir } => { + commands::sync_from_snapshot(&rpc_url, &data_dir, work_dir.as_deref()).await?; + } Commands::Receipt { tx_hash, rpc_url } => { commands::show_receipt(&tx_hash, &rpc_url).await?; } diff --git a/crates/catalyst-rpc/src/lib.rs b/crates/catalyst-rpc/src/lib.rs index 2cc7c8f..a66d853 100644 --- a/crates/catalyst-rpc/src/lib.rs +++ b/crates/catalyst-rpc/src/lib.rs @@ -21,6 +21,7 @@ use tokio::sync::mpsc; const META_PROTOCOL_CHAIN_ID: &str = "protocol:chain_id"; const META_PROTOCOL_NETWORK_ID: &str = "protocol:network_id"; const META_PROTOCOL_GENESIS_HASH: &str = "protocol:genesis_hash"; +const META_SNAPSHOT_LATEST: &str = "snapshot:latest"; const RPC_ERR_RATE_LIMITED_CODE: i32 = -32029; @@ -118,6 +119,10 @@ pub trait CatalystRpc { #[method(name = "catalyst_getSyncInfo")] async fn get_sync_info(&self) -> RpcResult; + /// Get published snapshot info for fast sync (if the node operator published one). + #[method(name = "catalyst_getSnapshotInfo")] + async fn get_snapshot_info(&self) -> RpcResult>; + /// Get the current block number #[method(name = "catalyst_blockNumber")] async fn block_number(&self) -> RpcResult; @@ -248,6 +253,27 @@ pub struct RpcSyncInfo { pub head: RpcHead, } +/// Operator-published snapshot metadata for fast sync. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RpcSnapshotInfo { + pub version: u32, + pub created_at_ms: u64, + pub chain_id: String, + pub network_id: String, + pub genesis_hash: String, + pub applied_cycle: u64, + pub applied_lsu_hash: String, + pub applied_state_root: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_lsu_cid: Option, + /// Download URL for a tar archive that extracts to a snapshot directory. + pub archive_url: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub archive_sha256: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub archive_bytes: Option, +} + /// RPC transaction request structure #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RpcTransactionRequest { @@ -564,7 +590,7 @@ struct RpcLimiters { } impl RpcLimiters { - fn new(global_rps: u32, sender_rps: u32) -> Self { + fn new(global_rps: u32, _sender_rps: u32) -> Self { let global_rps = global_rps.max(1); Self { global: Mutex::new(catalyst_utils::utils::RateLimiter::new(global_rps, global_rps)), @@ -665,6 +691,22 @@ impl CatalystRpcServer for CatalystRpcImpl { }) } + async fn get_snapshot_info(&self) -> RpcResult> { + let Some(bytes) = self + .storage + .get_metadata(META_SNAPSHOT_LATEST) + .await + .ok() + .flatten() + else { + return Ok(None); + }; + let s = String::from_utf8_lossy(&bytes).to_string(); + let info = serde_json::from_str::(&s) + .map_err(|e| ErrorObjectOwned::from(RpcServerError::Server(e.to_string())))?; + Ok(Some(info)) + } + async fn block_number(&self) -> RpcResult { let n = self .storage diff --git a/docs/sync-guide.md b/docs/sync-guide.md index b28073e..38598d6 100644 --- a/docs/sync-guide.md +++ b/docs/sync-guide.md @@ -27,7 +27,8 @@ Run on the source node (adjust data dir path to your config): ```bash ./target/release/catalyst-cli db-backup \ --data-dir /var/lib/catalyst/eu/data \ - --out-dir /tmp/catalyst-snapshot + --out-dir /tmp/catalyst-snapshot \ + --archive /tmp/catalyst-snapshot.tar ``` This writes: @@ -36,6 +37,7 @@ This writes: - `catalyst_snapshot.json` containing: - chain identity (`chain_id`, `network_id`, `genesis_hash`) - applied head (`applied_cycle`, `applied_lsu_hash`, `applied_state_root`) +- optionally, a tar archive (recommended for transport) ### 3) Transfer the snapshot directory to the destination node @@ -45,6 +47,20 @@ Copy the full directory (including `catalyst_snapshot.json`) to the destination rsync -av /tmp/catalyst-snapshot/ root@DEST:/tmp/catalyst-snapshot/ ``` +### 3b) (Optional) Publish snapshot info for automation + +If your RPC node should advertise a downloadable snapshot to new nodes, publish it: + +```bash +./target/release/catalyst-cli snapshot-publish \ + --data-dir /var/lib/catalyst/eu/data \ + --snapshot-dir /tmp/catalyst-snapshot \ + --archive-path /tmp/catalyst-snapshot.tar \ + --archive-url https:///catalyst-snapshot.tar +``` + +Then clients can query `catalyst_getSnapshotInfo` from RPC. + ### 4) Restore on the destination node Stop the node if it is running, then restore: @@ -57,6 +73,16 @@ Stop the node if it is running, then restore: If `catalyst_snapshot.json` is present, the CLI verifies `chain_id` and `genesis_hash` after restore. +### 4b) (Optional) Automated restore from RPC-published snapshot + +On a new node, you can download and restore the published snapshot archive: + +```bash +./target/release/catalyst-cli sync-from-snapshot \ + --rpc-url http://:8545 \ + --data-dir /var/lib/catalyst/us/data +``` + ### 5) Start the node normally and verify it is on the same chain Start the node with the same `protocol.chain_id` / `protocol.network_id` as the rest of the testnet. From 0607f2d68555d15ac172f4e282f64b37acac4a77 Mon Sep 17 00:00:00 2001 From: TheNewAutonomy Date: Mon, 9 Feb 2026 07:18:16 +0000 Subject: [PATCH 7/8] sync: add snapshot-make-latest automation command Add a one-shot CLI command to create a timestamped snapshot+archive, publish it for RPC fast-sync clients, and clean up older snapshots/archives by retention. Co-authored-by: Cursor --- crates/catalyst-cli/src/commands.rs | 79 +++++++++++++++++++++++++++++ crates/catalyst-cli/src/main.rs | 18 +++++++ docs/sync-guide.md | 15 ++++++ 3 files changed, 112 insertions(+) diff --git a/crates/catalyst-cli/src/commands.rs b/crates/catalyst-cli/src/commands.rs index fcbe883..3e55efd 100644 --- a/crates/catalyst-cli/src/commands.rs +++ b/crates/catalyst-cli/src/commands.rs @@ -475,6 +475,85 @@ pub async fn sync_from_snapshot( Ok(()) } +pub async fn snapshot_make_latest( + data_dir: &Path, + out_base_dir: &Path, + archive_url_base: &str, + retain: usize, +) -> Result<()> { + anyhow::ensure!(retain >= 1, "--retain must be >= 1"); + std::fs::create_dir_all(out_base_dir)?; + + let created_at_ms = catalyst_utils::utils::current_timestamp_ms(); + let snapshot_dirname = format!("catalyst-snapshot-{created_at_ms}"); + let snapshot_dir = out_base_dir.join(&snapshot_dirname); + + let archive_name = format!("catalyst-snapshot-{created_at_ms}.tar"); + let archive_path = out_base_dir.join(&archive_name); + let url_base = archive_url_base.trim_end_matches('/'); + let archive_url = format!("{url_base}/{archive_name}"); + + db_backup(data_dir, &snapshot_dir, Some(&archive_path)).await?; + snapshot_publish(data_dir, &snapshot_dir, &archive_url, &archive_path).await?; + + // Retention: keep newest N snapshots/archives (by timestamp in filename). + #[derive(Debug)] + struct Item { + ts: u64, + path: std::path::PathBuf, + is_dir: bool, + } + + fn parse_ts(name: &str) -> Option { + if let Some(rest) = name.strip_prefix("catalyst-snapshot-") { + if let Some(num) = rest.strip_suffix(".tar") { + return num.parse::().ok(); + } + return rest.parse::().ok(); + } + None + } + + let mut items: Vec = Vec::new(); + for entry in std::fs::read_dir(out_base_dir)? { + let entry = entry?; + let path = entry.path(); + let file_name = entry.file_name(); + let name = file_name.to_string_lossy().to_string(); + let Some(ts) = parse_ts(&name) else { continue }; + let ft = entry.file_type()?; + items.push(Item { ts, path, is_dir: ft.is_dir() }); + } + + // Determine timestamps to keep (newest first). + let mut timestamps: Vec = items.iter().map(|i| i.ts).collect(); + timestamps.sort_unstable(); + timestamps.dedup(); + timestamps.sort_unstable_by(|a, b| b.cmp(a)); + let keep: std::collections::HashSet = timestamps.into_iter().take(retain).collect(); + + let mut deleted = 0usize; + for item in items { + if keep.contains(&item.ts) { + continue; + } + if item.is_dir { + let _ = std::fs::remove_dir_all(&item.path); + } else { + let _ = std::fs::remove_file(&item.path); + } + deleted += 1; + } + + println!("snapshot_ok: true"); + println!("snapshot_dir: {}", snapshot_dir.display()); + println!("archive_path: {}", archive_path.display()); + println!("archive_url: {}", archive_url); + println!("retained: {}", retain); + println!("deleted: {}", deleted); + Ok(()) +} + pub async fn show_receipt(tx_hash: &str, rpc_url: &str) -> Result<()> { get_receipt(tx_hash, rpc_url).await } diff --git a/crates/catalyst-cli/src/main.rs b/crates/catalyst-cli/src/main.rs index 139900d..7100cf1 100644 --- a/crates/catalyst-cli/src/main.rs +++ b/crates/catalyst-cli/src/main.rs @@ -181,6 +181,21 @@ enum Commands { #[arg(long)] work_dir: Option, }, + /// Create+archive a new snapshot and publish it to RPC (with retention cleanup) + SnapshotMakeLatest { + /// Data directory (same as config.storage.data_dir) of the RPC node + #[arg(long)] + data_dir: PathBuf, + /// Base directory to write snapshot directories and tar archives into + #[arg(long)] + out_base_dir: PathBuf, + /// Base URL that serves files from out_base_dir (no trailing slash required) + #[arg(long)] + archive_url_base: String, + /// How many snapshots/archives to retain in out_base_dir + #[arg(long, default_value_t = 3)] + retain: usize, + }, /// Show a transaction receipt/status (and inclusion proof when applied) Receipt { /// Transaction hash (tx_id) @@ -417,6 +432,9 @@ async fn main() -> Result<()> { Commands::SyncFromSnapshot { rpc_url, data_dir, work_dir } => { commands::sync_from_snapshot(&rpc_url, &data_dir, work_dir.as_deref()).await?; } + Commands::SnapshotMakeLatest { data_dir, out_base_dir, archive_url_base, retain } => { + commands::snapshot_make_latest(&data_dir, &out_base_dir, &archive_url_base, retain).await?; + } Commands::Receipt { tx_hash, rpc_url } => { commands::show_receipt(&tx_hash, &rpc_url).await?; } diff --git a/docs/sync-guide.md b/docs/sync-guide.md index 38598d6..ee9eb11 100644 --- a/docs/sync-guide.md +++ b/docs/sync-guide.md @@ -61,6 +61,21 @@ If your RPC node should advertise a downloadable snapshot to new nodes, publish Then clients can query `catalyst_getSnapshotInfo` from RPC. +### 3c) Recommended: one-shot create+archive+publish (with retention) + +This is the operator-friendly way to keep a “latest snapshot” published: + +```bash +./target/release/catalyst-cli snapshot-make-latest \ + --data-dir /var/lib/catalyst/eu/data \ + --out-base-dir /var/lib/catalyst/eu/snapshots \ + --archive-url-base https:///snapshots \ + --retain 3 +``` + +You still need to serve `/var/lib/catalyst/eu/snapshots/*.tar` over HTTP(S) (e.g. nginx/caddy). +After this runs, `catalyst_getSnapshotInfo` points at the newest archive URL. + ### 4) Restore on the destination node Stop the node if it is running, then restore: From 5a567ef28ea1fe8eacd444ca2a69612ab90f0f07 Mon Sep 17 00:00:00 2001 From: TheNewAutonomy Date: Mon, 9 Feb 2026 07:51:29 +0000 Subject: [PATCH 8/8] sync: add snapshot TTL fields and built-in archive server Extend snapshot advertisements with publish/expiry times and add a CLI sidecar HTTP server (Range-capable) to serve snapshot tar archives without external web servers. Co-authored-by: Cursor --- Cargo.lock | 14 ++- crates/catalyst-cli/Cargo.toml | 5 + crates/catalyst-cli/src/commands.rs | 171 +++++++++++++++++++++++++++- crates/catalyst-cli/src/main.rs | 26 ++++- crates/catalyst-rpc/src/lib.rs | 6 + docs/sync-guide.md | 12 ++ 6 files changed, 224 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 70966a2..4d7f525 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1104,9 +1104,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[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", ] @@ -1150,6 +1150,7 @@ dependencies = [ "anyhow", "bincode", "blake2", + "bytes", "catalyst-config", "catalyst-consensus", "catalyst-core", @@ -1164,6 +1165,9 @@ dependencies = [ "dirs", "futures", "hex", + "http-body-util", + "hyper 1.8.1", + "hyper-util", "jsonrpsee", "rand 0.8.5", "reqwest 0.13.2", @@ -1174,6 +1178,7 @@ dependencies = [ "tar", "thiserror 1.0.69", "tokio", + "tokio-util", "toml 0.8.2", "tracing", "tracing-subscriber 0.3.22", @@ -3305,14 +3310,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 0.22.1", "bytes", "futures-channel", - "futures-core", "futures-util", "http 1.4.0", "http-body 1.0.1", diff --git a/crates/catalyst-cli/Cargo.toml b/crates/catalyst-cli/Cargo.toml index 8a190f0..35a88be 100644 --- a/crates/catalyst-cli/Cargo.toml +++ b/crates/catalyst-cli/Cargo.toml @@ -47,6 +47,11 @@ futures = "0.3" jsonrpsee = { version = "0.21", features = ["http-client"] } reqwest = { version = "0.13.2", features = ["json", "rustls", "stream"], default-features = false } tar = "0.4.44" +hyper = { version = "1.8.1", features = ["http1", "server"] } +hyper-util = { version = "0.1.20", features = ["tokio"] } +http-body-util = "0.1.3" +bytes = "1.11.1" +tokio-util = { workspace = true, features = ["io"] } [features] default = [] diff --git a/crates/catalyst-cli/src/commands.rs b/crates/catalyst-cli/src/commands.rs index 3e55efd..8630008 100644 --- a/crates/catalyst-cli/src/commands.rs +++ b/crates/catalyst-cli/src/commands.rs @@ -14,6 +14,7 @@ use crate::evm::EvmTxKind; use alloy_primitives::Address as EvmAddress; use catalyst_storage::{StorageConfig as StorageConfigLib, StorageManager}; use tokio::io::AsyncWriteExt; +use std::net::SocketAddr; #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] struct SnapshotMetaV1 { @@ -369,6 +370,7 @@ pub async fn snapshot_publish( snapshot_dir: &Path, archive_url: &str, archive_path: &Path, + ttl_seconds: Option, ) -> Result<()> { // Load snapshot meta file produced by db-backup. let meta_path = snapshot_dir.join("catalyst_snapshot.json"); @@ -381,9 +383,14 @@ pub async fn snapshot_publish( h.update(&bytes); let sum = h.finalize(); + let published_at_ms = catalyst_utils::utils::current_timestamp_ms(); + let expires_at_ms = ttl_seconds.map(|s| published_at_ms.saturating_add(s.saturating_mul(1000))); + let info = catalyst_rpc::RpcSnapshotInfo { version: 1, created_at_ms: meta.created_at_ms, + published_at_ms, + expires_at_ms, chain_id: format!("0x{:x}", meta.chain_id), network_id: meta.network_id.clone(), genesis_hash: meta.genesis_hash.clone(), @@ -480,6 +487,7 @@ pub async fn snapshot_make_latest( out_base_dir: &Path, archive_url_base: &str, retain: usize, + ttl_seconds: Option, ) -> Result<()> { anyhow::ensure!(retain >= 1, "--retain must be >= 1"); std::fs::create_dir_all(out_base_dir)?; @@ -494,7 +502,7 @@ pub async fn snapshot_make_latest( let archive_url = format!("{url_base}/{archive_name}"); db_backup(data_dir, &snapshot_dir, Some(&archive_path)).await?; - snapshot_publish(data_dir, &snapshot_dir, &archive_url, &archive_path).await?; + snapshot_publish(data_dir, &snapshot_dir, &archive_url, &archive_path, ttl_seconds).await?; // Retention: keep newest N snapshots/archives (by timestamp in filename). #[derive(Debug)] @@ -558,6 +566,167 @@ pub async fn show_receipt(tx_hash: &str, rpc_url: &str) -> Result<()> { get_receipt(tx_hash, rpc_url).await } +pub async fn snapshot_serve(dir: &Path, bind: &str) -> Result<()> { + use bytes::Bytes; + use http_body_util::{BodyExt, Empty, Full, StreamBody, combinators::BoxBody}; + use hyper::{Method, Request, Response, StatusCode}; + use hyper::body::Frame; + use hyper::server::conn::http1; + use hyper::service::service_fn; + use hyper_util::rt::TokioIo; + use tokio::io::{AsyncSeekExt, AsyncReadExt}; + use tokio::net::TcpListener; + use tokio_util::io::ReaderStream; + use futures::TryStreamExt; + use std::convert::Infallible; + use std::io; + use std::path::PathBuf; + + fn boxed_empty() -> BoxBody { + Empty::new() + .map_err(|_: Infallible| io::Error::new(io::ErrorKind::Other, "infallible")) + .boxed() + } + + fn boxed_text(status: StatusCode, s: &str) -> Response> { + let body = Full::new(Bytes::from(s.to_string())) + .map_err(|_: Infallible| io::Error::new(io::ErrorKind::Other, "infallible")) + .boxed(); + Response::builder() + .status(status) + .header("content-type", "text/plain; charset=utf-8") + .header("cache-control", "no-store") + .body(body) + .unwrap() + } + + fn reject(status: StatusCode, msg: &str) -> Response> { + boxed_text(status, msg) + } + + fn sanitize_filename(path: &str) -> Option { + let p = path.trim_start_matches('/'); + if p.is_empty() { + return None; + } + if p.contains('/') || p.contains('\\') || p.contains("..") { + return None; + } + if !p.ends_with(".tar") { + return None; + } + Some(p.to_string()) + } + + async fn handle_request( + req: Request, + base_dir: PathBuf, + ) -> Result>, io::Error> { + let method = req.method().clone(); + if method != Method::GET && method != Method::HEAD { + return Ok(reject(StatusCode::METHOD_NOT_ALLOWED, "method not allowed\n")); + } + + let Some(name) = sanitize_filename(req.uri().path()) else { + return Ok(reject(StatusCode::NOT_FOUND, "not found\n")); + }; + let full_path = base_dir.join(&name); + let meta = match tokio::fs::metadata(&full_path).await { + Ok(m) => m, + Err(_) => return Ok(reject(StatusCode::NOT_FOUND, "not found\n")), + }; + if !meta.is_file() { + return Ok(reject(StatusCode::NOT_FOUND, "not found\n")); + } + let total_len = meta.len(); + if total_len == 0 { + return Ok(reject(StatusCode::NOT_FOUND, "empty file\n")); + } + + // Parse Range: bytes=start-end (single range only). + let mut start: u64 = 0; + let mut end: u64 = total_len.saturating_sub(1); + let mut partial = false; + if let Some(range) = req.headers().get("range").and_then(|v| v.to_str().ok()) { + if let Some(spec) = range.strip_prefix("bytes=") { + if let Some((a, b)) = spec.split_once('-') { + let a = a.trim(); + let b = b.trim(); + if !a.is_empty() { + if let Ok(s) = a.parse::() { + start = s; + } else { + start = total_len; // force 416 + } + } + if !b.is_empty() { + if let Ok(e) = b.parse::() { + end = e; + } else { + end = 0; + } + } + partial = true; + } + } + } + + if start >= total_len || end >= total_len || start > end { + let body = boxed_empty(); + let resp = Response::builder() + .status(StatusCode::RANGE_NOT_SATISFIABLE) + .header("accept-ranges", "bytes") + .header("content-range", format!("bytes */{total_len}")) + .body(body) + .unwrap(); + return Ok(resp); + } + + let content_len = end - start + 1; + let mut builder = Response::builder() + .header("content-type", "application/x-tar") + .header("accept-ranges", "bytes") + .header("content-length", content_len.to_string()) + .header("cache-control", "public, max-age=60"); + + if partial { + builder = builder + .status(StatusCode::PARTIAL_CONTENT) + .header("content-range", format!("bytes {start}-{end}/{total_len}")); + } else { + builder = builder.status(StatusCode::OK); + } + + if method == Method::HEAD { + return Ok(builder.body(boxed_empty()).unwrap()); + } + + let mut file = tokio::fs::File::open(&full_path).await?; + file.seek(std::io::SeekFrom::Start(start)).await?; + let reader = file.take(content_len); + let stream = ReaderStream::new(reader).map_ok(Frame::data); + let body = http_body_util::BodyExt::boxed(StreamBody::new(stream)); + Ok(builder.body(body).unwrap()) + } + + let addr: SocketAddr = bind.parse()?; + let listener = TcpListener::bind(addr).await?; + println!("snapshot_serve_ok: true"); + println!("dir: {}", dir.display()); + println!("bind: {}", bind); + + let base_dir = dir.to_path_buf(); + loop { + let (stream, _) = listener.accept().await?; + let io = TokioIo::new(stream); + let bd = base_dir.clone(); + tokio::spawn(async move { + let svc = service_fn(move |req| handle_request(req, bd.clone())); + let _ = http1::Builder::new().serve_connection(io, svc).await; + }); + } +} + pub async fn send_transaction( to: &str, amount: &str, diff --git a/crates/catalyst-cli/src/main.rs b/crates/catalyst-cli/src/main.rs index 7100cf1..fdc1b23 100644 --- a/crates/catalyst-cli/src/main.rs +++ b/crates/catalyst-cli/src/main.rs @@ -168,6 +168,9 @@ enum Commands { /// Path to the tar archive file (used to compute sha256/bytes) #[arg(long)] archive_path: PathBuf, + /// Optional TTL for this snapshot advertisement (seconds) + #[arg(long)] + ttl_seconds: Option, }, /// Download the published snapshot archive and restore it locally SyncFromSnapshot { @@ -195,6 +198,18 @@ enum Commands { /// How many snapshots/archives to retain in out_base_dir #[arg(long, default_value_t = 3)] retain: usize, + /// Optional TTL for the published snapshot advertisement (seconds) + #[arg(long)] + ttl_seconds: Option, + }, + /// Serve snapshot archives over HTTP (simple sidecar, supports Range requests) + SnapshotServe { + /// Directory containing `*.tar` archives + #[arg(long)] + dir: PathBuf, + /// Bind address, e.g. 0.0.0.0:8090 + #[arg(long, default_value = "0.0.0.0:8090")] + bind: String, }, /// Show a transaction receipt/status (and inclusion proof when applied) Receipt { @@ -426,14 +441,17 @@ async fn main() -> Result<()> { Commands::DbRestore { data_dir, from_dir } => { commands::db_restore(&data_dir, &from_dir).await?; } - Commands::SnapshotPublish { data_dir, snapshot_dir, archive_url, archive_path } => { - commands::snapshot_publish(&data_dir, &snapshot_dir, &archive_url, &archive_path).await?; + Commands::SnapshotPublish { data_dir, snapshot_dir, archive_url, archive_path, ttl_seconds } => { + commands::snapshot_publish(&data_dir, &snapshot_dir, &archive_url, &archive_path, ttl_seconds).await?; } Commands::SyncFromSnapshot { rpc_url, data_dir, work_dir } => { commands::sync_from_snapshot(&rpc_url, &data_dir, work_dir.as_deref()).await?; } - Commands::SnapshotMakeLatest { data_dir, out_base_dir, archive_url_base, retain } => { - commands::snapshot_make_latest(&data_dir, &out_base_dir, &archive_url_base, retain).await?; + Commands::SnapshotMakeLatest { data_dir, out_base_dir, archive_url_base, retain, ttl_seconds } => { + commands::snapshot_make_latest(&data_dir, &out_base_dir, &archive_url_base, retain, ttl_seconds).await?; + } + Commands::SnapshotServe { dir, bind } => { + commands::snapshot_serve(&dir, &bind).await?; } Commands::Receipt { tx_hash, rpc_url } => { commands::show_receipt(&tx_hash, &rpc_url).await?; diff --git a/crates/catalyst-rpc/src/lib.rs b/crates/catalyst-rpc/src/lib.rs index a66d853..3da9655 100644 --- a/crates/catalyst-rpc/src/lib.rs +++ b/crates/catalyst-rpc/src/lib.rs @@ -258,6 +258,12 @@ pub struct RpcSyncInfo { pub struct RpcSnapshotInfo { pub version: u32, pub created_at_ms: u64, + /// When this snapshot was published/advertised by the operator. + #[serde(default)] + pub published_at_ms: u64, + /// Optional expiry time for this snapshot ad (clients should ignore if expired). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at_ms: Option, pub chain_id: String, pub network_id: String, pub genesis_hash: String, diff --git a/docs/sync-guide.md b/docs/sync-guide.md index ee9eb11..98c2b44 100644 --- a/docs/sync-guide.md +++ b/docs/sync-guide.md @@ -76,6 +76,18 @@ This is the operator-friendly way to keep a “latest snapshot” published: You still need to serve `/var/lib/catalyst/eu/snapshots/*.tar` over HTTP(S) (e.g. nginx/caddy). After this runs, `catalyst_getSnapshotInfo` points at the newest archive URL. +### 3d) Optional: built-in snapshot HTTP sidecar + +If you don’t want nginx/caddy, you can run a simple HTTP sidecar that serves `*.tar` with Range support: + +```bash +./target/release/catalyst-cli snapshot-serve \ + --dir /var/lib/catalyst/eu/snapshots \ + --bind 0.0.0.0:8090 +``` + +Then you can set `--archive-url-base http://:8090` when publishing snapshots. + ### 4) Restore on the destination node Stop the node if it is running, then restore: