diff --git a/Cargo.lock b/Cargo.lock index 39d8c4bd4..deab2ac9c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -599,6 +599,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d696c370c750c948ada61c69a0ee2cbbb9c50b1019ddb86d9317157a99c2cae" +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + [[package]] name = "bolt-derive" version = "0.4.0" @@ -976,6 +985,17 @@ dependencies = [ "cipher 0.3.0", ] +[[package]] +name = "ctrlc" +version = "3.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" +dependencies = [ + "dispatch2", + "nix 0.31.1", + "windows-sys 0.61.2", +] + [[package]] name = "darling" version = "0.21.3" @@ -1096,6 +1116,18 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "dispatch2" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec" +dependencies = [ + "bitflags 2.10.0", + "block2", + "libc", + "objc2", +] + [[package]] name = "displaydoc" version = "0.2.5" @@ -1554,7 +1586,7 @@ dependencies = [ "lazy_static", "linemux", "log", - "nix", + "nix 0.30.1", "prost 0.12.6", "serde", "serde_json", @@ -1583,6 +1615,22 @@ dependencies = [ "uniffi", ] +[[package]] +name = "gl-sdk-cli" +version = "0.1.0" +dependencies = [ + "bip39", + "clap", + "ctrlc", + "dirs", + "env_logger 0.11.8", + "gl-sdk", + "hex", + "serde", + "serde_json", + "thiserror 2.0.17", +] + [[package]] name = "gl-sdk-node" version = "0.1.0" @@ -2177,9 +2225,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.177" +version = "0.2.180" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" [[package]] name = "libloading" @@ -2473,6 +2521,18 @@ dependencies = [ "libc", ] +[[package]] +name = "nix" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "225e7cfe711e0ba79a68baeddb2982723e4235247aefce1482f2f16c27865b66" +dependencies = [ + "bitflags 2.10.0", + "cfg-if", + "cfg_aliases", + "libc", +] + [[package]] name = "no-std-compat" version = "0.4.1" @@ -2591,6 +2651,21 @@ dependencies = [ "libm", ] +[[package]] +name = "objc2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + [[package]] name = "object" version = "0.37.3" diff --git a/Cargo.toml b/Cargo.toml index 52e29dba9..5aef094ee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ members = [ "libs/gl-sdk", "libs/uniffi-bindgen", "libs/gl-sdk-napi", + "libs/gl-sdk-cli", ] [workspace.dependencies] diff --git a/libs/gl-sdk-cli/Cargo.toml b/libs/gl-sdk-cli/Cargo.toml new file mode 100644 index 000000000..53bb57e0a --- /dev/null +++ b/libs/gl-sdk-cli/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "gl-sdk-cli" +version = "0.1.0" +edition = "2021" +description = "CLI wrapper for gl-sdk" + +[[bin]] +name = "glsdk" +path = "src/bin/glsdk.rs" + +[dependencies] +glsdk = { path = "../gl-sdk", package = "gl-sdk" } +bip39 = { version = "2.2", features = ["rand"] } +clap = { version = "4.5", features = ["derive"] } +dirs = "6.0" +env_logger = "0.11" +hex = "0.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2.0" +ctrlc = { version = "3.4", features = ["termination"] } diff --git a/libs/gl-sdk-cli/src/bin/glsdk.rs b/libs/gl-sdk-cli/src/bin/glsdk.rs new file mode 100644 index 000000000..1842db0a1 --- /dev/null +++ b/libs/gl-sdk-cli/src/bin/glsdk.rs @@ -0,0 +1,8 @@ +use clap::Parser; + +fn main() { + let cli = gl_sdk_cli::Cli::parse(); + if let Err(e) = gl_sdk_cli::run(cli) { + e.print_and_exit(); + } +} diff --git a/libs/gl-sdk-cli/src/error.rs b/libs/gl-sdk-cli/src/error.rs new file mode 100644 index 000000000..fd2a53902 --- /dev/null +++ b/libs/gl-sdk-cli/src/error.rs @@ -0,0 +1,43 @@ +use serde::Serialize; + +pub type Result = std::result::Result; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("{0}")] + Sdk(#[from] glsdk::Error), + + #[error("{0}")] + Io(#[from] std::io::Error), + + #[error("{0}")] + Bip39(#[from] bip39::Error), + + #[error("{0}")] + Json(#[from] serde_json::Error), + + #[error("Phrase not found: {0}")] + PhraseNotFound(String), + + #[error("Credentials not found: {0}")] + CredentialsNotFound(String), + + #[error("{0}")] + Other(String), +} + +#[derive(Serialize)] +struct ErrorJson { + error: String, +} + +impl Error { + pub fn print_and_exit(&self) -> ! { + let json = serde_json::to_string(&ErrorJson { + error: self.to_string(), + }) + .unwrap_or_else(|_| format!("{{\"error\":\"{}\"}}", self)); + eprintln!("{}", json); + std::process::exit(1); + } +} diff --git a/libs/gl-sdk-cli/src/lib.rs b/libs/gl-sdk-cli/src/lib.rs new file mode 100644 index 000000000..260449b20 --- /dev/null +++ b/libs/gl-sdk-cli/src/lib.rs @@ -0,0 +1,67 @@ +use clap::{Parser, Subcommand}; +use std::path::PathBuf; + +pub mod error; +pub mod node; +pub mod output; +pub mod scheduler; +pub mod signer; +pub mod util; + +use error::Result; + +#[derive(Parser, Debug)] +#[command(name = "glsdk", about = "CLI for gl-sdk", version)] +pub struct Cli { + /// Data directory for phrase and credentials + #[arg(short, long, global = true, help_heading = "Global options")] + data_dir: Option, + + /// Bitcoin network (bitcoin or regtest) + #[arg(short, long, default_value = "bitcoin", global = true, help_heading = "Global options")] + network: String, + + /// Enable debug logging + #[arg(short, long, global = true, help_heading = "Global options")] + verbose: bool, + + #[command(subcommand)] + cmd: Commands, +} + +#[derive(Subcommand, Debug)] +pub enum Commands { + /// Interact with the scheduler (register, recover) + #[command(subcommand)] + Scheduler(scheduler::Command), + + /// Interact with the local signer + #[command(subcommand)] + Signer(signer::Command), + + /// Interact with the node + #[command(subcommand)] + Node(node::Command), +} + +pub fn run(cli: Cli) -> Result<()> { + if cli.verbose { + if std::env::var("RUST_LOG").is_err() { + unsafe { std::env::set_var("RUST_LOG", "debug") }; + } + env_logger::init(); + } + + let data_dir = cli + .data_dir + .map(|d| util::DataDir(PathBuf::from(d))) + .unwrap_or_default(); + + let network = util::parse_network(&cli.network)?; + + match cli.cmd { + Commands::Scheduler(cmd) => scheduler::handle(cmd, &data_dir, network), + Commands::Signer(cmd) => signer::handle(cmd, &data_dir), + Commands::Node(cmd) => node::handle(cmd, &data_dir), + } +} diff --git a/libs/gl-sdk-cli/src/node.rs b/libs/gl-sdk-cli/src/node.rs new file mode 100644 index 000000000..448a1da8d --- /dev/null +++ b/libs/gl-sdk-cli/src/node.rs @@ -0,0 +1,171 @@ +use crate::error::{Error, Result}; +use crate::output::{self, *}; +use crate::util::{self, DataDir}; +use clap::Subcommand; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; + +#[derive(Subcommand, Debug)] +pub enum Command { + /// Get basic node information + GetInfo, + /// List connected peers + ListPeers, + /// List channels with peers + ListPeerChannels, + /// List available funds + ListFunds, + /// Create a lightning invoice + Receive { + /// Invoice label + label: String, + /// Invoice description + description: String, + /// Amount in millisatoshis (omit for any-amount invoice) + #[arg(long)] + amount_msat: Option, + }, + /// Pay a lightning invoice + Send { + /// BOLT11 invoice to pay + invoice: String, + /// Amount in millisatoshis (for amount-less invoices) + #[arg(long)] + amount_msat: Option, + }, + /// Get a new on-chain address + OnchainReceive, + /// Send funds on-chain + OnchainSend { + /// Destination bitcoin address + destination: String, + /// Amount in satoshis, or "all" + amount_or_all: String, + }, + /// Stream real-time node events (blocks until Ctrl+C) + StreamEvents, + /// Stop the node + Stop, +} + +pub fn handle(cmd: Command, data_dir: &DataDir) -> Result<()> { + let creds = util::read_credentials(data_dir)?; + let node = glsdk::Node::new(&creds).map_err(|e| Error::Other(e.to_string()))?; + + match cmd { + Command::GetInfo => get_info(&node), + Command::ListPeers => list_peers(&node), + Command::ListPeerChannels => list_peer_channels(&node), + Command::ListFunds => list_funds(&node), + Command::Receive { + label, + description, + amount_msat, + } => receive(&node, label, description, amount_msat), + Command::Send { + invoice, + amount_msat, + } => send(&node, invoice, amount_msat), + Command::OnchainReceive => onchain_receive(&node), + Command::OnchainSend { + destination, + amount_or_all, + } => onchain_send(&node, destination, amount_or_all), + Command::StreamEvents => stream_events(&node), + Command::Stop => stop(&node), + } +} + +fn get_info(node: &glsdk::Node) -> Result<()> { + let res = node.get_info()?; + output::print_json(&GetInfoOutput::from(res)); + Ok(()) +} + +fn list_peers(node: &glsdk::Node) -> Result<()> { + let res = node.list_peers()?; + output::print_json(&ListPeersOutput::from(res)); + Ok(()) +} + +fn list_peer_channels(node: &glsdk::Node) -> Result<()> { + let res = node.list_peer_channels()?; + output::print_json(&ListPeerChannelsOutput::from(res)); + Ok(()) +} + +fn list_funds(node: &glsdk::Node) -> Result<()> { + let res = node.list_funds()?; + output::print_json(&ListFundsOutput::from(res)); + Ok(()) +} + +fn receive( + node: &glsdk::Node, + label: String, + description: String, + amount_msat: Option, +) -> Result<()> { + let res = node.receive(label, description, amount_msat)?; + output::print_json(&ReceiveOutput::from(res)); + Ok(()) +} + +fn send(node: &glsdk::Node, invoice: String, amount_msat: Option) -> Result<()> { + let res = node.send(invoice, amount_msat)?; + output::print_json(&SendOutput::from(res)); + Ok(()) +} + +fn onchain_receive(node: &glsdk::Node) -> Result<()> { + let res = node.onchain_receive()?; + output::print_json(&OnchainReceiveOutput::from(res)); + Ok(()) +} + +fn onchain_send(node: &glsdk::Node, destination: String, amount_or_all: String) -> Result<()> { + let res = node.onchain_send(destination, amount_or_all)?; + output::print_json(&OnchainSendOutput::from(res)); + Ok(()) +} + +fn stream_events(node: &glsdk::Node) -> Result<()> { + let stream = node.stream_node_events()?; + + let running = Arc::new(AtomicBool::new(true)); + let r = running.clone(); + ctrlc::set_handler(move || { + r.store(false, Ordering::SeqCst); + }) + .map_err(|e| Error::Other(e.to_string()))?; + + eprintln!("Streaming events, press Ctrl+C to stop"); + + while running.load(Ordering::SeqCst) { + match stream.next() { + Ok(Some(event)) => { + output::print_json(&NodeEventOutput::from(event)); + } + Ok(None) => { + eprintln!("Event stream ended"); + break; + } + Err(e) => { + if running.load(Ordering::SeqCst) { + return Err(e.into()); + } + break; + } + } + } + + Ok(()) +} + +fn stop(node: &glsdk::Node) -> Result<()> { + node.stop()?; + eprintln!("Node stopped"); + Ok(()) +} diff --git a/libs/gl-sdk-cli/src/output.rs b/libs/gl-sdk-cli/src/output.rs new file mode 100644 index 000000000..635342afa --- /dev/null +++ b/libs/gl-sdk-cli/src/output.rs @@ -0,0 +1,351 @@ +use serde::Serialize; + +pub fn print_json(value: &T) { + println!("{}", serde_json::to_string_pretty(value).unwrap()); +} + +// ============================================================ +// GetInfo +// ============================================================ + +#[derive(Serialize)] +pub struct GetInfoOutput { + pub id: String, + pub alias: Option, + pub color: String, + pub num_peers: u32, + pub num_pending_channels: u32, + pub num_active_channels: u32, + pub num_inactive_channels: u32, + pub version: String, + pub lightning_dir: String, + pub blockheight: u32, + pub network: String, + pub fees_collected_msat: u64, +} + +impl From for GetInfoOutput { + fn from(r: glsdk::GetInfoResponse) -> Self { + Self { + id: hex::encode(&r.id), + alias: r.alias, + color: hex::encode(&r.color), + num_peers: r.num_peers, + num_pending_channels: r.num_pending_channels, + num_active_channels: r.num_active_channels, + num_inactive_channels: r.num_inactive_channels, + version: r.version, + lightning_dir: r.lightning_dir, + blockheight: r.blockheight, + network: r.network, + fees_collected_msat: r.fees_collected_msat, + } + } +} + +// ============================================================ +// ListPeers +// ============================================================ + +#[derive(Serialize)] +pub struct ListPeersOutput { + pub peers: Vec, +} + +#[derive(Serialize)] +pub struct PeerOutput { + pub id: String, + pub connected: bool, + pub num_channels: Option, + pub netaddr: Vec, + pub remote_addr: Option, + pub features: Option, +} + +impl From for ListPeersOutput { + fn from(r: glsdk::ListPeersResponse) -> Self { + Self { + peers: r.peers.into_iter().map(Into::into).collect(), + } + } +} + +impl From for PeerOutput { + fn from(p: glsdk::Peer) -> Self { + Self { + id: hex::encode(&p.id), + connected: p.connected, + num_channels: p.num_channels, + netaddr: p.netaddr, + remote_addr: p.remote_addr, + features: p.features.map(|f| hex::encode(&f)), + } + } +} + +// ============================================================ +// ListPeerChannels +// ============================================================ + +#[derive(Serialize)] +pub struct ListPeerChannelsOutput { + pub channels: Vec, +} + +#[derive(Serialize)] +pub struct PeerChannelOutput { + pub peer_id: String, + pub peer_connected: bool, + pub state: String, + pub short_channel_id: Option, + pub channel_id: Option, + pub funding_txid: Option, + pub funding_outnum: Option, + pub to_us_msat: Option, + pub total_msat: Option, + pub spendable_msat: Option, + pub receivable_msat: Option, +} + +impl From for ListPeerChannelsOutput { + fn from(r: glsdk::ListPeerChannelsResponse) -> Self { + Self { + channels: r.channels.into_iter().map(Into::into).collect(), + } + } +} + +fn channel_state_str(s: &glsdk::ChannelState) -> &'static str { + match s { + glsdk::ChannelState::Openingd => "OPENINGD", + glsdk::ChannelState::ChanneldAwaitingLockin => "CHANNELD_AWAITING_LOCKIN", + glsdk::ChannelState::ChanneldNormal => "CHANNELD_NORMAL", + glsdk::ChannelState::ChanneldShuttingDown => "CHANNELD_SHUTTING_DOWN", + glsdk::ChannelState::ClosingdSigexchange => "CLOSINGD_SIGEXCHANGE", + glsdk::ChannelState::ClosingdComplete => "CLOSINGD_COMPLETE", + glsdk::ChannelState::AwaitingUnilateral => "AWAITING_UNILATERAL", + glsdk::ChannelState::FundingSpendSeen => "FUNDING_SPEND_SEEN", + glsdk::ChannelState::Onchain => "ONCHAIN", + glsdk::ChannelState::DualopendOpenInit => "DUALOPEND_OPEN_INIT", + glsdk::ChannelState::DualopendAwaitingLockin => "DUALOPEND_AWAITING_LOCKIN", + glsdk::ChannelState::DualopendOpenCommitted => "DUALOPEND_OPEN_COMMITTED", + glsdk::ChannelState::DualopendOpenCommitReady => "DUALOPEND_OPEN_COMMIT_READY", + } +} + +impl From for PeerChannelOutput { + fn from(c: glsdk::PeerChannel) -> Self { + Self { + peer_id: hex::encode(&c.peer_id), + peer_connected: c.peer_connected, + state: channel_state_str(&c.state).to_string(), + short_channel_id: c.short_channel_id, + channel_id: c.channel_id.map(|v| hex::encode(&v)), + funding_txid: c.funding_txid.map(|v| hex::encode(&v)), + funding_outnum: c.funding_outnum, + to_us_msat: c.to_us_msat, + total_msat: c.total_msat, + spendable_msat: c.spendable_msat, + receivable_msat: c.receivable_msat, + } + } +} + +// ============================================================ +// ListFunds +// ============================================================ + +#[derive(Serialize)] +pub struct ListFundsOutput { + pub outputs: Vec, + pub channels: Vec, +} + +#[derive(Serialize)] +pub struct FundOutputOutput { + pub txid: String, + pub output: u32, + pub amount_msat: u64, + pub status: String, + pub address: Option, + pub blockheight: Option, +} + +#[derive(Serialize)] +pub struct FundChannelOutput { + pub peer_id: String, + pub our_amount_msat: u64, + pub amount_msat: u64, + pub funding_txid: String, + pub funding_output: u32, + pub connected: bool, + pub state: String, + pub short_channel_id: Option, + pub channel_id: Option, +} + +fn output_status_str(s: &glsdk::OutputStatus) -> &'static str { + match s { + glsdk::OutputStatus::Unconfirmed => "unconfirmed", + glsdk::OutputStatus::Confirmed => "confirmed", + glsdk::OutputStatus::Spent => "spent", + glsdk::OutputStatus::Immature => "immature", + } +} + +impl From for ListFundsOutput { + fn from(r: glsdk::ListFundsResponse) -> Self { + Self { + outputs: r.outputs.into_iter().map(Into::into).collect(), + channels: r.channels.into_iter().map(Into::into).collect(), + } + } +} + +impl From for FundOutputOutput { + fn from(o: glsdk::FundOutput) -> Self { + Self { + txid: hex::encode(&o.txid), + output: o.output, + amount_msat: o.amount_msat, + status: output_status_str(&o.status).to_string(), + address: o.address, + blockheight: o.blockheight, + } + } +} + +impl From for FundChannelOutput { + fn from(c: glsdk::FundChannel) -> Self { + Self { + peer_id: hex::encode(&c.peer_id), + our_amount_msat: c.our_amount_msat, + amount_msat: c.amount_msat, + funding_txid: hex::encode(&c.funding_txid), + funding_output: c.funding_output, + connected: c.connected, + state: channel_state_str(&c.state).to_string(), + short_channel_id: c.short_channel_id, + channel_id: c.channel_id.map(|v| hex::encode(&v)), + } + } +} + +// ============================================================ +// Receive / Send / Onchain +// ============================================================ + +#[derive(Serialize)] +pub struct ReceiveOutput { + pub bolt11: String, +} + +impl From for ReceiveOutput { + fn from(r: glsdk::ReceiveResponse) -> Self { + Self { bolt11: r.bolt11 } + } +} + +#[derive(Serialize)] +pub struct SendOutput { + pub status: String, + pub preimage: String, + pub amount_msat: u64, + pub amount_sent_msat: u64, + pub parts: u32, +} + +fn pay_status_str(s: &glsdk::PayStatus) -> &'static str { + match s { + glsdk::PayStatus::COMPLETE => "complete", + glsdk::PayStatus::PENDING => "pending", + glsdk::PayStatus::FAILED => "failed", + } +} + +impl From for SendOutput { + fn from(r: glsdk::SendResponse) -> Self { + Self { + status: pay_status_str(&r.status).to_string(), + preimage: hex::encode(&r.preimage), + amount_msat: r.amount_msat, + amount_sent_msat: r.amount_sent_msat, + parts: r.parts, + } + } +} + +#[derive(Serialize)] +pub struct OnchainReceiveOutput { + pub bech32: String, + pub p2tr: String, +} + +impl From for OnchainReceiveOutput { + fn from(r: glsdk::OnchainReceiveResponse) -> Self { + Self { + bech32: r.bech32, + p2tr: r.p2tr, + } + } +} + +#[derive(Serialize)] +pub struct OnchainSendOutput { + pub tx: String, + pub txid: String, + pub psbt: String, +} + +impl From for OnchainSendOutput { + fn from(r: glsdk::OnchainSendResponse) -> Self { + Self { + tx: hex::encode(&r.tx), + txid: hex::encode(&r.txid), + psbt: r.psbt, + } + } +} + +// ============================================================ +// NodeEvent +// ============================================================ + +#[derive(Serialize)] +#[serde(tag = "type")] +pub enum NodeEventOutput { + #[serde(rename = "invoice_paid")] + InvoicePaid { + payment_hash: String, + bolt11: String, + preimage: String, + label: String, + amount_msat: u64, + }, + #[serde(rename = "unknown")] + Unknown, +} + +impl From for NodeEventOutput { + fn from(e: glsdk::NodeEvent) -> Self { + match e { + glsdk::NodeEvent::InvoicePaid { details } => NodeEventOutput::InvoicePaid { + payment_hash: hex::encode(&details.payment_hash), + bolt11: details.bolt11, + preimage: hex::encode(&details.preimage), + label: details.label, + amount_msat: details.amount_msat, + }, + glsdk::NodeEvent::Unknown => NodeEventOutput::Unknown, + } + } +} + +// ============================================================ +// Signer node-id +// ============================================================ + +#[derive(Serialize)] +pub struct NodeIdOutput { + pub node_id: String, +} diff --git a/libs/gl-sdk-cli/src/scheduler.rs b/libs/gl-sdk-cli/src/scheduler.rs new file mode 100644 index 000000000..be80f99fc --- /dev/null +++ b/libs/gl-sdk-cli/src/scheduler.rs @@ -0,0 +1,62 @@ +use crate::error::{Error, Result}; +use crate::util::{self, DataDir}; +use clap::Subcommand; + +#[derive(Subcommand, Debug)] +pub enum Command { + /// Register a new greenlight node + Register { + /// An invite code for greenlight + #[arg(short, long)] + invite_code: Option, + }, + /// Recover credentials for an existing node + Recover, +} + +pub fn handle(cmd: Command, data_dir: &DataDir, network: glsdk::Network) -> Result<()> { + match cmd { + Command::Register { invite_code } => register(data_dir, network, invite_code), + Command::Recover => recover(data_dir, network), + } +} + +fn register( + data_dir: &DataDir, + network: glsdk::Network, + invite_code: Option, +) -> Result<()> { + // Generate or load secret + let signer = match util::read_secret(data_dir) { + Ok(secret) => { + eprintln!("Secret already exists, using it"); + util::signer_from_secret(secret)? + } + Err(_) => { + let mnemonic = bip39::Mnemonic::generate(12)?; + let phrase = mnemonic.to_string(); + util::write_phrase(data_dir, &phrase)?; + eprintln!("New mnemonic generated and saved"); + eprintln!("Recovery phrase: {phrase}"); + glsdk::Signer::new(phrase).map_err(Error::Sdk)? + } + }; + let scheduler = glsdk::Scheduler::new(network)?; + let creds = scheduler.register(&signer, invite_code)?; + + util::write_credentials(data_dir, &creds)?; + eprintln!("Credentials saved"); + + Ok(()) +} + +fn recover(data_dir: &DataDir, network: glsdk::Network) -> Result<()> { + let signer = util::make_signer(data_dir)?; + let scheduler = glsdk::Scheduler::new(network)?; + let creds = scheduler.recover(&signer)?; + + util::write_credentials(data_dir, &creds)?; + eprintln!("Credentials recovered and saved"); + + Ok(()) +} diff --git a/libs/gl-sdk-cli/src/signer.rs b/libs/gl-sdk-cli/src/signer.rs new file mode 100644 index 000000000..31bb1d9e5 --- /dev/null +++ b/libs/gl-sdk-cli/src/signer.rs @@ -0,0 +1,58 @@ +use crate::error::{Error, Result}; +use crate::output::{self, NodeIdOutput}; +use crate::util::{self, DataDir}; +use clap::Subcommand; +use std::sync::{ + atomic::{AtomicBool, Ordering}, + Arc, +}; + +#[derive(Subcommand, Debug)] +pub enum Command { + /// Start the signer (blocks until Ctrl+C) + Run, + /// Print the node's public key + NodeId, +} + +pub fn handle(cmd: Command, data_dir: &DataDir) -> Result<()> { + match cmd { + Command::Run => run(data_dir), + Command::NodeId => node_id(data_dir), + } +} + +fn run(data_dir: &DataDir) -> Result<()> { + let creds = util::read_credentials(data_dir)?; + let signer = util::make_signer(data_dir)?; + let signer = signer + .authenticate(&creds) + .map_err(|e| Error::Other(e.to_string()))?; + let handle = signer.start().map_err(|e| Error::Other(e.to_string()))?; + + eprintln!("Signer running, press Ctrl+C to stop"); + + let running = Arc::new(AtomicBool::new(true)); + let r = running.clone(); + ctrlc::set_handler(move || { + r.store(false, Ordering::SeqCst); + }) + .map_err(|e| Error::Other(e.to_string()))?; + + while running.load(Ordering::SeqCst) { + std::thread::sleep(std::time::Duration::from_millis(100)); + } + + eprintln!("\nStopping signer..."); + handle.stop(); + Ok(()) +} + +fn node_id(data_dir: &DataDir) -> Result<()> { + let signer = util::make_signer(data_dir)?; + let id = signer.node_id(); + output::print_json(&NodeIdOutput { + node_id: hex::encode(&id), + }); + Ok(()) +} diff --git a/libs/gl-sdk-cli/src/util.rs b/libs/gl-sdk-cli/src/util.rs new file mode 100644 index 000000000..0c2b1d1dc --- /dev/null +++ b/libs/gl-sdk-cli/src/util.rs @@ -0,0 +1,91 @@ +use crate::error::{Error, Result}; +use std::fs; +use std::path::{Path, PathBuf}; + +pub const PHRASE_FILE_NAME: &str = "hsm_secret"; +pub const CREDENTIALS_FILE_NAME: &str = "credentials.gfs"; +const DEFAULT_GREENLIGHT_DIR: &str = "greenlight"; + +pub struct DataDir(pub PathBuf); + +impl Default for DataDir { + fn default() -> Self { + let data_dir = dirs::data_dir().unwrap().join(DEFAULT_GREENLIGHT_DIR); + Self(data_dir) + } +} + +impl AsRef for DataDir { + fn as_ref(&self) -> &Path { + self.0.as_path() + } +} + +/// The secret stored in `hsm_secret` — either a BIP39 mnemonic +/// phrase (text) or raw seed bytes (gl-cli legacy format). +pub enum Secret { + Phrase(String), + Seed(Vec), +} + +pub fn read_secret(data_dir: &DataDir) -> Result { + let path = data_dir.0.join(PHRASE_FILE_NAME); + let raw = fs::read(&path).map_err(|_| { + Error::PhraseNotFound(format!("could not read from {}", path.display())) + })?; + + // Try UTF-8 mnemonic first (glsdk format) + if let Ok(text) = std::str::from_utf8(&raw) { + let trimmed = text.trim(); + if !trimmed.is_empty() && trimmed.contains(' ') { + return Ok(Secret::Phrase(trimmed.to_string())); + } + } + + // Raw seed bytes (gl-cli legacy format) + Ok(Secret::Seed(raw)) +} + +pub fn signer_from_secret(secret: Secret) -> Result { + match secret { + Secret::Phrase(phrase) => glsdk::Signer::new(phrase).map_err(Error::Sdk), + Secret::Seed(seed) => glsdk::Signer::new_from_seed(seed).map_err(Error::Sdk), + } +} + +pub fn make_signer(data_dir: &DataDir) -> Result { + signer_from_secret(read_secret(data_dir)?) +} + +pub fn write_phrase(data_dir: &DataDir, phrase: &str) -> Result<()> { + fs::create_dir_all(&data_dir.0)?; + let path = data_dir.0.join(PHRASE_FILE_NAME); + fs::write(&path, phrase)?; + Ok(()) +} + +pub fn read_credentials(data_dir: &DataDir) -> Result { + let path = data_dir.0.join(CREDENTIALS_FILE_NAME); + let raw = fs::read(&path).map_err(|_| { + Error::CredentialsNotFound(format!("could not read from {}", path.display())) + })?; + glsdk::Credentials::load(raw).map_err(Error::Sdk) +} + +pub fn write_credentials(data_dir: &DataDir, creds: &glsdk::Credentials) -> Result<()> { + fs::create_dir_all(&data_dir.0)?; + let path = data_dir.0.join(CREDENTIALS_FILE_NAME); + let raw = creds.save()?; + fs::write(&path, &raw)?; + Ok(()) +} + +pub fn parse_network(s: &str) -> Result { + match s { + "bitcoin" => Ok(glsdk::Network::BITCOIN), + "regtest" => Ok(glsdk::Network::REGTEST), + _ => Err(Error::Other(format!( + "unsupported network: {s} (expected bitcoin or regtest)" + ))), + } +} diff --git a/libs/gl-sdk/src/signer.rs b/libs/gl-sdk/src/signer.rs index 404f74574..ea2b4bf9d 100644 --- a/libs/gl-sdk/src/signer.rs +++ b/libs/gl-sdk/src/signer.rs @@ -16,9 +16,11 @@ impl Signer { pub fn new(phrase: String) -> Result { let phrase = Mnemonic::from_str(phrase.as_str()).map_err(|_e| Error::PhraseCorrupted())?; let seed = phrase.to_seed_normalized(&"").to_vec(); + Self::new_from_seed(seed) + } - // FIXME: We may need to give the signer real credentials to - // talk to the node too. + #[uniffi::constructor()] + pub fn new_from_seed(seed: Vec) -> Result { let credentials = gl_client::credentials::Nobody::new(); let inner = gl_client::signer::Signer::new(