From 824da2588172a23b730dc0602b5137be87bb8e6c Mon Sep 17 00:00:00 2001 From: benthecarman Date: Fri, 6 Feb 2026 15:49:43 -0600 Subject: [PATCH 1/2] Make CLI commands use positional arguments Made it so the primary arguments in our cli commands no longer use named flags but instead use positional arguments. For example, bolt11-send can now be used as `bolt11-send ` instead of `bolt11-send --invoice `. --- ldk-server-cli/src/main.rs | 81 +++++++++++++++----------------------- 1 file changed, 32 insertions(+), 49 deletions(-) diff --git a/ldk-server-cli/src/main.rs b/ldk-server-cli/src/main.rs index 1a44f8aa..e89cbfd2 100644 --- a/ldk-server-cli/src/main.rs +++ b/ldk-server-cli/src/main.rs @@ -95,10 +95,9 @@ enum Commands { OnchainReceive, #[command(about = "Send an on-chain payment to the given address")] OnchainSend { - #[arg(short, long, help = "The address to send coins to")] + #[arg(help = "The address to send coins to")] address: String, #[arg( - long, help = "The amount in satoshis to send. Will respect any on-chain reserve needed for anchor channels" )] amount_sats: Option, @@ -115,6 +114,10 @@ enum Commands { }, #[command(about = "Create a BOLT11 invoice to receive a payment")] Bolt11Receive { + #[arg( + help = "Amount in millisatoshis to request. If unset, a variable-amount invoice is returned" + )] + amount_msat: Option, #[arg(short, long, help = "Description to attach along with the invoice")] description: Option, #[arg( @@ -124,17 +127,12 @@ enum Commands { description_hash: Option, #[arg(short, long, help = "Invoice expiry time in seconds (default: 86400)")] expiry_secs: Option, - #[arg( - long, - help = "Amount in millisatoshis to request. If unset, a variable-amount invoice is returned" - )] - amount_msat: Option, }, #[command(about = "Pay a BOLT11 invoice")] Bolt11Send { - #[arg(short, long, help = "A BOLT11 invoice for a payment within the Lightning Network")] + #[arg(help = "A BOLT11 invoice for a payment within the Lightning Network")] invoice: String, - #[arg(long, help = "Amount in millisatoshis. Required when paying a zero-amount invoice")] + #[arg(help = "Amount in millisatoshis. Required when paying a zero-amount invoice")] amount_msat: Option, #[arg( long, @@ -156,13 +154,12 @@ enum Commands { }, #[command(about = "Return a BOLT12 offer for receiving payments")] Bolt12Receive { - #[arg(short, long, help = "Description to attach along with the offer")] - description: String, #[arg( - long, help = "Amount in millisatoshis to request. If unset, a variable-amount offer is returned" )] amount_msat: Option, + #[arg(help = "Description to attach along with the offer")] + description: String, #[arg(long, help = "Offer expiry time in seconds")] expiry_secs: Option, #[arg(long, help = "Number of items requested. Can only be set for fixed-amount offers")] @@ -170,9 +167,9 @@ enum Commands { }, #[command(about = "Send a payment for a BOLT12 offer")] Bolt12Send { - #[arg(short, long, help = "A BOLT12 offer for a payment within the Lightning Network")] + #[arg(help = "A BOLT12 offer for a payment within the Lightning Network")] offer: String, - #[arg(long, help = "Amount in millisatoshis. Required when paying a zero-amount offer")] + #[arg(help = "Amount in millisatoshis. Required when paying a zero-amount offer")] amount_msat: Option, #[arg(short, long, help = "Number of items requested")] quantity: Option, @@ -202,9 +199,9 @@ enum Commands { }, #[command(about = "Send a spontaneous payment (keysend) to a node")] SpontaneousSend { - #[arg(short, long, help = "The hex-encoded public key of the node to send the payment to")] + #[arg(help = "The hex-encoded public key of the node to send the payment to")] node_id: String, - #[arg(short, long, help = "The amount in millisatoshis to send")] + #[arg(help = "The amount in millisatoshis to send")] amount_msat: u64, #[arg( long, @@ -226,39 +223,29 @@ enum Commands { }, #[command(about = "Cooperatively close the channel specified by the given channel ID")] CloseChannel { - #[arg(short, long, help = "The local user_channel_id of this channel")] + #[arg(help = "The local user_channel_id of this channel")] user_channel_id: String, - #[arg( - short, - long, - help = "The hex-encoded public key of the node to close a channel with" - )] + #[arg(help = "The hex-encoded public key of the node to close a channel with")] counterparty_node_id: String, }, #[command(about = "Force close the channel specified by the given channel ID")] ForceCloseChannel { - #[arg(short, long, help = "The local user_channel_id of this channel")] + #[arg(help = "The local user_channel_id of this channel")] user_channel_id: String, - #[arg( - short, - long, - help = "The hex-encoded public key of the node to close a channel with" - )] + #[arg(help = "The hex-encoded public key of the node to close a channel with")] counterparty_node_id: String, #[arg(long, help = "The reason for force-closing, defaults to \"\"")] force_close_reason: Option, }, #[command(about = "Create a new outbound channel to the given remote node")] OpenChannel { - #[arg(short, long, help = "The hex-encoded public key of the node to open a channel with")] + #[arg(help = "The hex-encoded public key of the node to open a channel with")] node_pubkey: String, #[arg( - short, - long, help = "Address to connect to remote peer (IPv4:port, IPv6:port, OnionV3:port, or hostname:port)" )] address: String, - #[arg(long, help = "The amount of satoshis to commit to the channel")] + #[arg(help = "The amount of satoshis to commit to the channel")] channel_amount_sats: u64, #[arg( long, @@ -288,20 +275,20 @@ enum Commands { about = "Increase the channel balance by the given amount, funds will come from the node's on-chain wallet" )] SpliceIn { - #[arg(short, long, help = "The local user_channel_id of the channel")] + #[arg(help = "The local user_channel_id of the channel")] user_channel_id: String, - #[arg(short, long, help = "The hex-encoded public key of the channel's counterparty node")] + #[arg(help = "The hex-encoded public key of the channel's counterparty node")] counterparty_node_id: String, - #[arg(long, help = "The amount of sats to splice into the channel")] + #[arg(help = "The amount of sats to splice into the channel")] splice_amount_sats: u64, }, #[command(about = "Decrease the channel balance by the given amount")] SpliceOut { - #[arg(short, long, help = "The local user_channel_id of this channel")] + #[arg(help = "The local user_channel_id of this channel")] user_channel_id: String, - #[arg(short, long, help = "The hex-encoded public key of the channel's counterparty node")] + #[arg(help = "The hex-encoded public key of the channel's counterparty node")] counterparty_node_id: String, - #[arg(long, help = "The amount of sats to splice out of the channel")] + #[arg(help = "The amount of sats to splice out of the channel")] splice_amount_sats: u64, #[arg( short, @@ -325,7 +312,7 @@ enum Commands { }, #[command(about = "Get details of a specific payment by its payment ID")] GetPaymentDetails { - #[arg(short, long, help = "The payment ID in hex-encoded form")] + #[arg(help = "The payment ID in hex-encoded form")] payment_id: String, }, #[command(about = "Retrieves list of all forwarded payments")] @@ -340,11 +327,9 @@ enum Commands { page_token: Option, }, UpdateChannelConfig { - #[arg(short, long, help = "The local user_channel_id of this channel")] + #[arg(help = "The local user_channel_id of this channel")] user_channel_id: String, #[arg( - short, - long, help = "The hex-encoded public key of the counterparty node to update channel config with" )] counterparty_node_id: String, @@ -366,11 +351,9 @@ enum Commands { }, #[command(about = "Connect to a peer on the Lightning Network without opening a channel")] ConnectPeer { - #[arg(short, long, help = "The hex-encoded public key of the node to connect to")] + #[arg(help = "The hex-encoded public key of the node to connect to")] node_pubkey: String, #[arg( - short, - long, help = "Address to connect to remote peer (IPv4:port, IPv6:port, OnionV3:port, or hostname:port)" )] address: String, @@ -383,16 +366,16 @@ enum Commands { }, #[command(about = "Sign a message with the node's secret key")] SignMessage { - #[arg(short, long, help = "The message to sign")] + #[arg(help = "The message to sign")] message: String, }, #[command(about = "Verify a signature against a message and public key")] VerifySignature { - #[arg(short, long, help = "The message that was signed")] + #[arg(help = "The message that was signed")] message: String, - #[arg(short, long, help = "The zbase32-encoded signature to verify")] + #[arg(help = "The zbase32-encoded signature to verify")] signature: String, - #[arg(short, long, help = "The hex-encoded public key of the signer")] + #[arg(help = "The hex-encoded public key of the signer")] public_key: String, }, #[command(about = "Export the pathfinding scores used by the router")] From 423b3d380d48890a1f704ce02d4be107e268b745 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Tue, 10 Feb 2026 15:48:38 -0600 Subject: [PATCH 2/2] Add Amount type for CLI amount arguments Replace raw u64 amount fields with an Amount type that requires a denomination suffix (e.g. 50sat, 50000msat). This removes ambiguity about whether positional arguments are in sats or msats. --- ldk-server-cli/src/main.rs | 117 ++++++++++++++++------------ ldk-server-cli/src/types.rs | 147 ++++++++++++++++++++++++++++++++++++ 2 files changed, 214 insertions(+), 50 deletions(-) diff --git a/ldk-server-cli/src/main.rs b/ldk-server-cli/src/main.rs index e89cbfd2..54f56013 100644 --- a/ldk-server-cli/src/main.rs +++ b/ldk-server-cli/src/main.rs @@ -40,7 +40,9 @@ use ldk_server_client::ldk_server_protos::types::{ }; use serde::Serialize; use serde_json::{json, Value}; -use types::{CliListForwardedPaymentsResponse, CliListPaymentsResponse, CliPaginatedResponse}; +use types::{ + Amount, CliListForwardedPaymentsResponse, CliListPaymentsResponse, CliPaginatedResponse, +}; mod config; mod types; @@ -97,10 +99,8 @@ enum Commands { OnchainSend { #[arg(help = "The address to send coins to")] address: String, - #[arg( - help = "The amount in satoshis to send. Will respect any on-chain reserve needed for anchor channels" - )] - amount_sats: Option, + #[arg(help = "The amount to send, e.g. 50sat or 50000msat")] + amount: Option, #[arg( long, help = "Send full balance to the address. Warning: will not retain on-chain reserves for anchor channels" @@ -115,9 +115,9 @@ enum Commands { #[command(about = "Create a BOLT11 invoice to receive a payment")] Bolt11Receive { #[arg( - help = "Amount in millisatoshis to request. If unset, a variable-amount invoice is returned" + help = "Amount to request, e.g. 50sat or 50000msat. If unset, a variable-amount invoice is returned" )] - amount_msat: Option, + amount: Option, #[arg(short, long, help = "Description to attach along with the invoice")] description: Option, #[arg( @@ -132,13 +132,15 @@ enum Commands { Bolt11Send { #[arg(help = "A BOLT11 invoice for a payment within the Lightning Network")] invoice: String, - #[arg(help = "Amount in millisatoshis. Required when paying a zero-amount invoice")] - amount_msat: Option, + #[arg( + help = "Amount to send, e.g. 50sat or 50000msat. Required when paying a zero-amount invoice" + )] + amount: Option, #[arg( long, - help = "Maximum total fees in millisatoshis that may accrue during route finding. Defaults to 1% of payment + 50 sats" + help = "Maximum total routing fee, e.g. 50sat or 50000msat. Defaults to 1% of payment + 50 sats" )] - max_total_routing_fee_msat: Option, + max_total_routing_fee: Option, #[arg(long, help = "Maximum total CLTV delta we accept for the route (default: 1008)")] max_total_cltv_expiry_delta: Option, #[arg( @@ -155,9 +157,9 @@ enum Commands { #[command(about = "Return a BOLT12 offer for receiving payments")] Bolt12Receive { #[arg( - help = "Amount in millisatoshis to request. If unset, a variable-amount offer is returned" + help = "Amount to request, e.g. 50sat or 50000msat. If unset, a variable-amount offer is returned" )] - amount_msat: Option, + amount: Option, #[arg(help = "Description to attach along with the offer")] description: String, #[arg(long, help = "Offer expiry time in seconds")] @@ -169,8 +171,10 @@ enum Commands { Bolt12Send { #[arg(help = "A BOLT12 offer for a payment within the Lightning Network")] offer: String, - #[arg(help = "Amount in millisatoshis. Required when paying a zero-amount offer")] - amount_msat: Option, + #[arg( + help = "Amount to send, e.g. 50sat or 50000msat. Required when paying a zero-amount offer" + )] + amount: Option, #[arg(short, long, help = "Number of items requested")] quantity: Option, #[arg( @@ -181,9 +185,9 @@ enum Commands { payer_note: Option, #[arg( long, - help = "Maximum total fees, in millisatoshi, that may accrue during route finding, Defaults to 1% of the payment amount + 50 sats" + help = "Maximum total routing fee, e.g. 50sat or 50000msat. Defaults to 1% of the payment amount + 50 sats" )] - max_total_routing_fee_msat: Option, + max_total_routing_fee: Option, #[arg(long, help = "Maximum total CLTV delta we accept for the route (default: 1008)")] max_total_cltv_expiry_delta: Option, #[arg( @@ -201,13 +205,13 @@ enum Commands { SpontaneousSend { #[arg(help = "The hex-encoded public key of the node to send the payment to")] node_id: String, - #[arg(help = "The amount in millisatoshis to send")] - amount_msat: u64, + #[arg(help = "The amount to send, e.g. 50sat or 50000msat")] + amount: Amount, #[arg( long, - help = "Maximum total fees in millisatoshis that may accrue during route finding. Defaults to 1% of payment + 50 sats" + help = "Maximum total routing fee, e.g. 50sat or 50000msat. Defaults to 1% of payment + 50 sats" )] - max_total_routing_fee_msat: Option, + max_total_routing_fee: Option, #[arg(long, help = "Maximum total CLTV delta we accept for the route (default: 1008)")] max_total_cltv_expiry_delta: Option, #[arg( @@ -245,13 +249,10 @@ enum Commands { help = "Address to connect to remote peer (IPv4:port, IPv6:port, OnionV3:port, or hostname:port)" )] address: String, - #[arg(help = "The amount of satoshis to commit to the channel")] - channel_amount_sats: u64, - #[arg( - long, - help = "Amount of satoshis to push to the remote side as part of the initial commitment state" - )] - push_to_counterparty_msat: Option, + #[arg(help = "The amount to commit to the channel, e.g. 100sat or 100000msat")] + channel_amount: Amount, + #[arg(long, help = "Amount to push to the remote side, e.g. 50sat or 50000msat")] + push_to_counterparty: Option, #[arg(long, help = "Whether the channel should be public")] announce_channel: bool, // Channel config options @@ -279,8 +280,8 @@ enum Commands { user_channel_id: String, #[arg(help = "The hex-encoded public key of the channel's counterparty node")] counterparty_node_id: String, - #[arg(help = "The amount of sats to splice into the channel")] - splice_amount_sats: u64, + #[arg(help = "The amount to splice into the channel, e.g. 50sat or 50000msat")] + splice_amount: Amount, }, #[command(about = "Decrease the channel balance by the given amount")] SpliceOut { @@ -288,8 +289,8 @@ enum Commands { user_channel_id: String, #[arg(help = "The hex-encoded public key of the channel's counterparty node")] counterparty_node_id: String, - #[arg(help = "The amount of sats to splice out of the channel")] - splice_amount_sats: u64, + #[arg(help = "The amount to splice out of the channel, e.g. 50sat or 50000msat")] + splice_amount: Amount, #[arg( short, long, @@ -464,7 +465,8 @@ async fn main() { client.onchain_receive(OnchainReceiveRequest {}).await, ); }, - Commands::OnchainSend { address, amount_sats, send_all, fee_rate_sat_per_vb } => { + Commands::OnchainSend { address, amount, send_all, fee_rate_sat_per_vb } => { + let amount_sats = amount.map(|a| a.to_sat().unwrap_or_else(|e| handle_error_msg(&e))); handle_response_result::<_, OnchainSendResponse>( client .onchain_send(OnchainSendRequest { @@ -476,7 +478,8 @@ async fn main() { .await, ); }, - Commands::Bolt11Receive { description, description_hash, expiry_secs, amount_msat } => { + Commands::Bolt11Receive { description, description_hash, expiry_secs, amount } => { + let amount_msat = amount.map(|a| a.to_msat()); let invoice_description = match (description, description_hash) { (Some(desc), None) => Some(Bolt11InvoiceDescription { kind: Some(bolt11_invoice_description::Kind::Direct(desc)), @@ -503,12 +506,14 @@ async fn main() { }, Commands::Bolt11Send { invoice, - amount_msat, - max_total_routing_fee_msat, + amount, + max_total_routing_fee, max_total_cltv_expiry_delta, max_path_count, max_channel_saturation_power_of_half, } => { + let amount_msat = amount.map(|a| a.to_msat()); + let max_total_routing_fee_msat = max_total_routing_fee.map(|a| a.to_msat()); let route_parameters = RouteParametersConfig { max_total_routing_fee_msat, max_total_cltv_expiry_delta: max_total_cltv_expiry_delta @@ -527,7 +532,8 @@ async fn main() { .await, ); }, - Commands::Bolt12Receive { description, amount_msat, expiry_secs, quantity } => { + Commands::Bolt12Receive { description, amount, expiry_secs, quantity } => { + let amount_msat = amount.map(|a| a.to_msat()); handle_response_result::<_, Bolt12ReceiveResponse>( client .bolt12_receive(Bolt12ReceiveRequest { @@ -541,14 +547,16 @@ async fn main() { }, Commands::Bolt12Send { offer, - amount_msat, + amount, quantity, payer_note, - max_total_routing_fee_msat, + max_total_routing_fee, max_total_cltv_expiry_delta, max_path_count, max_channel_saturation_power_of_half, } => { + let amount_msat = amount.map(|a| a.to_msat()); + let max_total_routing_fee_msat = max_total_routing_fee.map(|a| a.to_msat()); let route_parameters = RouteParametersConfig { max_total_routing_fee_msat, max_total_cltv_expiry_delta: max_total_cltv_expiry_delta @@ -572,12 +580,14 @@ async fn main() { }, Commands::SpontaneousSend { node_id, - amount_msat, - max_total_routing_fee_msat, + amount, + max_total_routing_fee, max_total_cltv_expiry_delta, max_path_count, max_channel_saturation_power_of_half, } => { + let amount_msat = amount.to_msat(); + let max_total_routing_fee_msat = max_total_routing_fee.map(|a| a.to_msat()); let route_parameters = RouteParametersConfig { max_total_routing_fee_msat, max_total_cltv_expiry_delta: max_total_cltv_expiry_delta @@ -622,13 +632,16 @@ async fn main() { Commands::OpenChannel { node_pubkey, address, - channel_amount_sats, - push_to_counterparty_msat, + channel_amount, + push_to_counterparty, announce_channel, forwarding_fee_proportional_millionths, forwarding_fee_base_msat, cltv_expiry_delta, } => { + let channel_amount_sats = + channel_amount.to_sat().unwrap_or_else(|e| handle_error_msg(&e)); + let push_to_counterparty_msat = push_to_counterparty.map(|a| a.to_msat()); let channel_config = build_open_channel_config( forwarding_fee_proportional_millionths, forwarding_fee_base_msat, @@ -648,7 +661,9 @@ async fn main() { .await, ); }, - Commands::SpliceIn { user_channel_id, counterparty_node_id, splice_amount_sats } => { + Commands::SpliceIn { user_channel_id, counterparty_node_id, splice_amount } => { + let splice_amount_sats = + splice_amount.to_sat().unwrap_or_else(|e| handle_error_msg(&e)); handle_response_result::<_, SpliceInResponse>( client .splice_in(SpliceInRequest { @@ -659,12 +674,9 @@ async fn main() { .await, ); }, - Commands::SpliceOut { - user_channel_id, - counterparty_node_id, - address, - splice_amount_sats, - } => { + Commands::SpliceOut { user_channel_id, counterparty_node_id, address, splice_amount } => { + let splice_amount_sats = + splice_amount.to_sat().unwrap_or_else(|e| handle_error_msg(&e)); handle_response_result::<_, SpliceOutResponse>( client .splice_out(SpliceOutRequest { @@ -875,6 +887,11 @@ fn parse_page_token(token_str: &str) -> Result { Ok(PageToken { token: parts[0].to_string(), index }) } +fn handle_error_msg(msg: &str) -> ! { + eprintln!("Error: {msg}"); + std::process::exit(1); +} + fn handle_error(e: LdkServerError) -> ! { let error_type = match e.error_code { InvalidRequestError => "Invalid Request", diff --git a/ldk-server-cli/src/types.rs b/ldk-server-cli/src/types.rs index e3c708d5..92f778ad 100644 --- a/ldk-server-cli/src/types.rs +++ b/ldk-server-cli/src/types.rs @@ -13,6 +13,9 @@ //! of API responses for CLI output. These wrappers ensure that the CLI's output //! format matches what users expect and what the CLI can parse back as input. +use std::fmt; +use std::str::FromStr; + use ldk_server_client::ldk_server_protos::types::{ForwardedPayment, PageToken, Payment}; use serde::Serialize; @@ -39,3 +42,147 @@ pub type CliListForwardedPaymentsResponse = CliPaginatedResponse String { format!("{}:{}", token.token, token.index) } + +/// A denomination-aware amount that stores its value internally in millisatoshis. +/// +/// Accepts the following formats when parsed from a string: +/// - `sat` or `sats` — interpreted as satoshis +/// - `msat` or `msats` — interpreted as millisatoshis +/// +/// Bare numbers without a suffix are rejected. +#[derive(Debug, Clone, Copy, PartialOrd, Ord, PartialEq, Eq, Hash)] +pub struct Amount { + msats: u64, +} + +impl Amount { + /// Returns the value in millisatoshis. + pub fn to_msat(self) -> u64 { + self.msats + } + + /// Returns the value in satoshis. + /// + /// Returns an error string if the value is not evenly divisible by 1000. + pub fn to_sat(self) -> Result { + if self.msats % 1000 != 0 { + Err(format!( + "amount {}msats is not evenly divisible by 1000, cannot convert to whole satoshis", + self.msats + )) + } else { + Ok(self.msats / 1000) + } + } +} + +impl fmt::Display for Amount { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}msats", self.msats) + } +} + +impl FromStr for Amount { + type Err = String; + + fn from_str(s: &str) -> Result { + let s = s.trim(); + if let Some(num_str) = s.strip_suffix("msats") { + let val: u64 = num_str + .parse() + .map_err(|_| format!("invalid amount: '{s}' — expected a number before 'msats'"))?; + Ok(Amount { msats: val }) + } else if let Some(num_str) = s.strip_suffix("msat") { + let val: u64 = num_str + .parse() + .map_err(|_| format!("invalid amount: '{s}' — expected a number before 'msat'"))?; + Ok(Amount { msats: val }) + } else if let Some(num_str) = s.strip_suffix("sats") { + let val: u64 = num_str + .parse() + .map_err(|_| format!("invalid amount: '{s}' — expected a number before 'sats'"))?; + Ok(Amount { + msats: val.checked_mul(1000).ok_or_else(|| "amount overflow".to_string())?, + }) + } else if let Some(num_str) = s.strip_suffix("sat") { + let val: u64 = num_str + .parse() + .map_err(|_| format!("invalid amount: '{s}' — expected a number before 'sat'"))?; + Ok(Amount { + msats: val.checked_mul(1000).ok_or_else(|| "amount overflow".to_string())?, + }) + } else { + Err(format!( + "invalid amount: '{s}' — must include a denomination suffix (e.g. 1000sat, 5000msat)" + )) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn amount_parsing_and_conversion() { + // sat suffix + let amount = Amount::from_str("1000sat").unwrap(); + assert_eq!(amount.to_msat(), 1_000_000); + assert_eq!(amount.to_sat().unwrap(), 1000); + + // sats suffix + let amount = Amount::from_str("50sats").unwrap(); + assert_eq!(amount.to_msat(), 50_000); + assert_eq!(amount.to_sat().unwrap(), 50); + + // msat suffix + let amount = Amount::from_str("5000msat").unwrap(); + assert_eq!(amount.to_msat(), 5000); + assert_eq!(amount.to_sat().unwrap(), 5); + + // msats suffix + let amount = Amount::from_str("3000msats").unwrap(); + assert_eq!(amount.to_msat(), 3000); + assert_eq!(amount.to_sat().unwrap(), 3); + + // zero + let amount = Amount::from_str("0sat").unwrap(); + assert_eq!(amount.to_msat(), 0); + assert_eq!(amount.to_sat().unwrap(), 0); + let amount = Amount::from_str("0msat").unwrap(); + assert_eq!(amount.to_msat(), 0); + assert_eq!(amount.to_sat().unwrap(), 0); + + // sat/msat equivalence + let from_sat = Amount::from_str("5sat").unwrap(); + let from_msat = Amount::from_str("5000msat").unwrap(); + assert_eq!(from_sat.to_msat(), from_msat.to_msat()); + + // to_sat rejects non-divisible msat values + let amount = Amount::from_str("1500msat").unwrap(); + assert_eq!(amount.to_msat(), 1500); + assert!(amount.to_sat().is_err()); + + // rejects bare number + assert!(Amount::from_str("1000").is_err()); + + // rejects empty string + assert!(Amount::from_str("").is_err()); + + // rejects suffix with no number + assert!(Amount::from_str("sat").is_err()); + assert!(Amount::from_str("msat").is_err()); + + // rejects negative + assert!(Amount::from_str("-100sat").is_err()); + assert!(Amount::from_str("-100msat").is_err()); + + // rejects decimal + assert!(Amount::from_str("1.5sat").is_err()); + assert!(Amount::from_str("1.5msat").is_err()); + + // rejects overflow (u64::MAX sats would overflow when multiplied by 1000) + let big = format!("{}sat", u64::MAX); + assert!(Amount::from_str(&big).is_err()); + } +}