Skip to main content

paralock_cli/
config.rs

1//! `paralock.toml` and `--devnet`: everything a command needs, resolved and checked before anything connects.
2//!
3//! Keys never sit in `paralock.toml` by value unless `allow_inline_keys = true`: each key comes from an environment
4//! variable (`key_env`) or a file (`key_file`, which should be readable by you only). See `paralock.example.toml`.
5
6use std::collections::BTreeMap;
7use std::net::SocketAddr;
8use std::path::{Path, PathBuf};
9use std::time::Duration;
10
11use paralock::{
12    ClaimLimits, ClientAddress, CorsOrigins, DEVNET_POLICY, EvmChain, Hex, MAINNET_POLICY, MakerLimits, Network,
13    RateLimit, ServerOptions, TakerLimits, TakerTimeouts, TimeoutPolicy,
14};
15use paralock_core::protocol::DEFAULT_MAKER_LIMITS;
16use serde::Deserialize;
17use zeroize::Zeroizing;
18
19use crate::amount;
20use crate::devnet::{self, DevnetFile};
21use crate::error::{CliError, CliResult};
22use crate::output::warn;
23use crate::state;
24
25/// The global options that decide where the configuration comes from.
26#[derive(Debug, Clone, Default)]
27pub struct Globals {
28    /// Use the local devnet (`devnet.json`) instead of `paralock.toml`.
29    pub devnet: bool,
30    /// An explicit `paralock.toml`.
31    pub config: Option<PathBuf>,
32    /// Overrides the maker URL.
33    pub maker_url: Option<String>,
34    /// Devnet only: use anvil dev account N as this role's EVM account.
35    pub evm_account: Option<usize>,
36    /// Devnet only: reach Zcash through lightwalletd instead of the node's RPC.
37    pub lightwalletd: bool,
38}
39
40/// Which side the command plays: the keys differ.
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Role {
43    /// quote, swap, pay, swaps, resume, refund.
44    Taker,
45    /// maker.
46    Maker,
47}
48
49/// Where a private key comes from.
50#[derive(Clone)]
51pub enum KeySource {
52    /// An environment variable.
53    Env(String),
54    /// A file (its first line; `0x` optional).
55    File(PathBuf),
56    /// Written in the configuration (only with `allow_inline_keys`), or a public anvil key on the devnet.
57    Inline(Zeroizing<String>, String),
58    /// Devnet: a key this binary created in the state directory.
59    Generated(PathBuf),
60}
61
62impl std::fmt::Debug for KeySource {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        f.write_str(&self.describe())
65    }
66}
67
68impl KeySource {
69    /// Where the key comes from, for messages (never the key itself).
70    pub fn describe(&self) -> String {
71        match self {
72            Self::Env(name) => format!("${name}"),
73            Self::File(path) => path.display().to_string(),
74            Self::Inline(_, origin) => origin.clone(),
75            Self::Generated(path) => format!("{} (devnet key)", path.display()),
76        }
77    }
78
79    /// Reads the key, as hex without `0x`, checking that it is a secp256k1 private key. Warns when a key file is
80    /// readable by other users.
81    pub fn load(&self) -> CliResult<Zeroizing<String>> {
82        let raw = match self {
83            Self::Env(name) => Zeroizing::new(
84                std::env::var(name)
85                    .map_err(|_| {
86                        CliError::usage(format!("${name} is not set")).hint(format!("export {name}=<hex private key>"))
87                    })?
88                    .trim()
89                    .to_owned(),
90            ),
91            Self::File(path) => {
92                let text = Zeroizing::new(
93                    std::fs::read_to_string(path)
94                        .map_err(|e| CliError::usage(format!("can't read the key file {}: {e}", path.display())))?,
95                );
96                warn_if_shared(path, "key file");
97                Zeroizing::new(text.lines().next().unwrap_or("").trim().to_owned())
98            }
99            Self::Inline(key, _) => key.clone(),
100            Self::Generated(path) => devnet::load_or_create_key(path)?,
101        };
102        let hex = Zeroizing::new(raw.strip_prefix("0x").unwrap_or(&raw).to_ascii_lowercase());
103        paralock_core::keys::PrivateKey::from_hex(&hex).map_err(|_| {
104            CliError::usage(format!("the key from {} is not a 32-byte secp256k1 private key in hex", self.describe()))
105        })?;
106        Ok(hex)
107    }
108
109    /// The key as `0x` hex (what EVM clients take).
110    pub fn load_0x(&self) -> CliResult<Zeroizing<String>> {
111        Ok(Zeroizing::new(format!("0x{}", self.load()?.as_str())))
112    }
113}
114
115/// Warns if `path` (a file holding secrets) is readable or writable by group or others.
116pub fn warn_if_shared(path: &Path, what: &str) {
117    #[cfg(unix)]
118    {
119        use std::os::unix::fs::PermissionsExt;
120        if let Ok(meta) = std::fs::metadata(path)
121            && meta.permissions().mode() & 0o077 != 0
122        {
123            warn(&format!(
124                "the {what} {} is accessible by other users; restrict it: chmod 600 {}",
125                path.display(),
126                path.display()
127            ));
128        }
129    }
130    #[cfg(not(unix))]
131    let _ = (path, what);
132}
133
134/// A Zcash backend.
135#[derive(Debug, Clone)]
136pub enum Backend {
137    /// Zebra / Zakura JSON-RPC.
138    Rpc {
139        /// Endpoint.
140        url: String,
141        /// HTTP basic auth: user and where the password comes from.
142        auth: Option<(String, Secret)>,
143    },
144    /// lightwalletd / Zaino gRPC.
145    Lightwalletd {
146        /// Endpoint.
147        url: String,
148    },
149}
150
151/// A non-key secret (an RPC password).
152#[derive(Clone)]
153pub enum Secret {
154    /// An environment variable.
155    Env(String),
156    /// A file.
157    File(PathBuf),
158    /// Zebra's cookie file (`__cookie__:<password>`).
159    Cookie(PathBuf),
160    /// Inline (with `allow_inline_keys`).
161    Inline(Zeroizing<String>),
162}
163
164impl std::fmt::Debug for Secret {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        f.write_str(match self {
167            Self::Env(_) => "Secret::Env",
168            Self::File(_) => "Secret::File",
169            Self::Cookie(_) => "Secret::Cookie",
170            Self::Inline(_) => "Secret::Inline",
171        })
172    }
173}
174
175impl Secret {
176    /// Reads it: `(user override, password)`; a cookie file carries its own user.
177    pub fn load(&self) -> CliResult<(Option<String>, Zeroizing<String>)> {
178        match self {
179            Self::Env(name) => std::env::var(name)
180                .map(|v| (None, Zeroizing::new(v)))
181                .map_err(|_| CliError::usage(format!("${name} (the Zcash RPC password) is not set"))),
182            Self::File(path) => {
183                warn_if_shared(path, "password file");
184                let text = Zeroizing::new(std::fs::read_to_string(path)?);
185                Ok((None, Zeroizing::new(text.lines().next().unwrap_or("").trim().to_owned())))
186            }
187            Self::Cookie(path) => {
188                let text = Zeroizing::new(std::fs::read_to_string(path)?);
189                let (user, pass) = text.trim().split_once(':').ok_or_else(|| {
190                    CliError::usage(format!("{} is not a cookie file (user:password)", path.display()))
191                })?;
192                Ok((Some(user.to_owned()), Zeroizing::new(pass.to_owned())))
193            }
194            Self::Inline(pass) => Ok((None, pass.clone())),
195        }
196    }
197}
198
199/// A stablecoin a maker quotes.
200#[derive(Debug, Clone)]
201pub struct Token {
202    /// Its address.
203    pub address: Hex,
204    /// Its symbol.
205    pub symbol: String,
206    /// Its decimals.
207    pub decimals: u32,
208}
209
210/// One EVM chain.
211#[derive(Debug, Clone)]
212pub struct Chain {
213    /// The chain.
214    pub chain: EvmChain,
215    /// JSON-RPC endpoint.
216    pub rpc: String,
217    /// Chain id.
218    pub chain_id: u64,
219    /// `ParalockHTLC`.
220    pub htlc: Hex,
221    /// `ParalockAdaptorEscrow` (private mode).
222    pub adaptor_escrow: Option<Hex>,
223    /// Block the contracts were deployed in.
224    pub from_block: Option<u64>,
225    /// Tempo: the TIP-20 that pays fees.
226    pub fee_token: Option<Hex>,
227    /// Taker: stablecoins accepted (empty: the registry's for the chain id).
228    pub tokens: Vec<Hex>,
229    /// Maker: the stablecoin quoted.
230    pub token: Option<Token>,
231    /// This role's account.
232    pub key: KeySource,
233    /// Taker: submits claims (`--relay`).
234    pub relayer: Option<KeySource>,
235    /// Per-request RPC timeout.
236    pub rpc_timeout: Option<Duration>,
237    /// Taker: a second, independent RPC endpoint; the last check before claiming needs both to agree.
238    pub verify_rpc: Option<String>,
239}
240
241/// Where prices come from.
242#[derive(Debug, Clone)]
243pub enum Price {
244    /// CoinGecko's simple price API (or an endpoint with the same JSON).
245    CoinGecko {
246        /// Another endpoint.
247        url: Option<String>,
248        /// Devnets only: the price when the feed is down.
249        fallback: Option<f64>,
250    },
251    /// A fixed price (regtest and testnet only).
252    Fixed(f64),
253}
254
255/// Maker settings (`paralock maker`).
256#[derive(Debug, Clone)]
257pub struct MakerSettings {
258    /// Where to listen.
259    pub listen: SocketAddr,
260    /// Where claimed ZEC goes (default: the key's address).
261    pub payout_address: Option<String>,
262    /// The maker's Zcash key.
263    pub zcash_key: KeySource,
264    /// Spread in basis points.
265    pub spread_bps: f64,
266    /// Timeout policy.
267    pub policy: TimeoutPolicy,
268    /// Limits.
269    pub limits: MakerLimits,
270    /// HTTP server limits.
271    pub server: ServerOptions,
272    /// Finished swaps kept in memory.
273    pub finished_swaps_in_memory: usize,
274}
275
276/// Everything a command needs.
277#[derive(Debug, Clone)]
278pub struct Setup {
279    /// The Zcash network.
280    pub network: Network,
281    /// What was read: a `paralock.toml` or a `devnet.json`.
282    pub source: PathBuf,
283    /// The devnet, with `--devnet`.
284    pub devnet: Option<DevnetFile>,
285    /// The Zcash backend.
286    pub zcash: Backend,
287    /// Taker: the key that funds swaps.
288    pub zcash_key: KeySource,
289    /// Taker: where change and refunds go.
290    pub return_address: Option<String>,
291    /// The EVM chains.
292    pub chains: BTreeMap<EvmChain, Chain>,
293    /// Taker: the maker.
294    pub maker_url: Option<String>,
295    /// Base directory of the swap stores (`<dir>/taker`, `<dir>/maker`).
296    pub store_dir: PathBuf,
297    /// Prices.
298    pub price: Price,
299    /// Taker limits.
300    pub taker_limits: TakerLimits,
301    /// Taker waits.
302    pub taker_timeouts: TakerTimeouts,
303    /// Maker settings.
304    pub maker: MakerSettings,
305}
306
307impl Setup {
308    /// The chain `chain`, or a helpful error.
309    pub fn chain(&self, chain: EvmChain) -> CliResult<&Chain> {
310        self.chains.get(&chain).ok_or_else(|| {
311            let known: Vec<&str> = self.chains.keys().map(|c| c.as_str()).collect();
312            CliError::usage(format!("{chain} is not configured; available: {}", known.join(", ")))
313        })
314    }
315
316    /// The taker's store directory.
317    pub fn taker_store(&self) -> PathBuf {
318        self.store_dir.join("taker")
319    }
320
321    /// The maker's store directory.
322    pub fn maker_store(&self) -> PathBuf {
323        self.store_dir.join("maker")
324    }
325
326    /// True on the devnet.
327    pub fn is_devnet(&self) -> bool {
328        self.devnet.is_some()
329    }
330}
331
332// -----------------------------------------------------------------------------------------------------------------
333// paralock.toml
334
335#[derive(Debug, Deserialize)]
336#[serde(deny_unknown_fields)]
337struct ConfigFile {
338    network: String,
339    maker_url: Option<String>,
340    store_dir: Option<String>,
341    #[serde(default)]
342    allow_inline_keys: bool,
343    zcash: ZcashSection,
344    #[serde(default)]
345    evm: BTreeMap<String, EvmSection>,
346    #[serde(default)]
347    price: PriceSection,
348    #[serde(default)]
349    taker: TakerSection,
350    #[serde(default)]
351    maker: MakerSection,
352}
353
354#[derive(Debug, Deserialize)]
355#[serde(deny_unknown_fields)]
356struct ZcashSection {
357    rpc: Option<String>,
358    lightwalletd: Option<String>,
359    rpc_user: Option<String>,
360    rpc_password_env: Option<String>,
361    rpc_password_file: Option<String>,
362    rpc_password: Option<String>,
363    rpc_cookie_file: Option<String>,
364    key_env: Option<String>,
365    key_file: Option<String>,
366    key: Option<String>,
367    return_address: Option<String>,
368}
369
370#[derive(Debug, Deserialize)]
371#[serde(deny_unknown_fields)]
372struct EvmSection {
373    rpc: Option<String>,
374    verify_rpc: Option<String>,
375    chain_id: u64,
376    htlc: Option<String>,
377    adaptor_escrow: Option<String>,
378    from_block: Option<u64>,
379    fee_token: Option<String>,
380    #[serde(default)]
381    tokens: Vec<String>,
382    token: Option<TokenSection>,
383    key_env: Option<String>,
384    key_file: Option<String>,
385    key: Option<String>,
386    relayer_key_env: Option<String>,
387    relayer_key_file: Option<String>,
388    relayer_key: Option<String>,
389    rpc_timeout_seconds: Option<u64>,
390}
391
392#[derive(Debug, Deserialize)]
393#[serde(deny_unknown_fields)]
394struct TokenSection {
395    address: String,
396    symbol: String,
397    decimals: Option<u32>,
398}
399
400#[derive(Debug, Default, Deserialize)]
401#[serde(deny_unknown_fields)]
402struct PriceSection {
403    source: Option<String>,
404    url: Option<String>,
405    fixed: Option<f64>,
406    fallback: Option<f64>,
407}
408
409#[derive(Debug, Default, Deserialize)]
410#[serde(deny_unknown_fields)]
411struct TakerSection {
412    max_slippage_bps: Option<u32>,
413    min_claim_window_seconds: Option<u64>,
414    min_evm_confirmations: Option<u64>,
415    max_zcash_lock_blocks: Option<u32>,
416    max_zec: Option<String>,
417    poll_interval_seconds: Option<u64>,
418    funding_timeout_seconds: Option<u64>,
419    maker_lock_timeout_seconds: Option<u64>,
420}
421
422#[derive(Debug, Default, Deserialize)]
423#[serde(deny_unknown_fields)]
424struct MakerSection {
425    listen: Option<String>,
426    payout_address: Option<String>,
427    zcash_key_env: Option<String>,
428    zcash_key_file: Option<String>,
429    zcash_key: Option<String>,
430    spread_bps: Option<f64>,
431    policy: Option<String>,
432    min_zec: Option<String>,
433    max_zec: Option<String>,
434    max_concurrent_swaps: Option<u32>,
435    max_open_quotes: Option<u32>,
436    quote_ttl_seconds: Option<u64>,
437    min_evm_lock_seconds_left: Option<u64>,
438    check_inventory: Option<bool>,
439    finished_swaps_in_memory: Option<usize>,
440    #[serde(default)]
441    cors: Vec<String>,
442    #[serde(default)]
443    expose_swap_list: bool,
444    request_timeout_seconds: Option<u64>,
445    max_concurrent_requests: Option<usize>,
446    rate_limit: Option<bool>,
447    rate_limit_per_minute: Option<u32>,
448    rate_limit_burst: Option<u32>,
449    client_ip_header: Option<String>,
450    max_connections: Option<usize>,
451    idle_timeout_seconds: Option<u64>,
452}
453
454/// Finds the configuration file: `--config`, `$PARALOCK_CONFIG`, `./paralock.toml`, then `<state dir>/paralock.toml`.
455pub fn find_config(explicit: Option<&Path>) -> CliResult<PathBuf> {
456    if let Some(path) = explicit {
457        return if path.is_file() {
458            Ok(path.to_path_buf())
459        } else {
460            Err(CliError::usage(format!("{} does not exist", path.display())))
461        };
462    }
463    if let Some(path) = std::env::var_os("PARALOCK_CONFIG").filter(|p| !p.is_empty()) {
464        let path = PathBuf::from(path);
465        return if path.is_file() {
466            Ok(path)
467        } else {
468            Err(CliError::usage(format!("$PARALOCK_CONFIG points to {}, which does not exist", path.display())))
469        };
470    }
471    let local = PathBuf::from("paralock.toml");
472    if local.is_file() {
473        return Ok(local);
474    }
475    let home = state::current_state_dir().join("paralock.toml");
476    if home.is_file() {
477        return Ok(home);
478    }
479    Err(CliError::usage(format!("no paralock.toml found (looked in ./paralock.toml and {})", home.display())).hint(
480        "write one (see paralock.example.toml in the paralock-cli crate, or https://paralock.xyz/docs/cli), pass \
481         --config <file>, or use --devnet for the local devnet",
482    ))
483}
484
485/// Loads the configuration for `role`: `devnet.json` with `--devnet`, otherwise `paralock.toml`.
486pub fn load(globals: &Globals, role: Role) -> CliResult<Setup> {
487    if globals.devnet {
488        if globals.config.is_some() {
489            return Err(CliError::usage("--devnet and --config are exclusive"));
490        }
491        return from_devnet(&state::current_state_dir(), globals, role);
492    }
493    if globals.evm_account.is_some() || globals.lightwalletd {
494        return Err(CliError::usage("--evm-account and --lightwalletd are devnet options (add --devnet)"));
495    }
496    let path = find_config(globals.config.as_deref())?;
497    let text = Zeroizing::new(
498        std::fs::read_to_string(&path).map_err(|e| CliError::usage(format!("can't read {}: {e}", path.display())))?,
499    );
500    let mut setup = parse_config(&text, &path, role)?;
501    if let Some(url) = &globals.maker_url {
502        setup.maker_url = Some(check_url(url, "--maker-url")?);
503    }
504    Ok(setup)
505}
506
507fn check_url(url: &str, what: &str) -> CliResult<String> {
508    let url = url.trim().trim_end_matches('/').to_owned();
509    if url.starts_with("http://") || url.starts_with("https://") {
510        Ok(url)
511    } else {
512        Err(CliError::usage(format!("{what} must be an http(s) URL, got \"{url}\"")))
513    }
514}
515
516fn hex_address(value: &str, what: &str) -> CliResult<Hex> {
517    Hex::new(value.trim())
518        .ok()
519        .filter(|h| h.byte_len() == 20)
520        .ok_or_else(|| CliError::usage(format!("{what} must be a 0x-prefixed 20-byte address, got \"{value}\"")))
521}
522
523fn network_of(name: &str) -> CliResult<Network> {
524    match name {
525        "mainnet" | "main" => Ok(Network::Mainnet),
526        "testnet" | "test" => Ok(Network::Testnet),
527        "regtest" => Ok(Network::Regtest),
528        other => Err(CliError::usage(format!("network must be mainnet, testnet or regtest, got \"{other}\""))),
529    }
530}
531
532/// A key from `env` / `file` / inline, in that precedence; `default_env` when none is given.
533fn key_source(
534    env: Option<&String>,
535    file: Option<&String>,
536    inline: Option<&String>,
537    default_env: Option<&str>,
538    ctx: &Ctx<'_>,
539    what: &str,
540) -> CliResult<Option<KeySource>> {
541    let given = [env.is_some(), file.is_some(), inline.is_some()].iter().filter(|g| **g).count();
542    if given > 1 {
543        return Err(CliError::usage(format!("{what}: give one of key_env, key_file or key")));
544    }
545    if let Some(name) = env {
546        return Ok(Some(KeySource::Env(name.clone())));
547    }
548    if let Some(file) = file {
549        return Ok(Some(KeySource::File(state::resolve_path(file, ctx.base))));
550    }
551    if let Some(key) = inline {
552        if !ctx.allow_inline {
553            return Err(CliError::usage(format!("{what}: a key written in {} is refused", ctx.path.display())).hint(
554                "use key_env (an environment variable) or key_file (a file only you can read); for throwaway keys \
555                 set allow_inline_keys = true",
556            ));
557        }
558        if ctx.network == Network::Mainnet {
559            warn(&format!("{what}: a mainnet key is written in {}", ctx.path.display()));
560        }
561        ctx.inline_used.set(true);
562        return Ok(Some(KeySource::Inline(Zeroizing::new(key.clone()), format!("{} ({what})", ctx.path.display()))));
563    }
564    Ok(default_env.map(|name| KeySource::Env(name.to_owned())))
565}
566
567struct Ctx<'a> {
568    path: &'a Path,
569    base: &'a Path,
570    network: Network,
571    allow_inline: bool,
572    inline_used: std::cell::Cell<bool>,
573}
574
575/// Parses and checks a `paralock.toml` (`path` is used for messages and relative paths).
576pub fn parse_config(text: &str, path: &Path, role: Role) -> CliResult<Setup> {
577    let file: ConfigFile =
578        toml::from_str(text).map_err(|e| CliError::usage(format!("{}: {}", path.display(), e.message().trim())))?;
579    let network = network_of(&file.network)?;
580    let base = path.parent().map(Path::to_path_buf).unwrap_or_default();
581    let ctx = Ctx { path, base: &base, network, allow_inline: file.allow_inline_keys, inline_used: false.into() };
582
583    // Zcash.
584    let z = &file.zcash;
585    let zcash = match (&z.rpc, &z.lightwalletd) {
586        (Some(_), Some(_)) => return Err(CliError::usage("[zcash]: set rpc or lightwalletd, not both")),
587        (None, None) => return Err(CliError::usage("[zcash]: set rpc (Zebra JSON-RPC) or lightwalletd")),
588        (Some(url), None) => {
589            let url = check_url(url, "zcash.rpc")?;
590            let secrets = [&z.rpc_password_env, &z.rpc_password_file, &z.rpc_password, &z.rpc_cookie_file];
591            if secrets.iter().filter(|s| s.is_some()).count() > 1 {
592                return Err(CliError::usage(
593                    "[zcash]: give one of rpc_password_env, rpc_password_file, rpc_password or rpc_cookie_file",
594                ));
595            }
596            let secret = if let Some(name) = &z.rpc_password_env {
597                Some(Secret::Env(name.clone()))
598            } else if let Some(file) = &z.rpc_password_file {
599                Some(Secret::File(state::resolve_path(file, &base)))
600            } else if let Some(file) = &z.rpc_cookie_file {
601                Some(Secret::Cookie(state::resolve_path(file, &base)))
602            } else if let Some(pass) = &z.rpc_password {
603                if !file.allow_inline_keys {
604                    return Err(CliError::usage("[zcash]: rpc_password in the file is refused")
605                        .hint("use rpc_password_env or rpc_password_file (or allow_inline_keys = true)"));
606                }
607                ctx.inline_used.set(true);
608                Some(Secret::Inline(Zeroizing::new(pass.clone())))
609            } else {
610                None
611            };
612            let auth = match (secret, &z.rpc_user) {
613                (Some(Secret::Cookie(p)), _) => Some((String::new(), Secret::Cookie(p))),
614                (Some(secret), Some(user)) => Some((user.clone(), secret)),
615                (Some(_), None) => return Err(CliError::usage("[zcash]: rpc_user is required with a password")),
616                (None, Some(_)) => return Err(CliError::usage("[zcash]: rpc_user needs a password")),
617                (None, None) => None,
618            };
619            Backend::Rpc { url, auth }
620        }
621        (None, Some(url)) => Backend::Lightwalletd { url: url.trim().to_owned() },
622    };
623    let zcash_key = key_source(
624        z.key_env.as_ref(),
625        z.key_file.as_ref(),
626        z.key.as_ref(),
627        Some("PARALOCK_ZCASH_KEY"),
628        &ctx,
629        "zcash key",
630    )?
631    .unwrap_or_else(|| KeySource::Env("PARALOCK_ZCASH_KEY".into()));
632    if let Some(address) = &z.return_address {
633        paralock_core::zcash::TransparentAddress::decode_for(address, network)
634            .map_err(|_| CliError::usage(format!("zcash.return_address must be a {network} transparent address")))?;
635    }
636
637    // EVM chains.
638    let mut chains = BTreeMap::new();
639    for (name, e) in &file.evm {
640        let chain = EvmChain::parse(name).ok_or_else(|| {
641            CliError::usage(format!(
642                "[evm.{name}]: unknown chain (expected tempo, base, ethereum, arbitrum, robinhood or hyperevm)"
643            ))
644        })?;
645        if e.chain_id == 0 {
646            return Err(CliError::usage(format!("[evm.{name}]: chain_id must be positive")));
647        }
648        let deployment = paralock_evm::testnet_deployment(e.chain_id);
649        let known = paralock_evm::known_network(e.chain_id);
650        let rpc = match (&e.rpc, known.and_then(|k| k.rpc_url)) {
651            (Some(rpc), _) => check_url(rpc, &format!("evm.{name}.rpc"))?,
652            (None, Some(public)) => public.to_owned(),
653            (None, None) => return Err(CliError::usage(format!("[evm.{name}]: rpc is required"))),
654        };
655        let htlc = match (&e.htlc, deployment) {
656            (Some(a), _) => hex_address(a, &format!("evm.{name}.htlc"))?,
657            (None, Some(d)) => Hex::from_bytes(d.htlc.as_slice()),
658            (None, None) => {
659                return Err(CliError::usage(format!(
660                    "[evm.{name}]: htlc (the ParalockHTLC address) is required for chain id {}",
661                    e.chain_id
662                )));
663            }
664        };
665        let adaptor_escrow = match (&e.adaptor_escrow, deployment) {
666            (Some(a), _) => Some(hex_address(a, &format!("evm.{name}.adaptor_escrow"))?),
667            (None, Some(d)) if e.htlc.is_none() => Some(Hex::from_bytes(d.adaptor_escrow.as_slice())),
668            _ => None,
669        };
670        let from_block = e.from_block.or(deployment.filter(|_| e.htlc.is_none()).map(|d| d.start_block));
671        let tokens =
672            e.tokens.iter().map(|t| hex_address(t, &format!("evm.{name}.tokens"))).collect::<CliResult<Vec<_>>>()?;
673        let token = match (&e.token, deployment) {
674            (Some(t), _) => Some(Token {
675                address: hex_address(&t.address, &format!("evm.{name}.token.address"))?,
676                symbol: t.symbol.clone(),
677                decimals: t.decimals.unwrap_or(6),
678            }),
679            (None, Some(d)) => d.quote_token_address().map(|a| Token {
680                address: Hex::from_bytes(a.as_slice()),
681                symbol: d.quote_token.to_owned(),
682                decimals: 6,
683            }),
684            (None, None) => None,
685        };
686        let key = key_source(
687            e.key_env.as_ref(),
688            e.key_file.as_ref(),
689            e.key.as_ref(),
690            Some("PARALOCK_EVM_KEY"),
691            &ctx,
692            &format!("evm.{name} key"),
693        )?
694        .unwrap_or_else(|| KeySource::Env("PARALOCK_EVM_KEY".into()));
695        let relayer = key_source(
696            e.relayer_key_env.as_ref(),
697            e.relayer_key_file.as_ref(),
698            e.relayer_key.as_ref(),
699            None,
700            &ctx,
701            &format!("evm.{name} relayer key"),
702        )?;
703        let fee_token = e.fee_token.as_deref().map(|t| hex_address(t, &format!("evm.{name}.fee_token"))).transpose()?;
704        let verify_rpc =
705            e.verify_rpc.as_deref().map(|u| check_url(u, &format!("evm.{name}.verify_rpc"))).transpose()?;
706        if verify_rpc.as_deref() == Some(rpc.as_str()) {
707            return Err(CliError::usage(format!("[evm.{name}]: verify_rpc must be another endpoint than rpc")));
708        }
709        chains.insert(
710            chain,
711            Chain {
712                chain,
713                rpc,
714                chain_id: e.chain_id,
715                htlc,
716                adaptor_escrow,
717                from_block,
718                fee_token,
719                tokens,
720                token,
721                key,
722                relayer,
723                rpc_timeout: e.rpc_timeout_seconds.map(Duration::from_secs),
724                verify_rpc,
725            },
726        );
727    }
728    if chains.is_empty() {
729        return Err(CliError::usage(format!("{}: configure at least one [evm.<chain>]", path.display())));
730    }
731
732    // Price.
733    let p = &file.price;
734    let price = match p.source.as_deref().unwrap_or(if p.fixed.is_some() { "fixed" } else { "coingecko" }) {
735        "coingecko" => {
736            if p.fallback.is_some() && network == Network::Mainnet {
737                return Err(CliError::usage("price.fallback is refused on mainnet (a stale price is a free option)"));
738            }
739            Price::CoinGecko { url: p.url.clone(), fallback: p.fallback }
740        }
741        "fixed" => {
742            let price = p.fixed.ok_or_else(|| CliError::usage("price.source = \"fixed\" needs price.fixed"))?;
743            if network == Network::Mainnet {
744                return Err(CliError::usage("a fixed price is refused on mainnet"));
745            }
746            if !(price.is_finite() && price > 0.0) {
747                return Err(CliError::usage("price.fixed must be a positive number"));
748            }
749            Price::Fixed(price)
750        }
751        other => return Err(CliError::usage(format!("price.source must be coingecko or fixed, got \"{other}\""))),
752    };
753
754    // Taker.
755    let t = &file.taker;
756    let mut taker_limits = TakerLimits::default();
757    if let Some(bps) = t.max_slippage_bps {
758        taker_limits.quote.max_slippage_bps = bps;
759    }
760    if let Some(blocks) = t.max_zcash_lock_blocks {
761        taker_limits.quote.max_zcash_lock_blocks = blocks;
762    }
763    if let Some(max) = &t.max_zec {
764        taker_limits.quote.max_zec_amount_zat = amount::parse_zec(max).map_err(|e| e.context("taker.max_zec"))?;
765    }
766    if t.min_claim_window_seconds.is_some() || t.min_evm_confirmations.is_some() {
767        let defaults = paralock_core::protocol::DEFAULT_CLAIM_LIMITS;
768        let window = t.min_claim_window_seconds.unwrap_or(defaults.min_claim_window_seconds());
769        let confirmations = t.min_evm_confirmations.unwrap_or(defaults.min_evm_confirmations());
770        taker_limits.claim = match ClaimLimits::new(window, confirmations) {
771            Ok(limits) => limits,
772            Err(_) if network == Network::Regtest => ClaimLimits::new_below_floor(window, confirmations),
773            Err(e) => return Err(CliError::usage(format!("[taker]: {}", e.message))),
774        };
775    }
776    let mut taker_timeouts = TakerTimeouts::default();
777    if let Some(s) = t.poll_interval_seconds {
778        taker_timeouts.poll_interval = Duration::from_secs(s.max(1));
779    }
780    if let Some(s) = t.funding_timeout_seconds {
781        taker_timeouts.funding_confirmation = Duration::from_secs(s);
782    }
783    if let Some(s) = t.maker_lock_timeout_seconds {
784        taker_timeouts.maker_lock = Duration::from_secs(s);
785    }
786
787    // Maker.
788    let m = &file.maker;
789    let maker_zcash_key = key_source(
790        m.zcash_key_env.as_ref(),
791        m.zcash_key_file.as_ref(),
792        m.zcash_key.as_ref(),
793        None,
794        &ctx,
795        "maker zcash key",
796    )?
797    .unwrap_or_else(|| zcash_key.clone());
798    let policy = match m.policy.as_deref() {
799        None if network == Network::Regtest => DEVNET_POLICY,
800        None | Some("mainnet") => MAINNET_POLICY,
801        Some("devnet") if network == Network::Mainnet => {
802            return Err(CliError::usage("maker.policy = \"devnet\" is refused on mainnet"));
803        }
804        Some("devnet") => DEVNET_POLICY,
805        Some(other) => return Err(CliError::usage(format!("maker.policy must be mainnet or devnet, got \"{other}\""))),
806    };
807    let mut limits = DEFAULT_MAKER_LIMITS;
808    if let Some(min) = &m.min_zec {
809        limits.min_zec_amount_zat = amount::parse_zec(min).map_err(|e| e.context("maker.min_zec"))?;
810    }
811    if let Some(max) = &m.max_zec {
812        limits.max_zec_amount_zat = amount::parse_zec(max).map_err(|e| e.context("maker.max_zec"))?;
813    }
814    if let Some(n) = m.max_concurrent_swaps {
815        limits.max_concurrent_swaps = n;
816    }
817    if let Some(n) = m.max_open_quotes {
818        limits.max_open_quotes = n;
819    }
820    if let Some(s) = m.quote_ttl_seconds {
821        limits.quote_ttl_seconds = s;
822    }
823    if let Some(s) = m.min_evm_lock_seconds_left {
824        limits.min_evm_lock_seconds_left = s;
825    }
826    if let Some(check) = m.check_inventory {
827        limits.check_inventory = check;
828    }
829    let mut server = ServerOptions::default();
830    if !m.cors.is_empty() {
831        server.cors =
832            Some(if m.cors.iter().any(|o| o == "*") { CorsOrigins::Any } else { CorsOrigins::List(m.cors.clone()) });
833    }
834    server.expose_swap_list = m.expose_swap_list;
835    if let Some(s) = m.request_timeout_seconds {
836        server.request_timeout = (s > 0).then(|| Duration::from_secs(s));
837    }
838    if let Some(n) = m.max_concurrent_requests {
839        server.max_concurrent_requests = (n > 0).then_some(n);
840    }
841    if m.rate_limit == Some(false) {
842        server.rate_limit = None;
843    } else if m.rate_limit_per_minute.is_some() || m.rate_limit_burst.is_some() {
844        let d = paralock::http::DEFAULT_RATE_LIMIT;
845        server.rate_limit = Some(RateLimit {
846            per_minute: m.rate_limit_per_minute.unwrap_or(d.per_minute),
847            burst: m.rate_limit_burst.unwrap_or(d.burst),
848        });
849    }
850    if let Some(header) = &m.client_ip_header {
851        server.client_address = ClientAddress::Header(header.to_ascii_lowercase());
852    }
853    if let Some(n) = m.max_connections {
854        server.max_connections = (n > 0).then_some(n);
855    }
856    if let Some(s) = m.idle_timeout_seconds {
857        server.idle_timeout = (s > 0).then(|| Duration::from_secs(s));
858    }
859    let listen = m.listen.as_deref().unwrap_or("127.0.0.1:7420");
860    let listen: SocketAddr = listen.parse().map_err(|_| {
861        CliError::usage(format!("maker.listen must be an address such as 127.0.0.1:7420, got \"{listen}\""))
862    })?;
863    if let Some(address) = &m.payout_address {
864        paralock_core::zcash::TransparentAddress::decode_for(address, network)
865            .map_err(|_| CliError::usage(format!("maker.payout_address must be a {network} transparent address")))?;
866    }
867    let maker = MakerSettings {
868        listen,
869        payout_address: m.payout_address.clone(),
870        zcash_key: maker_zcash_key,
871        spread_bps: m.spread_bps.unwrap_or(50.0),
872        policy,
873        limits,
874        server,
875        finished_swaps_in_memory: m
876            .finished_swaps_in_memory
877            .unwrap_or(paralock::maker::DEFAULT_FINISHED_SWAPS_IN_MEMORY),
878    };
879    if role == Role::Maker && maker.limits.min_zec_amount_zat > maker.limits.max_zec_amount_zat {
880        return Err(CliError::usage("maker.min_zec is above maker.max_zec"));
881    }
882
883    if ctx.inline_used.get() {
884        warn_if_shared(path, "configuration file (it holds keys)");
885    }
886    let store_dir = match &file.store_dir {
887        Some(dir) => state::resolve_path(dir, &base),
888        None => state::current_state_dir().join(format!("rust-{network}")),
889    };
890    Ok(Setup {
891        network,
892        source: path.to_path_buf(),
893        devnet: None,
894        zcash,
895        zcash_key,
896        return_address: z.return_address.clone(),
897        chains,
898        maker_url: file.maker_url.as_deref().map(|u| check_url(u, "maker_url")).transpose()?,
899        store_dir,
900        price,
901        taker_limits,
902        taker_timeouts,
903        maker,
904    })
905}
906
907// -----------------------------------------------------------------------------------------------------------------
908// --devnet
909
910/// The devnet's taker limits: a 15 s claim window (the devnet's EVM locks last 45 s).
911fn devnet_taker_limits() -> TakerLimits {
912    TakerLimits { claim: ClaimLimits::new_below_floor(15, 1), ..TakerLimits::default() }
913}
914
915/// The setup for the local devnet in `state_dir`.
916pub fn from_devnet(state_dir: &Path, globals: &Globals, role: Role) -> CliResult<Setup> {
917    let (path, file) = devnet::read_devnet(state_dir)?;
918    let home = state_dir.join("rust-cli");
919    let anvil = |n: usize| -> CliResult<KeySource> {
920        let key = devnet::ANVIL_KEYS
921            .get(n)
922            .ok_or_else(|| CliError::usage(format!("--evm-account must be 0 to {}", devnet::ANVIL_KEYS.len() - 1)))?;
923        Ok(KeySource::Inline(Zeroizing::new((*key).to_owned()), format!("anvil dev account {n}")))
924    };
925    let evm_key = match (globals.evm_account, role) {
926        (Some(n), _) => anvil(n)?,
927        (None, Role::Taker) => KeySource::Inline(Zeroizing::new(file.taker.evm_key.clone()), "devnet taker".into()),
928        (None, Role::Maker) => KeySource::Inline(Zeroizing::new(file.maker.evm_key.clone()), "devnet maker".into()),
929    };
930    let relayer = KeySource::Inline(Zeroizing::new(file.relayer.evm_key.clone()), "devnet relayer".into());
931    let mut chains = BTreeMap::new();
932    for (name, net) in &file.evm {
933        let Some(chain) = EvmChain::parse(name) else { continue };
934        let token = net.quote_token_address()?;
935        chains.insert(
936            chain,
937            Chain {
938                chain,
939                rpc: net.rpc.clone(),
940                chain_id: net.chain_id,
941                htlc: hex_address(&net.htlc, &format!("devnet {name}.htlc"))?,
942                adaptor_escrow: net
943                    .adaptor_escrow
944                    .as_deref()
945                    .map(|a| hex_address(a, &format!("devnet {name}.adaptorEscrow")))
946                    .transpose()?,
947                from_block: net.start_block,
948                fee_token: None,
949                tokens: vec![token.clone()],
950                token: Some(Token { address: token, symbol: net.quote_token.clone(), decimals: 6 }),
951                key: evm_key.clone(),
952                relayer: Some(relayer.clone()),
953                // Local anvils answer at once: fail fast.
954                rpc_timeout: Some(Duration::from_secs(5)),
955                verify_rpc: None,
956            },
957        );
958    }
959    if chains.is_empty() {
960        return Err(CliError::usage(format!("{} lists no EVM chain", path.display())));
961    }
962    let zcash = if globals.lightwalletd {
963        let url = file.zcash.lightwalletd.clone().ok_or_else(|| CliError::usage("devnet.json has no lightwalletd"))?;
964        Backend::Lightwalletd { url }
965    } else {
966        Backend::Rpc { url: file.zcash.rpc.clone(), auth: None }
967    };
968    let maker_url = match &globals.maker_url {
969        Some(url) => Some(check_url(url, "--maker-url")?),
970        None => file.maker.url.clone(),
971    };
972    let limits = MakerLimits {
973        quote_ttl_seconds: 120,
974        max_zec_amount_zat: 500_000_000,
975        min_zec_amount_zat: 20_000,
976        max_concurrent_swaps: 25,
977        ..DEFAULT_MAKER_LIMITS
978    };
979    let server = ServerOptions { cors: Some(CorsOrigins::Any), ..ServerOptions::default() };
980    let timeouts = TakerTimeouts {
981        poll_interval: Duration::from_secs(1),
982        funding_confirmation: Duration::from_secs(180),
983        maker_lock: Duration::from_secs(180),
984        follow_maker: Duration::from_secs(60),
985        ..TakerTimeouts::default()
986    };
987    Ok(Setup {
988        network: Network::Regtest,
989        source: path,
990        devnet: Some(file),
991        zcash,
992        zcash_key: KeySource::Generated(home.join("taker-zcash.key")),
993        return_address: None,
994        chains,
995        maker_url,
996        store_dir: home.clone(),
997        price: Price::CoinGecko { url: None, fallback: Some(1270.0) },
998        taker_limits: devnet_taker_limits(),
999        taker_timeouts: timeouts,
1000        maker: MakerSettings {
1001            listen: SocketAddr::from(([127, 0, 0, 1], 7420)),
1002            payout_address: None,
1003            zcash_key: KeySource::Generated(home.join("maker-zcash.key")),
1004            spread_bps: 50.0,
1005            policy: DEVNET_POLICY,
1006            limits,
1007            server,
1008            finished_swaps_in_memory: paralock::maker::DEFAULT_FINISHED_SWAPS_IN_MEMORY,
1009        },
1010    })
1011}