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