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