use std::path::PathBuf;
use std::time::Duration;
use clap::{Parser, Subcommand};
use serde_json::json;
use sidestr_agent::{
destination, identity, parse_pubkey, pegin_plan, prepare, refuse_secret, AgentKey, Payment,
PegTarget,
};
use sidestr_core::document::ChainDocument;
use sidestr_round::relay::{ok_count, publish_all, unix_now};
use sidestr_wallet::deliver::client;
const RELAYS: &str = "wss://nos.lol,wss://relay.damus.io,wss://relay.primal.net,wss://nostr.mom,wss://nostr.oxtr.dev";
#[derive(Parser, Debug)]
#[command(
name = "sidestr-agent",
version,
about = "A did:nostr agent's wallet on a sidestr sidechain: the Nostr key is the wallet. Testnet only."
)]
struct Cli {
#[arg(long, global = true, default_value = "http://127.0.0.1:3450")]
url: String,
#[arg(long, global = true, default_value = RELAYS, value_delimiter = ',')]
relays: Vec<String>,
#[arg(long, global = true)]
key_file: Option<PathBuf>,
#[arg(long, global = true)]
chain: Option<PathBuf>,
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand, Debug)]
enum Cmd {
Balance,
Address {
who: Option<String>,
#[arg(long)]
prefix: Option<String>,
},
Send {
to: String,
amount: u64,
#[command(flatten)]
deliver: Deliver,
},
Burn {
to: String,
amount: u64,
#[command(flatten)]
deliver: Deliver,
},
PeginPlan {
#[arg(long)]
amount: u64,
#[arg(long)]
refund: Option<String>,
#[arg(long)]
to: Option<String>,
#[arg(long)]
peg_address: Option<String>,
#[arg(long, conflicts_with = "peg_address")]
peg_key: Option<String>,
},
}
#[derive(clap::Args, Debug)]
struct Deliver {
#[arg(long)]
fee: Option<u64>,
#[arg(long)]
post: bool,
#[arg(long)]
dry_run: bool,
}
fn key(cli: &Cli) -> Result<AgentKey, Box<dyn std::error::Error>> {
let path = cli
.key_file
.as_ref()
.ok_or("--key-file is required for this command")?;
Ok(AgentKey::from_file(path)?)
}
fn chain(cli: &Cli) -> Result<ChainDocument, Box<dyn std::error::Error>> {
Ok(match &cli.chain {
Some(p) => ChainDocument::from_json(&std::fs::read_to_string(p)?)?,
None => client::chain(&cli.url)?,
})
}
fn destination_args(args: &[String]) -> Vec<&str> {
const WITH_VALUE: [&str; 7] = [
"--url",
"--relays",
"--key-file",
"--chain",
"--fee",
"--refund",
"--peg-key",
];
let mut out = Vec::new();
let mut i = 0;
let mut payment = false;
let mut positional_seen = false;
while i < args.len() {
let a = args[i].as_str();
if let Some(v) = a
.strip_prefix("--to=")
.or_else(|| a.strip_prefix("--peg-address="))
{
out.push(v);
} else if a == "--to" || a == "--peg-address" {
if let Some(v) = args.get(i + 1) {
out.push(v);
}
i += 1;
} else if WITH_VALUE.contains(&a) {
i += 1;
} else if a == "send" || a == "burn" {
payment = true;
} else if payment && !positional_seen && !a.starts_with('-') {
out.push(a);
positional_seen = true;
}
i += 1;
}
out
}
#[tokio::main]
async fn main() {
let args: Vec<String> = std::env::args().skip(1).collect();
for v in destination_args(&args) {
if let Err(e) = refuse_secret(v) {
eprintln!("sidestr-agent: {e}");
std::process::exit(1);
}
}
let cli = Cli::parse();
match run(&cli).await {
Ok(v) => println!("{v}"),
Err(e) => {
eprintln!("sidestr-agent: {e}");
std::process::exit(1);
}
}
}
async fn run(cli: &Cli) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
match &cli.cmd {
Cmd::Balance => {
let k = key(cli)?;
let script = k.script().to_hex_string();
let tip = client::tip(&cli.url)?;
let coins = client::coins(&cli.url, &script)?;
let mature: u64 = coins
.iter()
.filter(|c| c.is_mature(tip.height))
.map(|c| c.value)
.sum();
Ok(json!({
"did": format!("did:nostr:{}", k.pubkey()),
"script": script,
"tip": tip.height,
"coins": coins.len(),
"balance": coins.iter().map(|c| c.value).sum::<u64>(),
"spendable": mature,
}))
}
Cmd::Address { who, prefix } => {
let pubkey = match who {
Some(w) => parse_pubkey(w)?,
None => key(cli)?.pubkey(),
};
let prefix = match prefix {
Some(p) => p.clone(),
None => chain(cli)?.address_prefix,
};
let id = identity(&pubkey, &prefix)
.ok_or_else(|| format!("{prefix:?} is not a bech32 prefix"))?;
Ok(serde_json::to_value(id)?)
}
Cmd::Send {
to,
amount,
deliver,
} => pay(cli, Payment::Send, &destination(to)?, *amount, deliver).await,
Cmd::Burn {
to,
amount,
deliver,
} => pay(cli, Payment::Burn, refuse_secret(to)?, *amount, deliver).await,
Cmd::PeginPlan {
amount,
refund,
to,
peg_key,
peg_address,
} => {
let doc = chain(cli)?;
let own = cli.key_file.as_ref().map(|_| key(cli)).transpose()?;
let refund = match (refund, &own) {
(Some(r), _) => parse_pubkey(r)?,
(None, Some(k)) => k.pubkey(),
(None, None) => return Err("--refund or --key-file names the refund key".into()),
};
let side = match (to, &own) {
(Some(t), _) => t.clone(),
(None, Some(k)) => k.script().to_hex_string(),
(None, None) => return Err("--to or --key-file names the sidechain script".into()),
};
let target = match (peg_key, peg_address) {
(Some(k), _) => Some(PegTarget::Key(parse_pubkey(k)?)),
(None, Some(a)) => Some(PegTarget::Address(a.clone())),
(None, None) => None,
};
Ok(serde_json::to_value(pegin_plan(
&doc, *amount, &refund, &side, target,
)?)?)
}
}
}
async fn pay(
cli: &Cli,
what: Payment,
to: &str,
amount: u64,
d: &Deliver,
) -> Result<serde_json::Value, Box<dyn std::error::Error>> {
let k = key(cli)?;
let doc = chain(cli)?;
let tip = client::tip(&cli.url)?;
let coins = client::coins(&cli.url, &k.script().to_hex_string())?;
let p = prepare(
&k,
&doc,
&coins,
tip.height,
what,
to,
amount,
d.fee,
unix_now(),
)?;
let mut out = json!({
"cmd": what,
"chain": doc.id,
"txid": p.spend.txid.to_string(),
"event": p.event.id,
"amount": p.spend.amount,
"fee": p.spend.fee,
"change": p.spend.change,
"vsize": p.spend.vsize,
"note": p.spend.note,
});
if d.dry_run {
out["hex"] = json!(p.spend.hex);
out["signedEvent"] = serde_json::to_value(&p.event)?;
return Ok(out);
}
if d.post {
let r = client::post_tx(&cli.url, &p.spend.hex)?;
out["posted"] = json!({ "txid": r.txid, "fee": r.fee, "dup": r.dup });
}
let res = publish_all(&cli.relays, &p.event, Duration::from_secs(8)).await;
out["relaysOk"] = json!(ok_count(&res));
out["relays"] = json!(res.len());
Ok(out)
}