/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 | | } |