Coverage Report

Created: 2026-07-26 02:19

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/home/runner/work/lyquor/lyquor/toolchain/config/src/profile.rs
Line
Count
Source
1
use std::str::FromStr;
2
use std::sync::OnceLock;
3
4
use anyhow::{Result, bail};
5
use lyquor_primitives::{ChainPos, LyquidID};
6
use serde::{Deserialize, Deserializer, Serialize};
7
8
/// High-level network profile for Lyquor deployments.
9
///
10
/// This enum is shared across TLS, oracle key derivation and other places that
11
/// need to distinguish devnet/testnet/mainnet behavior.
12
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, Default)]
13
#[serde(rename_all = "lowercase")]
14
pub enum NetworkType {
15
    #[default]
16
    Devnet,
17
    Testnet,
18
    Mainnet,
19
}
20
21
impl NetworkType {
22
    /// Return the lowercase config identifier for this network type.
23
1
    pub fn as_str(&self) -> &'static str {
24
1
        match self {
25
0
            Self::Devnet => "devnet",
26
0
            Self::Testnet => "testnet",
27
1
            Self::Mainnet => "mainnet",
28
        }
29
1
    }
30
31
    /// Return the built-in network domain used when config does not override it.
32
22
    pub fn default_network_domain(&self) -> &'static str {
33
22
        match self {
34
22
            Self::Devnet => "dev.lyquor.net",
35
0
            Self::Testnet => "test.lyquor.net",
36
0
            Self::Mainnet => "lyquor.network",
37
        }
38
22
    }
39
40
2
    fn defaults(&self) -> BuiltinProfile {
41
2
        match self {
42
2
            Self::Devnet => BuiltinProfile {
43
2
                display_name: "Local Network",
44
2
                id: "devnet",
45
2
                bartender_id: "Lyquid-f7vljmg2ymknxo6ox4lwdblt6ldygrc2kf6aa",
46
2
                bartender_addr: "0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0",
47
2
                submitter_key: Some("0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"),
48
2
                init_chain_position: ChainPos::ZERO,
49
2
                finality: "\"latest\"",
50
2
            },
51
0
            Self::Testnet => BuiltinProfile {
52
0
                display_name: "Test Network",
53
0
                id: "testnet",
54
0
                bartender_id: "",
55
0
                bartender_addr: "",
56
0
                submitter_key: None,
57
0
                init_chain_position: ChainPos::ZERO,
58
0
                finality: "\"latest\"",
59
0
            },
60
0
            Self::Mainnet => BuiltinProfile {
61
0
                display_name: "Main Network",
62
0
                id: "mainnet",
63
0
                bartender_id: "",
64
0
                bartender_addr: "",
65
0
                submitter_key: None,
66
0
                init_chain_position: ChainPos::ZERO,
67
0
                finality: "\"latest\"",
68
0
            },
69
        }
70
2
    }
71
72
    /// Return the built-in submitter key for local development profiles.
73
    ///
74
    /// Production profiles intentionally have no built-in signer.
75
2
    pub fn default_submitter_key(&self) -> Option<&'static str> {
76
2
        self.defaults().submitter_key
77
2
    }
78
}
79
80
impl FromStr for NetworkType {
81
    type Err = String;
82
83
3
    fn from_str(s: &str) -> Result<Self, Self::Err> {
84
3
        match s {
85
3
            "devnet" => 
Ok(Self::Devnet)2
,
86
1
            "testnet" => 
Ok(Self::Testnet)0
,
87
1
            "mainnet" => 
Ok(Self::Mainnet)0
,
88
1
            _ => Err(format!("Invalid network: {s}")),
89
        }
90
3
    }
91
}
92
93
16
fn default_profile_base() -> NetworkType {
94
16
    NetworkType::Devnet
95
16
}
96
97
/// Structured config for selecting a built-in profile and overriding selected fields.
98
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99
#[serde(deny_unknown_fields)]
100
pub struct ProfileConfig {
101
    /// Base built-in profile to inherit values from.
102
    #[serde(default = "default_profile_base")]
103
    pub base: NetworkType,
104
    /// Optional network domain override. The config value is validated as-is:
105
    /// ASCII-only, lowercase only, no leading/trailing whitespace, and no
106
    /// leading/trailing dots.
107
    #[serde(default, deserialize_with = "optional_domain_from_str")]
108
    pub network_domain: Option<String>,
109
    /// Optional WebSocket endpoint override for the sequencer.
110
    #[serde(default)]
111
    pub sequencer: Option<String>,
112
    /// Optional HTTP JSON-RPC endpoint override for Lyquid instance sequencer reads.
113
    #[serde(default)]
114
    pub sequencer_rpc: Option<String>,
115
}
116
117
impl Default for ProfileConfig {
118
15
    fn default() -> Self {
119
15
        Self {
120
15
            base: default_profile_base(),
121
15
            network_domain: None,
122
15
            sequencer: None,
123
15
            sequencer_rpc: None,
124
15
        }
125
15
    }
126
}
127
128
impl ProfileConfig {
129
    /// Validate configured profile overrides without resolving the sequencer endpoint.
130
14
    pub fn validate(&self) -> Result<()> {
131
14
        self.resolved_network_domain()
?0
;
132
14
        if self.sequencer_rpc.is_some() && 
self.sequencer4
.
is_none4
() {
133
1
            bail!("sequencer_rpc requires sequencer");
134
13
        }
135
13
        Ok(())
136
14
    }
137
138
    /// Return the configured network domain, or the built-in domain for the base profile.
139
20
    pub fn resolved_network_domain(&self) -> Result<String> {
140
20
        match self.network_domain.as_deref() {
141
6
            Some(network_domain) => validate_domain_name(network_domain),
142
14
            None => Ok(self.base.default_network_domain().to_string()),
143
        }
144
20
    }
145
146
    /// Resolve this profile config into a concrete profile for a sequencer endpoint.
147
0
    pub fn resolve(&self, sequencer: String) -> Result<LyquorProfile> {
148
0
        match self.base {
149
0
            NetworkType::Devnet => Ok(LyquorProfile::devnet(sequencer, self.resolved_network_domain()?)),
150
0
            NetworkType::Testnet | NetworkType::Mainnet => bail!("Network is not supported."),
151
        }
152
0
    }
153
}
154
155
/// Validate a DNS-shaped domain name exactly as provided without normalizing it.
156
499
pub fn validate_domain_name(input: &str) -> Result<String> {
157
499
    if input.is_empty() {
158
1
        bail!("domain name is empty");
159
498
    }
160
498
    if input != input.trim() {
161
4
        bail!("domain name must not contain leading or trailing whitespace");
162
494
    }
163
494
    if !input.is_ascii() {
164
2
        bail!("domain name must be ASCII");
165
492
    }
166
492
    if input != input.to_ascii_lowercase() {
167
3
        bail!("domain name must be lowercase");
168
489
    }
169
489
    if input == "." {
170
2
        bail!("domain name cannot be root");
171
487
    }
172
487
    if input.starts_with('.') || 
input478
.
ends_with478
('.') {
173
13
        bail!("domain name must not start or end with '.'");
174
474
    }
175
176
474
    Ok(input.to_string())
177
499
}
178
179
13
pub(crate) fn optional_domain_from_str<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
180
13
where
181
13
    D: Deserializer<'de>,
182
{
183
13
    let raw = Option::<String>::deserialize(deserializer)
?0
;
184
13
    raw.map(|domain| validate_domain_name(&domain).map_err(serde::de::Error::custom))
185
13
        .transpose()
186
13
}
187
188
#[derive(Debug, Clone, Copy)]
189
struct BuiltinProfile {
190
    display_name: &'static str,
191
    id: &'static str,
192
    bartender_id: &'static str,
193
    bartender_addr: &'static str,
194
    submitter_key: Option<&'static str>,
195
    init_chain_position: ChainPos,
196
    finality: &'static str,
197
}
198
199
/// Fully resolved network profile consumed by node startup and tooling.
200
#[derive(Debug)]
201
pub struct LyquorProfile {
202
    display_name: String,
203
    id: String,
204
    network_type: NetworkType,
205
    network_domain: String,
206
    sequencer: String,
207
    bartender_id_raw: String,
208
    bartender_addr: String,
209
    init_chain_position: ChainPos,
210
    finality: String,
211
    bartender_id: OnceLock<LyquidID>,
212
}
213
214
impl LyquorProfile {
215
0
    fn devnet(sequencer: String, network_domain: String) -> Self {
216
0
        let defaults = NetworkType::Devnet.defaults();
217
0
        Self {
218
0
            display_name: defaults.display_name.to_string(),
219
0
            id: defaults.id.to_string(),
220
0
            network_type: NetworkType::Devnet,
221
0
            network_domain,
222
0
            sequencer,
223
0
            bartender_id_raw: defaults.bartender_id.to_string(),
224
0
            bartender_addr: defaults.bartender_addr.to_string(),
225
0
            init_chain_position: defaults.init_chain_position,
226
0
            finality: defaults.finality.to_string(),
227
0
            bartender_id: OnceLock::new(),
228
0
        }
229
0
    }
230
231
    /// Human-readable profile name.
232
0
    pub fn display_name(&self) -> &str {
233
0
        &self.display_name
234
0
    }
235
236
    /// Stable lowercase profile identifier.
237
0
    pub fn id(&self) -> &str {
238
0
        &self.id
239
0
    }
240
241
    /// Sequencer endpoint configured for this profile.
242
0
    pub fn sequencer(&self) -> &str {
243
0
        &self.sequencer
244
0
    }
245
246
    /// Network type used to choose built-in defaults.
247
0
    pub fn network_type(&self) -> NetworkType {
248
0
        self.network_type
249
0
    }
250
251
    /// Network domain: the node identity namespace, doubling as the SPIFFE trust domain for
252
    /// node-to-node mutual TLS. The DNS suffix for external hostname publishing and ACME
253
    /// HTTPS serving is a separate value (`network.serving_dns_suffix`) defaulting to this one.
254
0
    pub fn network_domain(&self) -> &str {
255
0
        &self.network_domain
256
0
    }
257
258
    /// Built-in bartender Lyquid ID for this profile.
259
0
    pub fn bartender_id(&self) -> &LyquidID {
260
0
        self.bartender_id
261
0
            .get_or_init(|| LyquidID::from_str(&self.bartender_id_raw).expect("Invalid LyquidID"))
262
0
    }
263
264
    /// Built-in bartender contract address for this profile.
265
0
    pub fn bartender_addr(&self) -> &str {
266
0
        &self.bartender_addr
267
0
    }
268
269
    /// Initial sequencer position from which this profile starts recovery.
270
0
    pub fn init_chain_position(&self) -> ChainPos {
271
0
        self.init_chain_position
272
0
    }
273
274
    /// Backend finality tag used by this profile.
275
0
    pub fn finality(&self) -> &str {
276
0
        &self.finality
277
0
    }
278
}
279
280
#[cfg(test)]
281
mod tests {
282
    use super::*;
283
    use lyquor_test::test;
284
285
    #[test]
286
    fn validate_domain_name_accepts_canonical_form() {
287
        assert_eq!(validate_domain_name("example.com").unwrap(), "example.com");
288
    }
289
290
    #[test]
291
    fn validate_domain_name_rejects_invalid_forms() {
292
        for domain in [
293
            "",
294
            " example.com",
295
            "example.com ",
296
            "münich.example",
297
            "Example.com",
298
            ".example.com",
299
            "example.com.",
300
            ".",
301
        ] {
302
            assert!(
303
                validate_domain_name(domain).is_err(),
304
                "expected invalid domain name: {domain:?}"
305
            );
306
        }
307
    }
308
}