Coverage Report

Created: 2026-06-13 00:22

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