vls_proxy/
config.rs

1use clap::{ErrorKind, Parser};
2use lightning_signer::bitcoin::secp256k1::PublicKey;
3use lightning_signer::bitcoin::Network;
4use lightning_signer::policy::filter::{FilterResult, FilterRule};
5use lightning_signer::policy::simple_validator::OptionizedSimplePolicy;
6use lightning_signer::util::velocity::{VelocityControlIntervalType, VelocityControlSpec};
7use std::ffi::OsStr;
8use std::net::{IpAddr, Ipv4Addr};
9use std::path::PathBuf;
10use std::process::exit;
11use std::sync::{Arc, Mutex, MutexGuard};
12use std::{env, fs};
13use toml::value::{Table, Value};
14use url::Url;
15
16/// Network names
17pub const NETWORK_NAMES: &[&str] = &["testnet", "regtest", "signet", "bitcoin"];
18
19/// Useful with clap's `Arg::default_value_ifs`
20pub const CLAP_NETWORK_URL_MAPPING: &[(&str, Option<&str>, Option<&str>)] = &[
21    ("network", Some("bitcoin"), Some("http://user:pass@127.0.0.1:8332")),
22    ("network", Some("testnet"), Some("http://user:pass@127.0.0.1:18332")),
23    ("network", Some("regtest"), Some("http://user:pass@127.0.0.1:18443")),
24    ("network", Some("signet"), Some("http://user:pass@127.0.0.1:18443")),
25];
26
27pub const DEFAULT_DIR: &str = ".lightning-signer";
28pub const RPC_SERVER_PORT: u16 = 8011;
29pub const RPC_SERVER_ADDRESS: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST);
30pub const RPC_SERVER_ENDPOINT: &'static str = "http://127.0.0.1:8011";
31
32pub trait HasSignerArgs {
33    fn signer_args(&self) -> &SignerArgs;
34}
35
36// only used for usage display
37#[derive(Parser, Debug)]
38#[clap(about, long_about = None)]
39pub struct InitialArgs {
40    #[clap(
41        short = 'f',
42        long,
43        value_parser,
44        help = "configuration file - MUST be the first argument",
45        value_name = "FILE"
46    )]
47    config: Option<String>,
48}
49
50// note that value_parser gives us clap 4 forward compatibility
51#[derive(Parser, Debug)]
52#[clap(about, long_about = None)]
53pub struct SignerArgs {
54    #[clap(flatten)]
55    initial_args: InitialArgs,
56
57    #[clap(long, help = "print git desc version and exit")]
58    pub git_desc: bool,
59
60    #[clap(long, help = "LSS RPC endpoint")]
61    pub lss: Option<Url>,
62
63    #[clap(long, help = "dump LSS contents and exit")]
64    pub dump_lss: bool,
65
66    #[clap(long, help = "initialize LSS from local storage and exit.  LSS must be empty.")]
67    pub init_lss: bool,
68
69    #[clap(long, help = "dump local storage contents and exit")]
70    pub dump_storage: bool,
71
72    #[clap(
73        long,
74        value_parser,
75        help = "set the logging level",
76        value_name = "LEVEL",
77        default_value = "info",
78        possible_values = &["off", "error", "warn", "info", "debug", "trace"],
79    )]
80    pub log_level: String,
81
82    #[clap(short, long, value_parser, help = "data directory", value_name = "DIR")]
83    pub datadir: Option<String>,
84
85    #[clap(short, long, value_parser,
86        value_name = "NETWORK",
87        possible_values = NETWORK_NAMES,
88        default_value = NETWORK_NAMES[0]
89    )]
90    pub network: Network,
91
92    #[clap(
93        long,
94        value_parser,
95        help = "use integration test mode, reading/writing hsm_secret from CWD"
96    )]
97    pub integration_test: bool,
98
99    #[clap(
100        long,
101        value_parser,
102        help = "block explorer/bitcoind RPC endpoint - used for broadcasting recovery transactions",
103        default_value_ifs(CLAP_NETWORK_URL_MAPPING),
104        value_name = "URL"
105    )]
106    pub recover_rpc: Option<Url>,
107
108    #[clap(
109        long,
110        value_parser,
111        help = "block explorer type - used for broadcasting recovery transactions",
112        value_name = "TYPE",
113        default_value = "bitcoind",
114        possible_values = &["bitcoind", "esplora"]
115    )]
116    pub recover_type: String,
117
118    #[clap(
119        long,
120        value_parser,
121        help = "recover funds to the given address.  By default, l2 funds are recovered (force-close), but you can also recover l1 funds by specifying --recover-l1-range.  You can also perform a dry-run by specifying --recover-to=none.",
122        value_name = "BITCOIN_ADDRESS"
123    )]
124    pub recover_to: Option<String>,
125
126    #[clap(
127        long,
128        value_parser,
129        help = "recover l1 funds by sweeping BIP32 addresses up to the given derivation index",
130        value_name = "RANGE"
131    )]
132    pub recover_l1_range: Option<u32>,
133
134    #[clap(long, value_parser=parse_velocity_control_spec, help = "global velocity control e.g. hour:10000 (satoshi)")]
135    pub velocity_control: Option<VelocityControlSpec>,
136
137    #[clap(long, value_parser=parse_velocity_control_spec, help = "fee velocity control e.g. hour:10000 (satoshi)")]
138    pub fee_velocity_control: Option<VelocityControlSpec>,
139
140    #[clap(long, value_parser=parse_filter_rule, help = "policy filter rule, e.g. 'policy-channel-safe-mode:warn' or 'policy-channel-*:error'")]
141    pub policy_filter: Vec<FilterRule>,
142
143    #[clap(
144        long,
145        help = "rpc server's bind address",
146        default_value_t = RPC_SERVER_ADDRESS,
147        value_parser
148    )]
149    pub rpc_server_address: IpAddr,
150
151    #[clap(
152        long,
153        help = "rpc server's port",
154        default_value_t = RPC_SERVER_PORT,
155        value_parser
156    )]
157    pub rpc_server_port: u16,
158
159    #[clap(long, help = "rpc server admin username", value_parser)]
160    pub rpc_user: Option<String>,
161
162    #[clap(long, help = "rpc server admin password", value_parser)]
163    pub rpc_pass: Option<String>,
164
165    #[clap(long, help = "rpc server admin cookie file path", value_parser)]
166    pub rpc_cookie: Option<PathBuf>,
167
168    #[clap(short, help = "public key of trusted TXO oracle", value_parser)]
169    pub trusted_oracle_pubkey: Vec<PublicKey>,
170
171    #[clap(skip)]
172    pub policy: OptionizedSimplePolicy,
173}
174
175impl HasSignerArgs for SignerArgs {
176    fn signer_args(&self) -> &SignerArgs {
177        self
178    }
179}
180
181pub fn parse_args_and_config<A: Parser + HasSignerArgs>(bin_name: &str) -> A {
182    let env_args = env::args().collect::<Vec<_>>();
183    parse_args_and_config_from(bin_name, &env_args).unwrap_or_else(|e| match e.kind() {
184        clap::ErrorKind::DisplayVersion => exit(0), // exit directly because no Command
185        _ => e.exit(),
186    })
187}
188
189#[derive(Clone)]
190struct ConfigIterator {
191    args_stack: Arc<Mutex<Vec<Vec<String>>>>,
192}
193
194impl ConfigIterator {
195    fn new(args: &[String]) -> Self {
196        assert!(args.len() > 0, "at least one arg");
197        ConfigIterator { args_stack: Arc::new(Mutex::new(vec![args.iter().cloned().collect()])) }
198    }
199
200    fn do_next(args_stack: &mut MutexGuard<Vec<Vec<String>>>) -> Option<String> {
201        loop {
202            if args_stack.is_empty() {
203                return None;
204            }
205            let args = &mut args_stack[0];
206            if !args.is_empty() {
207                let arg = args.remove(0);
208                return Some(arg);
209            }
210            args_stack.remove(0);
211        }
212    }
213}
214
215impl Iterator for ConfigIterator {
216    type Item = String;
217
218    fn next(&mut self) -> Option<String> {
219        let mut args_stack = self.args_stack.lock().unwrap();
220        let arg = Self::do_next(&mut args_stack);
221        if let Some(arg) = arg {
222            if arg.starts_with("--config=") {
223                let path = arg.split('=').nth(1).unwrap();
224                let configs = toml_to_configs(path.as_ref());
225                args_stack.insert(0, configs);
226                return Self::do_next(&mut args_stack);
227            } else if arg == "--config" || arg == "-f" {
228                let path_opt = Self::do_next(&mut args_stack);
229                if let Some(path) = path_opt {
230                    let configs = toml_to_configs(path.as_ref());
231                    args_stack.insert(0, configs);
232                    return Self::do_next(&mut args_stack);
233                } else {
234                    println!("--config must be followed by a path");
235                    // let clap handle the error
236                    return Some(arg);
237                }
238            }
239            return Some(arg);
240        } else {
241            return None;
242        }
243    }
244}
245
246pub fn parse_args_and_config_from<A: Parser + HasSignerArgs>(
247    bin_name: &str,
248    env_args: &[String],
249) -> Result<A, clap::Error> {
250    let args_iter = ConfigIterator::new(env_args);
251    let args = A::try_parse_from(args_iter)?;
252
253    // short-circuit if we're just printing the git desc
254    if args.signer_args().git_desc {
255        println!("{} git_desc={}", bin_name, crate::GIT_DESC);
256        // Don't exit here because this is called by unit tests
257        return Err(clap::Error::raw(ErrorKind::DisplayVersion, ""));
258    }
259
260    Ok(args)
261}
262
263fn toml_to_configs(path: &OsStr) -> Vec<String> {
264    let contents = fs::read_to_string(path).unwrap();
265    let config: Table = toml::from_str(contents.as_str()).unwrap();
266    let configs = config
267        .into_iter()
268        .flat_map(|(k, value)| {
269            let vals = convert_toml_value(k, value);
270            vals.into_iter()
271        })
272        .map(|(k, v)| format!("--{}={}", k, v).to_string())
273        .collect();
274    configs
275}
276
277fn convert_toml_value(key: String, value: Value) -> Vec<(String, String)> {
278    match value {
279        Value::String(s) => vec![(key, s)],
280        Value::Integer(v) => vec![(key, v.to_string())],
281        Value::Float(v) => vec![(key, v.to_string())],
282        Value::Boolean(v) => vec![(key, v.to_string())],
283        Value::Datetime(v) => vec![(key, v.to_string())],
284        Value::Array(a) =>
285            a.into_iter().flat_map(|v| convert_toml_value(key.clone(), v)).collect::<Vec<_>>(),
286        Value::Table(_) => vec![],
287    }
288}
289
290fn parse_velocity_control_spec(spec: &str) -> Result<VelocityControlSpec, String> {
291    let mut parts = spec.splitn(2, ':');
292    let interval_type_str = parts.next().ok_or("missing duration")?;
293    let interval_type = match interval_type_str {
294        "hour" => VelocityControlIntervalType::Hourly,
295        "day" => VelocityControlIntervalType::Daily,
296        "unlimited" => return Ok(VelocityControlSpec::UNLIMITED),
297        _ => return Err(format!("unknown interval type: {}", interval_type_str)),
298    };
299    let limit: u64 = parts
300        .next()
301        .ok_or("missing limit")?
302        .to_string()
303        .parse()
304        .map_err(|_| "non-integer limit")?;
305    Ok(VelocityControlSpec { interval_type, limit_msat: limit })
306}
307
308fn parse_filter_rule(spec: &str) -> Result<FilterRule, String> {
309    let mut parts = spec.splitn(2, ':');
310    let mut tag: String = parts.next().ok_or("missing filter")?.to_string();
311    let is_prefix = if tag.ends_with('*') {
312        tag.pop();
313        true
314    } else {
315        false
316    };
317    let action_str = parts.next().ok_or("missing level")?;
318    let action = match action_str {
319        "error" => FilterResult::Error,
320        "warn" => FilterResult::Warn,
321        _ => return Err(format!("unknown filter action {}", action_str)),
322    };
323    Ok(FilterRule { tag, action, is_prefix })
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use std::io::Write;
330
331    #[test]
332    fn parse_velocity_control_spec_test() {
333        match parse_velocity_control_spec("hour:100").unwrap().interval_type {
334            VelocityControlIntervalType::Hourly => {}
335            _ => panic!("unexpected interval type"),
336        }
337        match parse_velocity_control_spec("day:100").unwrap().interval_type {
338            VelocityControlIntervalType::Daily => {}
339            _ => panic!("unexpected interval type"),
340        }
341        match parse_velocity_control_spec("unlimited").unwrap().interval_type {
342            VelocityControlIntervalType::Unlimited => {}
343            _ => panic!("unexpected interval type"),
344        }
345        assert!(parse_velocity_control_spec("hour").is_err());
346        assert!(parse_velocity_control_spec("hour:").is_err());
347        assert!(parse_velocity_control_spec("hour:foo").is_err());
348        assert!(parse_velocity_control_spec("foo:100").is_err());
349    }
350
351    #[test]
352    fn parse_filter_rule_test() {
353        assert!(parse_filter_rule("policy-channel-safe-mode:warn").is_ok());
354        assert!(parse_filter_rule("policy-channel-safe-mode:foo").is_err());
355        assert!(parse_filter_rule("policy-channel-safe-mode").is_err());
356        assert!(parse_filter_rule("policy-channel-safe-mode:").is_err());
357        assert!(parse_filter_rule("policy-channel-safe-mode:*").is_err());
358        assert!(parse_filter_rule("policy-channel-safe-mode-*:warn").is_ok());
359    }
360
361    #[test]
362    fn git_desc_test() {
363        let env_args: Vec<String> =
364            vec!["vlsd2", "--git-desc"].into_iter().map(|s| s.to_string()).collect();
365        let args_res: Result<SignerArgs, _> = parse_args_and_config_from("", &env_args);
366        assert!(args_res.is_err());
367    }
368
369    #[test]
370    fn clap_test() {
371        let env_args: Vec<String> = vec!["vlsd2"].into_iter().map(|s| s.to_string()).collect();
372        let args: SignerArgs = parse_args_and_config_from("", &env_args).unwrap();
373        assert!(args.policy_filter.is_empty());
374        assert!(args.velocity_control.is_none());
375        assert!(args.fee_velocity_control.is_none());
376
377        let env_args: Vec<String> = vec![
378            "vlsd2",
379            "--datadir=/tmp/vlsd2",
380            "--network=regtest",
381            "--integration-test",
382            "--recover-rpc=http://localhost:3000",
383            "--policy-filter",
384            "policy-channel-safe-mode:warn",
385            "--velocity-control",
386            "hour:100",
387            "--recover-to",
388            "abc123",
389            "--recover-l1-range",
390            "100",
391            "--rpc-server-address",
392            "127.0.0.1",
393            "--rpc-server-port",
394            "8011",
395        ]
396        .into_iter()
397        .map(|s| s.to_string())
398        .collect();
399        let args: SignerArgs = parse_args_and_config_from("", &env_args).unwrap();
400        assert_eq!(args.datadir.unwrap(), "/tmp/vlsd2");
401        assert_eq!(args.policy_filter.len(), 1);
402        assert!(args.velocity_control.is_some());
403        assert_eq!(args.network, Network::Regtest);
404        assert!(args.integration_test);
405        assert_eq!(args.recover_rpc.unwrap().as_str(), "http://localhost:3000/");
406        assert_eq!(args.recover_type, "bitcoind");
407        assert_eq!(args.recover_to.unwrap().as_str(), "abc123");
408        assert_eq!(args.recover_l1_range, Some(100));
409        assert_eq!(args.rpc_server_address, IpAddr::V4(Ipv4Addr::LOCALHOST));
410        assert_eq!(args.rpc_server_port, 8011);
411    }
412
413    #[test]
414    fn clap_with_config_file_test() {
415        let mut file = tempfile::NamedTempFile::new().unwrap();
416        writeln!(
417            file,
418            "datadir = \"/tmp/vlsd2\"\n\
419        velocity-control = \"day:222\"\n\
420        network = \"regtest\"\n\
421        "
422        )
423        .unwrap();
424        let env_args: Vec<String> =
425            vec!["vlsd2", "--config", file.path().to_str().unwrap(), "--network=bitcoin"]
426                .into_iter()
427                .map(|s| s.to_string())
428                .collect();
429        let args: SignerArgs = parse_args_and_config_from("", &env_args).unwrap();
430        assert_eq!(args.datadir.unwrap(), "/tmp/vlsd2");
431        assert!(args.velocity_control.is_some());
432        // command line args override config file
433        assert_eq!(args.network, Network::Bitcoin);
434
435        let env_args: Vec<String> =
436            vec!["vlsd2", "--network=bitcoin", "--config", file.path().to_str().unwrap()]
437                .into_iter()
438                .map(|s| s.to_string())
439                .collect();
440        let args: SignerArgs = parse_args_and_config_from("", &env_args).unwrap();
441        assert_eq!(args.datadir.unwrap(), "/tmp/vlsd2");
442        assert!(args.velocity_control.is_some());
443        // config file overrides command line because it comes last
444        assert_eq!(args.network, Network::Regtest);
445    }
446}