pub mod chain_node;
mod chain_routes;
mod genesis;
mod http;
mod requests;
mod routes;
use std::io;
use std::net::TcpListener;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::thread;
use std::time::{SystemTime, UNIX_EPOCH};
use cybergraph::native::{ImportReport, NativeNode};
use cybergraph::{NeuronId, Particle};
use crate::network::Network;
pub struct Node {
moniker: String,
network: Network,
genesis_unix: u64,
native: NativeNode,
failed: bool,
}
impl Node {
pub fn open(home: PathBuf, moniker: String) -> io::Result<Self> {
std::fs::create_dir_all(&home)?;
let database = home.join("bbg");
let legacy = home.join("log");
if legacy.try_exists()? && !database.try_exists()? {
return Err(io::Error::other(
"legacy log requires cyber storage import-legacy",
));
}
let genesis = genesis::load(&home, !database.try_exists()?)?;
let native = NativeNode::open(&database, &genesis.canonical).map_err(io::Error::other)?;
if legacy.try_exists()? {
let log = genesis::read_bounded(&legacy, genesis::MAX_LEGACY_LOG)?;
if !native
.legacy_source_matches(&log)
.map_err(io::Error::other)?
{
return Err(io::Error::other("legacy log differs from the imported source; explicit storage recovery is required"));
}
}
Ok(Self {
moniker,
network: Network::SpacePussyTest,
genesis_unix: genesis.time,
native,
failed: false,
})
}
pub fn height(&self) -> u64 {
self.native.graph().bbg.state.height
}
pub fn root_hex(&self) -> String {
hex(&self.native.graph().bbg.state.root())
}
pub fn signal_count(&self) -> u64 {
self.native.graph().bbg.state.signals.len() as u64
}
pub fn intent_count(&self) -> u64 {
self.native.graph().bbg.state.intents.len() as u64
}
pub fn neuron_count(&self) -> u64 {
self.native.graph().bbg.state.neurons.len() as u64
}
pub fn particle_count(&self) -> u64 {
self.native.graph().bbg.state.particles.len() as u64
}
pub fn axon_count(&self) -> u64 {
self.native.graph().bbg.state.axons_out.len() as u64
}
pub fn balance(&self, neuron: &NeuronId) -> u64 {
self.native.balance(neuron)
}
pub fn pi_supply(&self) -> u64 {
self.native.supply()
}
pub fn status_cybermark(&self) -> String {
let stats = self.native.graph().bbg.statistics();
format!(
"---\nparticle: status\nchain: {}\nprotocol: soft3/spacepussy-test/v2\nengine: cybergraph+bbg\nversion: {}\nmoniker: {}\nheight: {}\ngenesis: {}\nbbg-root: {}\nsignals: {}\nneurons: {}\nparticles: {}\naxons: {}\nnode-count: {}\nmax-degree: {}\nrole: {}\ndenom: {}\nprefix: {}\nrpc: {}\ncatching-up: false\nstorage: bbg-fjall\nintents: {}\n---\nthe state of spacepussy-test at height {}\n",
self.network.chain_id(), env!("CARGO_PKG_VERSION"),
self.moniker.replace(['\n', '\r'], " "), self.height(),
self.genesis_unix, self.root_hex(), self.signal_count(),
self.neuron_count(), self.particle_count(), self.axon_count(),
stats.node_count, stats.max_degree, self.network.role(),
self.network.denom(), self.network.bech32_prefix(), self.network.rpc(),
self.intent_count(), self.height(),
)
}
}
pub fn import_legacy(home: &Path) -> io::Result<ImportReport> {
let genesis = genesis::load(home, false)?;
let log = genesis::read_bounded(&home.join("log"), genesis::MAX_LEGACY_LOG)?;
NativeNode::import_legacy(&home.join("bbg"), &genesis.canonical, &log).map_err(io::Error::other)
}
fn key32(s: &str) -> Particle {
let candidate = s.strip_prefix("0x").unwrap_or(s);
if !candidate.is_empty()
&& candidate.len() <= 64
&& candidate.bytes().all(|b| b.is_ascii_hexdigit())
{
let padded = format!("{candidate:0>64}");
let mut out = [0u8; 32];
for (i, byte) in out.iter_mut().enumerate() {
*byte = u8::from_str_radix(&padded[i * 2..i * 2 + 2], 16)
.expect("validated hexadecimal pair");
}
out
} else {
hash32(s.as_bytes())
}
}
fn hash32(bytes: &[u8]) -> Particle {
let digest = hemera::hash(bytes);
let mut out = [0; 32];
out.copy_from_slice(&digest.as_bytes()[..32]);
out
}
fn hex(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn default_home() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join(".spacepussy-test")
}
pub fn run(home: PathBuf, bind: &str, moniker: &str) -> io::Result<()> {
let node = Node::open(home.clone(), moniker.to_owned())?;
let listener = TcpListener::bind(bind)?;
eprintln!(
"soft3 node ยท {} ยท moniker={} ยท home={}",
node.network.chain_id(),
moniker,
home.display()
);
eprintln!(
" listening {bind}; height {}; root {}",
node.height(),
node.root_hex()
);
let node = Arc::new(Mutex::new(node));
for stream in listener.incoming() {
match stream {
Ok(stream) => {
let node = Arc::clone(&node);
thread::spawn(move || {
if let Err(error) = http::handle_client(stream, &node) {
eprintln!(" request error: {error}");
}
});
}
Err(error) => eprintln!(" accept error: {error}"),
}
}
Ok(())
}
pub fn run_chain(opts: chain_node::ChainOpts, bind: &str) -> io::Result<()> {
let listener = TcpListener::bind(bind)?;
let node = chain_node::ChainNode::open(opts)?;
let tip = node.chain.tip();
eprintln!(
"soft3 node ยท chain {} ยท moniker={} ยท home={}",
node.chain_name, node.moniker, node.home.display()
);
eprintln!(" endpoint {}", hex(node.net.id().as_bytes()));
for s in node.net.bound() {
eprintln!(" radio {s}");
}
eprintln!(" http {bind}; height {}; tip {}", tip.height, hex(&tip.id));
for stream in listener.incoming() {
match stream {
Ok(stream) => {
let node = Arc::clone(&node);
thread::spawn(move || {
if let Err(error) = http::handle_client_with(stream, |request| chain_routes::route(&node, request)) {
eprintln!(" request error: {error}");
}
});
}
Err(error) => eprintln!(" accept error: {error}"),
}
}
Ok(())
}
#[cfg(test)]
mod legacy_guard_tests {
use super::*;
fn temp_home(label: &str) -> PathBuf {
let mut dir = std::env::temp_dir();
dir.push(format!(
"soft3-node-open-test-{label}-{}-{}",
std::process::id(),
now_unix()
));
dir
}
#[test]
fn open_rejects_legacy_log_without_database() {
let home = temp_home("legacy-only");
std::fs::create_dir_all(&home).unwrap();
std::fs::write(home.join("log"), b"legacy-signal-log").unwrap();
let error = match Node::open(home.clone(), "test".into()) {
Err(error) => error,
Ok(_) => panic!("expected the legacy-migration guard to reject this home"),
};
assert_eq!(
error.to_string(),
"legacy log requires cyber storage import-legacy"
);
std::fs::remove_dir_all(&home).ok();
}
#[test]
fn open_skips_legacy_guard_once_database_exists() {
let home = temp_home("legacy-and-db");
std::fs::create_dir_all(&home).unwrap();
std::fs::write(home.join("log"), b"legacy-signal-log").unwrap();
std::fs::create_dir_all(home.join("bbg")).unwrap();
let error = match Node::open(home.clone(), "test".into()) {
Err(error) => error,
Ok(_) => panic!("expected genesis load to fail on a database with no genesis.json"),
};
assert_ne!(
error.to_string(),
"legacy log requires cyber storage import-legacy"
);
std::fs::remove_dir_all(&home).ok();
}
#[test]
fn open_succeeds_on_a_fresh_home_without_a_legacy_log() {
let home = temp_home("fresh");
let node = match Node::open(home.clone(), "test".into()) {
Ok(node) => node,
Err(error) => panic!("expected a fresh home to open cleanly: {error}"),
};
assert_eq!(node.height(), 0);
std::fs::remove_dir_all(&home).ok();
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key32_passes_through_full_hex() {
let hex_str = "ab".repeat(32);
let expected: Vec<u8> = (0..32).map(|_| 0xab).collect();
assert_eq!(key32(&hex_str).as_slice(), expected.as_slice());
}
#[test]
fn key32_strips_0x_prefix() {
let hex_str = "ab".repeat(32);
assert_eq!(key32(&hex_str), key32(&format!("0x{hex_str}")));
}
#[test]
fn key32_left_pads_short_hex() {
assert_eq!(key32("ab"), key32(&format!("{}ab", "0".repeat(62))));
}
#[test]
fn key32_accepts_uppercase_hex() {
let hex_str = "ab".repeat(32);
assert_eq!(key32(&hex_str), key32(&hex_str.to_uppercase()));
}
#[test]
fn key32_falls_back_to_hash_for_non_hex_label() {
assert_eq!(key32("not-hex-zz"), hash32(b"not-hex-zz"));
}
#[test]
fn key32_falls_back_to_hash_when_longer_than_32_bytes() {
let too_long = "ab".repeat(33);
assert_eq!(key32(&too_long), hash32(too_long.as_bytes()));
}
#[test]
fn key32_falls_back_to_hash_for_empty_string() {
assert_eq!(key32(""), hash32(b""));
assert_eq!(key32("0x"), hash32(b"0x"));
}
#[test]
fn hash32_is_deterministic_and_distinct() {
assert_eq!(hash32(b"a"), hash32(b"a"));
assert_ne!(hash32(b"a"), hash32(b"b"));
}
}