use std::fs;
use std::io::{self, Read};
use std::str::FromStr;
use std::time::Duration;
use anyhow::{anyhow, bail, Context, Result};
use clap::Parser;
use lnsocket::bitcoin::secp256k1::{rand, PublicKey, SecretKey};
use lnsocket::commando::CallOpts;
use lnsocket::{CommandoClient, LNSocket};
use serde_json::Value;
#[derive(Debug, Parser)]
#[command(version, about, long_about = None)]
struct Args {
#[arg(
short,
long,
env = "CLN_COMMANDO_CONNECT",
value_name = "NODE_ID@HOST[:PORT]"
)]
connect: String,
#[arg(short, long, env = "CLN_COMMANDO_RUNE")]
rune: String,
#[arg(long, env = "CLN_COMMANDO_SECRET_KEY", value_name = "HEX")]
secret_key: Option<String>,
#[arg(short = 't', long, default_value_t = 30)]
timeout: u64,
#[arg(long, default_value_t = 3)]
retries: usize,
#[arg(short, long)]
pretty: bool,
#[arg(short = 'n', long)]
no_newline: bool,
method: String,
#[arg(value_name = "JSON|@FILE|-")]
params: Option<String>,
}
#[tokio::main]
async fn main() -> Result<()> {
let args = Args::parse();
let (their_pubkey, addr) = parse_connect(&args.connect)?;
let our_key = parse_or_generate_secret(args.secret_key.as_deref())?;
let params = read_params(args.params.as_deref())?;
let sock = LNSocket::connect_and_init(our_key, their_pubkey, &addr)
.await
.map_err(|e| anyhow!("connecting to {addr}: {e}"))?;
let client = CommandoClient::spawn(sock, args.rune);
let opts = CallOpts::new()
.timeout(Duration::from_secs(args.timeout))
.retry(args.retries);
let result = client
.call_with_opts(args.method, params, opts)
.await
.map_err(|e| anyhow!("commando RPC failed: {e}"))?;
if args.pretty {
print_json(serde_json::to_string_pretty(&result)?, args.no_newline);
} else {
print_json(serde_json::to_string(&result)?, args.no_newline);
}
Ok(())
}
fn print_json(s: String, no_newline: bool) {
if no_newline {
print!("{s}");
} else {
println!("{s}");
}
}
fn parse_connect(connect: &str) -> Result<(PublicKey, String)> {
let (pk, host) = connect
.split_once('@')
.ok_or_else(|| anyhow!("--connect must be NODE_ID@HOST[:PORT]"))?;
let pubkey = PublicKey::from_str(pk).context("invalid node id in --connect")?;
if host.is_empty() {
bail!("host is empty in --connect");
}
let addr = if host.rsplit_once(':').is_some() {
host.to_owned()
} else {
format!("{host}:9735")
};
Ok((pubkey, addr))
}
fn parse_or_generate_secret(secret: Option<&str>) -> Result<SecretKey> {
match secret {
Some(s) => SecretKey::from_str(s).context("invalid --secret-key hex"),
None => Ok(SecretKey::new(&mut rand::thread_rng())),
}
}
fn read_params(arg: Option<&str>) -> Result<Value> {
let Some(arg) = arg else {
return Ok(serde_json::json!({}));
};
let text = match arg {
"-" => {
let mut s = String::new();
io::stdin()
.read_to_string(&mut s)
.context("reading params from stdin")?;
s
}
s if s.starts_with('@') => {
let path = s.strip_prefix('@').unwrap();
fs::read_to_string(path).with_context(|| format!("reading params from {path}"))?
}
s => s.to_owned(),
};
if text.trim().is_empty() {
Ok(serde_json::json!({}))
} else {
serde_json::from_str(&text).context("params are not valid JSON")
}
}
#[cfg(test)]
mod tests {
use super::*;
const PK: &str = "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798";
#[test]
fn default_port() {
let (_, addr) = parse_connect(&format!("{PK}@example.com")).unwrap();
assert_eq!(addr, "example.com:9735");
}
#[test]
fn explicit_port() {
let (_, addr) = parse_connect(&format!("{PK}@example.com:19735")).unwrap();
assert_eq!(addr, "example.com:19735");
}
#[test]
fn no_params_is_object() {
assert_eq!(read_params(None).unwrap(), serde_json::json!({}));
}
#[test]
fn object_params() {
assert_eq!(
read_params(Some(r#"{"id":"x"}"#)).unwrap(),
serde_json::json!({"id": "x"})
);
}
#[test]
fn array_params() {
assert_eq!(
read_params(Some(r#"[1,"x"]"#)).unwrap(),
serde_json::json!([1, "x"])
);
}
}