1use 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#[derive(Debug, Clone, Default)]
27pub struct Globals {
28 pub devnet: bool,
30 pub config: Option<PathBuf>,
32 pub maker_url: Option<String>,
34 pub evm_account: Option<usize>,
36 pub lightwalletd: bool,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Role {
43 Taker,
45 Maker,
47}
48
49#[derive(Clone)]
51pub enum KeySource {
52 Env(String),
54 File(PathBuf),
56 Inline(Zeroizing<String>, String),
58 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 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 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 pub fn load_0x(&self) -> CliResult<Zeroizing<String>> {
111 Ok(Zeroizing::new(format!("0x{}", self.load()?.as_str())))
112 }
113}
114
115pub 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#[derive(Debug, Clone)]
136pub enum Backend {
137 Rpc {
139 url: String,
141 auth: Option<(String, Secret)>,
143 },
144 Lightwalletd {
146 url: String,
148 },
149}
150
151#[derive(Clone)]
153pub enum Secret {
154 Env(String),
156 File(PathBuf),
158 Cookie(PathBuf),
160 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 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#[derive(Debug, Clone)]
201pub struct Token {
202 pub address: Hex,
204 pub symbol: String,
206 pub decimals: u32,
208}
209
210#[derive(Debug, Clone)]
212pub struct Chain {
213 pub chain: EvmChain,
215 pub rpc: String,
217 pub chain_id: u64,
219 pub htlc: Hex,
221 pub adaptor_escrow: Option<Hex>,
223 pub from_block: Option<u64>,
225 pub fee_token: Option<Hex>,
227 pub tokens: Vec<Hex>,
229 pub token: Option<Token>,
231 pub key: KeySource,
233 pub relayer: Option<KeySource>,
235 pub rpc_timeout: Option<Duration>,
237 pub verify_rpc: Option<String>,
239}
240
241#[derive(Debug, Clone)]
243pub enum Price {
244 CoinGecko {
246 url: Option<String>,
248 fallback: Option<f64>,
250 },
251 Fixed(f64),
253}
254
255#[derive(Debug, Clone)]
257pub struct MakerSettings {
258 pub listen: SocketAddr,
260 pub payout_address: Option<String>,
262 pub zcash_key: KeySource,
264 pub spread_bps: f64,
266 pub policy: TimeoutPolicy,
268 pub limits: MakerLimits,
270 pub server: ServerOptions,
272 pub finished_swaps_in_memory: usize,
274}
275
276#[derive(Debug, Clone)]
278pub struct Setup {
279 pub network: Network,
281 pub source: PathBuf,
283 pub devnet: Option<DevnetFile>,
285 pub zcash: Backend,
287 pub zcash_key: KeySource,
289 pub return_address: Option<String>,
291 pub chains: BTreeMap<EvmChain, Chain>,
293 pub maker_url: Option<String>,
295 pub store_dir: PathBuf,
297 pub price: Price,
299 pub taker_limits: TakerLimits,
301 pub taker_timeouts: TakerTimeouts,
303 pub maker: MakerSettings,
305}
306
307impl Setup {
308 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 pub fn taker_store(&self) -> PathBuf {
318 self.store_dir.join("taker")
319 }
320
321 pub fn maker_store(&self) -> PathBuf {
323 self.store_dir.join("maker")
324 }
325
326 pub fn is_devnet(&self) -> bool {
328 self.devnet.is_some()
329 }
330}
331
332#[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
454pub 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
485pub 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
532fn 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
575pub 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 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 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 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 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 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
907fn devnet_taker_limits() -> TakerLimits {
912 TakerLimits { claim: ClaimLimits::new_below_floor(15, 1), ..TakerLimits::default() }
913}
914
915pub 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 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}