Skip to main content

arknet_common/
config.rs

1//! Shared configuration loading.
2//!
3//! Loads config from (in order, later overrides earlier):
4//! 1. Embedded defaults.
5//! 2. `node.toml` at the provided path (or `$HOME/.arknet/node.toml` if `None`).
6//! 3. Environment variables prefixed `ARKNET_` (double-underscore as section separator).
7//!
8//! Phase 0 stub: parses the shape without validating semantics. Validation
9//! (stake minimums, role combinations, hardware budgets) lands in [`arknet-node`]
10//! during Weeks 11-12.
11
12use std::path::{Path, PathBuf};
13
14use figment::{
15    providers::{Env, Format, Serialized, Toml},
16    Figment,
17};
18use serde::{Deserialize, Serialize};
19
20use crate::errors::{CommonError, Result};
21
22/// Top-level node configuration.
23///
24/// Deserialized from `node.toml`. See [`docs/NODE_OPERATOR_GUIDE.md`][guide]
25/// for the operator-facing reference.
26///
27/// [guide]: ../../../docs/NODE_OPERATOR_GUIDE.md
28#[derive(Clone, Debug, Default, Serialize, Deserialize)]
29#[serde(deny_unknown_fields)]
30pub struct NodeConfig {
31    /// Identity + data-dir settings.
32    #[serde(default)]
33    pub node: NodeSection,
34
35    /// Which roles are active on this node.
36    #[serde(default)]
37    pub roles: RolesSection,
38
39    /// Per-role resource budgets.
40    #[serde(default)]
41    pub resources: ResourcesSection,
42
43    /// Operator preferences (payout, region, reward thresholds).
44    #[serde(default)]
45    pub operator: OperatorSection,
46
47    /// P2P networking.
48    #[serde(default)]
49    pub network: NetworkSection,
50
51    /// Telemetry + observability.
52    #[serde(default)]
53    pub telemetry: TelemetrySection,
54
55    /// Trusted Execution Environment (TEE) settings for confidential inference.
56    #[serde(default)]
57    pub tee: TeeSection,
58
59    /// Data availability layer (Celestia / EigenDA).
60    #[serde(default)]
61    pub da: DaSection,
62}
63
64/// `[node]` section.
65#[derive(Clone, Debug, Serialize, Deserialize)]
66#[serde(deny_unknown_fields)]
67pub struct NodeSection {
68    /// Human-readable node name (shown in logs + explorer).
69    pub name: String,
70    /// `mainnet` | `testnet` | `devnet`.
71    pub network: String,
72    /// Data directory (state DB, keys, model cache).
73    pub data_dir: PathBuf,
74    /// Log verbosity (`trace`, `debug`, `info`, `warn`, `error`).
75    pub log_level: String,
76}
77
78impl Default for NodeSection {
79    fn default() -> Self {
80        Self {
81            name: "arknet-node".into(),
82            network: "devnet".into(),
83            data_dir: PathBuf::from("/var/lib/arknet"),
84            log_level: "info".into(),
85        }
86    }
87}
88
89/// `[roles]` section.
90#[derive(Clone, Debug, Default, Serialize, Deserialize)]
91#[serde(deny_unknown_fields)]
92pub struct RolesSection {
93    /// Run L1 validator.
94    #[serde(default)]
95    pub validator: bool,
96    /// Run L2 router.
97    #[serde(default)]
98    pub router: bool,
99    /// Run L2 compute (inference).
100    #[serde(default)]
101    pub compute: bool,
102    /// Run L2 verifier.
103    #[serde(default)]
104    pub verifier: bool,
105}
106
107/// `[resources]` section.
108#[derive(Clone, Debug, Default, Serialize, Deserialize)]
109#[serde(deny_unknown_fields)]
110pub struct ResourcesSection {
111    /// Compute-role hardware budget.
112    #[serde(default)]
113    pub compute: ComputeResources,
114    /// Router-role hardware budget.
115    #[serde(default)]
116    pub router: RouterResources,
117    /// Verifier-role hardware budget.
118    #[serde(default)]
119    pub verifier: VerifierResources,
120    /// Validator-role settings.
121    #[serde(default)]
122    pub validator: ValidatorResources,
123}
124
125/// Per-compute-role budget.
126#[derive(Clone, Debug, Default, Serialize, Deserialize)]
127#[serde(deny_unknown_fields)]
128pub struct ComputeResources {
129    /// GPU device indices to use (CUDA / ROCm / Metal).
130    #[serde(default)]
131    pub gpu_devices: Vec<u32>,
132    /// VRAM cap across all loaded models (GiB).
133    #[serde(default)]
134    pub max_vram_gb: u32,
135    /// Cap on concurrent inference jobs.
136    #[serde(default)]
137    pub max_concurrent_jobs: u32,
138    /// Model IDs to pre-load at startup, formatted `"<id>:<quant>"`.
139    #[serde(default)]
140    pub loaded_models: Vec<String>,
141    /// Allow the scheduler to evict loaded models for higher-demand ones.
142    #[serde(default)]
143    pub model_swap_enabled: bool,
144}
145
146/// Per-router-role budget.
147#[derive(Clone, Debug, Default, Serialize, Deserialize)]
148#[serde(deny_unknown_fields)]
149pub struct RouterResources {
150    /// Cap on % of total CPU usable for routing.
151    #[serde(default)]
152    pub cpu_percent: u32,
153    /// Maximum in-flight routes.
154    #[serde(default)]
155    pub max_concurrent_routes: u32,
156    /// Outbound bandwidth cap (Mbps).
157    #[serde(default)]
158    pub bandwidth_mbps: u32,
159}
160
161/// Per-verifier-role budget.
162#[derive(Clone, Debug, Default, Serialize, Deserialize)]
163#[serde(deny_unknown_fields)]
164pub struct VerifierResources {
165    /// `true` if verifier shares GPUs with the compute role.
166    #[serde(default)]
167    pub gpu_share_with_compute: bool,
168    /// Cap on verifications per hour.
169    #[serde(default)]
170    pub max_verifications_per_hour: u32,
171}
172
173/// Per-validator-role settings.
174#[derive(Clone, Debug, Default, Serialize, Deserialize)]
175#[serde(deny_unknown_fields)]
176pub struct ValidatorResources {
177    /// State DB path (defaults to `$data_dir/l1`).
178    #[serde(default)]
179    pub state_db_path: Option<PathBuf>,
180    /// Max peers to maintain for gossip.
181    #[serde(default)]
182    pub gossip_peers_max: u32,
183    /// Optional remote signer (tmkms-compatible).
184    #[serde(default)]
185    pub remote_signer: Option<String>,
186}
187
188/// `[operator]` section.
189#[derive(Clone, Debug, Default, Serialize, Deserialize)]
190#[serde(deny_unknown_fields)]
191pub struct OperatorSection {
192    /// Payout address for rewards.
193    #[serde(default)]
194    pub payout_address: Option<String>,
195    /// Preferred region (for latency-aware routing).
196    #[serde(default)]
197    pub preferred_region: Option<String>,
198    /// Minimum reward-per-job threshold below which this node won't bid.
199    #[serde(default)]
200    pub min_reward_per_job: Option<String>,
201    /// Auto-pull models when they become profitable.
202    #[serde(default)]
203    pub auto_model_pull: bool,
204    /// Auto-rebalance across pools based on demand.
205    #[serde(default)]
206    pub auto_pool_rebalance: bool,
207}
208
209/// `[network]` section.
210#[derive(Clone, Debug, Serialize, Deserialize)]
211#[serde(deny_unknown_fields)]
212pub struct NetworkSection {
213    /// P2P listen address.
214    pub p2p_listen: String,
215    /// RPC listen address (local gRPC/REST).
216    pub rpc_listen: String,
217    /// Metrics listen address (Prometheus `/metrics`).
218    pub metrics_listen: String,
219    /// Bootstrap peers (libp2p multiaddrs).
220    #[serde(default)]
221    pub bootstrap_peers: Vec<String>,
222    /// Externally reachable address (auto-detected if empty).
223    #[serde(default)]
224    pub external_address: Option<String>,
225    /// Inbound peer cap.
226    #[serde(default)]
227    pub max_inbound_peers: u32,
228    /// Outbound peer cap.
229    #[serde(default)]
230    pub max_outbound_peers: u32,
231}
232
233impl Default for NetworkSection {
234    fn default() -> Self {
235        Self {
236            p2p_listen: "0.0.0.0:26656".into(),
237            rpc_listen: "127.0.0.1:26657".into(),
238            metrics_listen: "127.0.0.1:9090".into(),
239            bootstrap_peers: Vec::new(),
240            external_address: None,
241            max_inbound_peers: 60,
242            max_outbound_peers: 20,
243        }
244    }
245}
246
247/// `[telemetry]` section.
248#[derive(Clone, Debug, Default, Serialize, Deserialize)]
249#[serde(deny_unknown_fields)]
250pub struct TelemetrySection {
251    /// Enable the Prometheus scrape endpoint.
252    #[serde(default)]
253    pub prometheus_enabled: bool,
254    /// OTLP endpoint for distributed tracing (optional).
255    #[serde(default)]
256    pub otlp_endpoint: Option<String>,
257    /// Sentry DSN for panic reports (optional, operator opt-in).
258    #[serde(default)]
259    pub sentry_dsn: Option<String>,
260}
261
262/// `[tee]` section — confidential inference via hardware TEE.
263///
264/// When `enabled = true`, the node generates an enclave keypair at boot,
265/// registers its TEE capability on-chain, and accepts `prefer_tee`
266/// requests with encrypted prompts.
267///
268/// ```toml
269/// [tee]
270/// enabled = true
271/// platform = "intel-tdx"   # or "amd-sev-snp"
272/// ```
273#[derive(Clone, Debug, Default, Serialize, Deserialize)]
274#[serde(deny_unknown_fields)]
275pub struct TeeSection {
276    /// Enable TEE mode. Requires actual TEE hardware.
277    #[serde(default)]
278    pub enabled: bool,
279    /// TEE platform: `"intel-tdx"`, `"amd-sev-snp"`, or `"arm-cca"`.
280    #[serde(default)]
281    pub platform: Option<String>,
282    /// Path to the enclave keypair file. Defaults to `<data_dir>/keys/enclave.key`.
283    #[serde(default)]
284    pub enclave_key_path: Option<PathBuf>,
285}
286
287/// Data availability layer settings.
288#[derive(Clone, Debug, Default, Serialize, Deserialize)]
289pub struct DaSection {
290    /// DA layer to use: `"inline"` (default, no offload), `"celestia"`, `"eigenda"`.
291    #[serde(default)]
292    pub layer: String,
293    /// RPC endpoint of the DA node (e.g. `http://localhost:26658` for Celestia light node).
294    #[serde(default)]
295    pub endpoint: String,
296    /// Namespace identifier (hex). Defaults to the arknet namespace.
297    #[serde(default)]
298    pub namespace: String,
299    /// Bearer auth token for the DA node RPC.
300    #[serde(default)]
301    pub auth_token: String,
302}
303
304impl NodeConfig {
305    /// Load configuration by layering defaults, the TOML at `path`, and `ARKNET_*` env vars.
306    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
307        Figment::new()
308            .merge(Serialized::defaults(NodeConfig::default()))
309            .merge(Toml::file(path.as_ref()))
310            .merge(Env::prefixed("ARKNET_").split("__"))
311            .extract::<Self>()
312            .map_err(|e| CommonError::Config(e.to_string()))
313    }
314
315    /// Load defaults + env only (no file).
316    pub fn load_env_only() -> Result<Self> {
317        Figment::new()
318            .merge(Serialized::defaults(NodeConfig::default()))
319            .merge(Env::prefixed("ARKNET_").split("__"))
320            .extract::<Self>()
321            .map_err(|e| CommonError::Config(e.to_string()))
322    }
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328
329    #[test]
330    fn defaults_are_sensible() {
331        let c = NodeConfig::default();
332        assert_eq!(c.node.network, "devnet");
333        assert_eq!(c.node.log_level, "info");
334        assert!(!c.roles.validator);
335        assert!(!c.roles.router);
336        assert!(!c.roles.compute);
337        assert!(!c.roles.verifier);
338        assert_eq!(c.network.p2p_listen, "0.0.0.0:26656");
339    }
340
341    #[test]
342    fn loads_minimal_toml() {
343        let tmp = tempdir();
344        let path = tmp.path().join("node.toml");
345        std::fs::write(
346            &path,
347            r#"
348[node]
349name     = "test-node"
350network  = "testnet"
351data_dir = "/tmp/arknet-test"
352log_level = "debug"
353
354[roles]
355router  = true
356compute = true
357"#,
358        )
359        .unwrap();
360
361        let c = NodeConfig::load(&path).expect("config loads");
362        assert_eq!(c.node.name, "test-node");
363        assert_eq!(c.node.network, "testnet");
364        assert!(c.roles.router);
365        assert!(c.roles.compute);
366        assert!(!c.roles.validator);
367    }
368
369    #[test]
370    fn rejects_unknown_fields() {
371        let tmp = tempdir();
372        let path = tmp.path().join("node.toml");
373        std::fs::write(
374            &path,
375            r#"
376[node]
377name = "x"
378network = "devnet"
379data_dir = "/tmp"
380log_level = "info"
381mystery_field = 42
382"#,
383        )
384        .unwrap();
385
386        let res = NodeConfig::load(&path);
387        assert!(res.is_err(), "expected deny_unknown_fields to reject");
388    }
389
390    /// Minimal inline `tempdir` to avoid pulling the `tempfile` crate into `arknet-common`.
391    ///
392    /// Uniqueness combines pid + a monotonic counter — nanos alone collide
393    /// when two parallel tests reach this helper in the same tick on fast
394    /// runners (macOS hit this on CI).
395    fn tempdir() -> TempDir {
396        static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
397        let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
398        let pid = std::process::id();
399        let mut base = std::env::temp_dir();
400        base.push(format!("arknet-test-{pid}-{seq}"));
401        std::fs::create_dir_all(&base).unwrap();
402        TempDir { path: base }
403    }
404
405    struct TempDir {
406        path: std::path::PathBuf,
407    }
408
409    impl TempDir {
410        fn path(&self) -> &std::path::Path {
411            &self.path
412        }
413    }
414
415    impl Drop for TempDir {
416        fn drop(&mut self) {
417            let _ = std::fs::remove_dir_all(&self.path);
418        }
419    }
420}