From eabfa6b61ef49682e0b0cfbe881876db0e5fa2a6 Mon Sep 17 00:00:00 2001 From: benthecarman Date: Mon, 27 Feb 2023 02:29:45 -0600 Subject: [PATCH] Initial commit Co-authored-by: Paul Miller --- .gitignore | 2 + Cargo.toml | 23 +++++ src/bip21.rs | 95 ++++++++++++++++++ src/lib.rs | 267 +++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 387 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 src/bip21.rs create mode 100644 src/lib.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4fffb2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +/Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..57bd288 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "bitcoin-waila" +version = "0.1.0" +edition = "2021" +authors = ["Ben Carman ", "Paul Miller "] +license = "MIT" +homepage = "https://github.com/MutinyWallet/bitcoin-waila/" +repository = "https://github.com/MutinyWallet/bitcoin-waila/" +readme = "README.md" +documentation = "https://docs.rs/bitcoin-waila/" +description = "\"What am I looking at?\" A tool for decoding bitcoin-related strings." +keywords = [ "lightning", "bitcoin" ] + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +bech32 = "0.9.1" +bitcoin = "0.29.2" +bip21 = "0.2.0" +lnurl-rs = { version = "0.1.3", no-default-features = true } +lightning-invoice = "0.21.0" +# todo update when 0.0.114 is released +lightning = { git = "https://github.com/lightningdevkit/rust-lightning", rev = "f71daed02d159e051e065802155d3ad77edbc124"} diff --git a/src/bip21.rs b/src/bip21.rs new file mode 100644 index 0000000..e0996fc --- /dev/null +++ b/src/bip21.rs @@ -0,0 +1,95 @@ +use core::str::FromStr; +use std::borrow::Cow; + +use ::bip21::de::*; +use ::bip21::*; +use lightning_invoice::{Invoice, ParseOrSemanticError}; + +/// This lets us parse a `lightning` parameter from a BIP21 URI. +pub type UnifiedUri<'a> = Uri<'a, LightningExtras>; + +#[derive(Debug, Default, Eq, PartialEq, Clone, Hash)] +pub struct LightningExtras { + pub lightning: Option, +} + +#[derive(Debug, Eq, PartialEq, Clone, Hash)] +pub enum LightningParseError { + MultipleParams, + InvoiceParsingError, +} + +impl From for LightningParseError { + fn from(_e: ParseOrSemanticError) -> Self { + LightningParseError::InvoiceParsingError + } +} + +impl DeserializationError for LightningExtras { + type Error = LightningParseError; +} + +impl<'a> DeserializeParams<'a> for LightningExtras { + type DeserializationState = LightningExtras; +} + +impl<'a> DeserializationState<'a> for LightningExtras { + type Value = LightningExtras; + + fn is_param_known(&self, param: &str) -> bool { + matches!(param, "lightning") + } + + fn deserialize_temp( + &mut self, + key: &str, + value: Param<'_>, + ) -> Result::Error> { + match key { + "lightning" if self.lightning.is_none() => { + let str = + Cow::try_from(value).map_err(|_| LightningParseError::InvoiceParsingError)?; + let invoice = Invoice::from_str(&str)?; + self.lightning = Some(invoice); + + Ok(ParamKind::Known) + } + "lightning" => Err(LightningParseError::MultipleParams), + _ => Ok(ParamKind::Unknown), + } + } + + fn finalize(self) -> Result::Error> { + Ok(self) + } +} + +#[cfg(test)] +mod test { + use core::str::FromStr; + use std::convert::TryFrom; + + use crate::bip21::LightningExtras; + use lightning_invoice::Invoice; + + type UnifiedUri<'a> = bip21::Uri<'a, LightningExtras>; + + #[test] + fn test_ln_uri() { + let input = "bitcoin:BC1QYLH3U67J673H6Y6ALV70M0PL2YZ53TZHVXGG7U?amount=0.00001&label=sbddesign%3A%20For%20lunch%20Tuesday&message=For%20lunch%20Tuesday&lightning=LNBC10U1P3PJ257PP5YZTKWJCZ5FTL5LAXKAV23ZMZEKAW37ZK6KMV80PK4XAEV5QHTZ7QDPDWD3XGER9WD5KWM36YPRX7U3QD36KUCMGYP282ETNV3SHJCQZPGXQYZ5VQSP5USYC4LK9CHSFP53KVCNVQ456GANH60D89REYKDNGSMTJ6YW3NHVQ9QYYSSQJCEWM5CJWZ4A6RFJX77C490YCED6PEMK0UPKXHY89CMM7SCT66K8GNEANWYKZGDRWRFJE69H9U5U0W57RRCSYSAS7GADWMZXC8C6T0SPJAZUP6"; + let expected_invoice = Invoice::from_str("LNBC10U1P3PJ257PP5YZTKWJCZ5FTL5LAXKAV23ZMZEKAW37ZK6KMV80PK4XAEV5QHTZ7QDPDWD3XGER9WD5KWM36YPRX7U3QD36KUCMGYP282ETNV3SHJCQZPGXQYZ5VQSP5USYC4LK9CHSFP53KVCNVQ456GANH60D89REYKDNGSMTJ6YW3NHVQ9QYYSSQJCEWM5CJWZ4A6RFJX77C490YCED6PEMK0UPKXHY89CMM7SCT66K8GNEANWYKZGDRWRFJE69H9U5U0W57RRCSYSAS7GADWMZXC8C6T0SPJAZUP6").unwrap(); + + assert!(UnifiedUri::try_from(input).is_ok()); + let uri = UnifiedUri::from_str(input).unwrap(); + assert_eq!(uri.extras.lightning, Some(expected_invoice)); + } + + #[test] + fn test_no_ln_uri() { + let input = "bitcoin:1andreas3batLhQa2FawWjeyjCqyBzypd"; + + assert!(UnifiedUri::try_from(input).is_ok()); + let uri = UnifiedUri::from_str(input).unwrap(); + assert_eq!(uri.extras.lightning, None); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..55ea316 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,267 @@ +mod bip21; + +use std::str::FromStr; + +use crate::bip21::UnifiedUri; +use bitcoin::{Address, Amount, Network, PublicKey}; +use lightning::offers::offer; +use lightning::offers::offer::Offer; +use lightning_invoice::{Currency, Invoice, InvoiceDescription}; +use lnurl::lightning_address::LightningAddress; +use lnurl::lnurl::LnUrl; + +#[derive(Debug)] +pub enum PaymentParams<'a> { + OnChain(Address), + Bip21(UnifiedUri<'a>), + Bolt11(Invoice), + Bolt12(Offer), + NodePubkey(PublicKey), + LnUrl(LnUrl), + LightningAddress(LightningAddress), +} + +impl PaymentParams<'_> { + pub fn memo(&self) -> Option { + match self { + PaymentParams::OnChain(_) => None, + PaymentParams::Bip21(uri) => uri.message.clone().and_then(|m| m.try_into().ok()), + PaymentParams::Bolt11(invoice) => match invoice.description() { + InvoiceDescription::Direct(desc) => Some(desc.to_string()), + InvoiceDescription::Hash(_) => None, + }, + PaymentParams::Bolt12(offer) => Some(offer.description().to_string()), + PaymentParams::NodePubkey(_) => None, + PaymentParams::LnUrl(_) => None, + PaymentParams::LightningAddress(_) => None, + } + } + + pub fn network(&self) -> Option { + match self { + PaymentParams::OnChain(address) => Some(address.network), + PaymentParams::Bip21(uri) => Some(uri.address.network), + PaymentParams::Bolt11(invoice) => match invoice.currency() { + Currency::Bitcoin => Some(Network::Bitcoin), + Currency::BitcoinTestnet => Some(Network::Testnet), + Currency::Regtest => Some(Network::Regtest), + Currency::Simnet => Some(Network::Regtest), + Currency::Signet => Some(Network::Signet), + }, + PaymentParams::Bolt12(_) => None, // todo fix after https://github.com/rust-bitcoin/rust-bitcoin/pull/1675 + PaymentParams::NodePubkey(_) => None, + PaymentParams::LnUrl(_) => None, + PaymentParams::LightningAddress(_) => None, + } + } + + pub fn amount(&self) -> Option { + self.amount_msats() + .map(|msats| Amount::from_sat(msats / 1_000)) + } + + pub fn amount_msats(&self) -> Option { + match self { + PaymentParams::OnChain(_) => None, + PaymentParams::Bip21(uri) => uri.amount.map(|amount| amount.to_sat() * 1_000), + PaymentParams::Bolt11(invoice) => invoice.amount_milli_satoshis(), + PaymentParams::Bolt12(offer) => offer.amount().and_then(|amt| match amt { + offer::Amount::Bitcoin { amount_msats } => Some(*amount_msats), + offer::Amount::Currency { .. } => None, + }), + PaymentParams::NodePubkey(_) => None, + PaymentParams::LnUrl(_) => None, + PaymentParams::LightningAddress(_) => None, + } + } + + pub fn address(&self) -> Option
{ + match self { + PaymentParams::OnChain(address) => Some(address.clone()), + PaymentParams::Bip21(uri) => Some(uri.address.clone()), + PaymentParams::Bolt11(_) => None, // todo update after https://github.com/lightningdevkit/rust-lightning/pull/2023 + PaymentParams::Bolt12(_) => None, + PaymentParams::NodePubkey(_) => None, + PaymentParams::LnUrl(_) => None, + PaymentParams::LightningAddress(_) => None, + } + } + + pub fn invoice(&self) -> Option { + match self { + PaymentParams::OnChain(_) => None, + PaymentParams::Bip21(uri) => uri.extras.clone().lightning, + PaymentParams::Bolt11(invoice) => Some(invoice.clone()), + PaymentParams::Bolt12(_) => None, + PaymentParams::NodePubkey(_) => None, + PaymentParams::LnUrl(_) => None, + PaymentParams::LightningAddress(_) => None, + } + } + + pub fn node_pubkey(&self) -> Option { + match self { + PaymentParams::OnChain(_) => None, + PaymentParams::Bip21(uri) => uri.extras.clone().lightning.map(|invoice| { + let secp = invoice.recover_payee_pub_key(); + PublicKey::new(secp) + }), + PaymentParams::Bolt11(invoice) => { + let secp = invoice.recover_payee_pub_key(); + Some(PublicKey::new(secp)) + } + PaymentParams::Bolt12(_) => None, + PaymentParams::NodePubkey(pubkey) => Some(*pubkey), + PaymentParams::LnUrl(_) => None, + PaymentParams::LightningAddress(_) => None, + } + } + + pub fn lnurl(&self) -> Option { + match self { + PaymentParams::OnChain(_) => None, + PaymentParams::Bip21(_) => None, + PaymentParams::Bolt11(_) => None, + PaymentParams::Bolt12(_) => None, + PaymentParams::NodePubkey(_) => None, + PaymentParams::LnUrl(lnurl) => Some(lnurl.clone()), + PaymentParams::LightningAddress(ln_addr) => LnUrl::from_url(ln_addr.lnurlp_url()).ok(), + } + } +} + +impl FromStr for PaymentParams<'_> { + type Err = (); + + fn from_str(str: &str) -> Result { + Address::from_str(str) + .map(PaymentParams::OnChain) + .or_else(|_| Invoice::from_str(str).map(PaymentParams::Bolt11)) + .or_else(|_| PublicKey::from_str(str).map(PaymentParams::NodePubkey)) + .or_else(|_| LnUrl::from_str(str).map(PaymentParams::LnUrl)) + .or_else(|_| LightningAddress::from_str(str).map(PaymentParams::LightningAddress)) + .or_else(|_| LightningAddress::from_str(str).map(PaymentParams::LightningAddress)) + .or_else(|_| UnifiedUri::from_str(str).map(PaymentParams::Bip21)) + .or_else(|_| Offer::from_str(str).map(PaymentParams::Bolt12)) + .map_err(|_| ()) + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use super::*; + + const SAMPLE_INVOICE: &str = "lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqsfpp3qjmp7lwpagxun9pygexvgpjdc4jdj85fr9yq20q82gphp2nflc7jtzrcazrra7wwgzxqc8u7754cdlpfrmccae92qgzqvzq2ps8pqqqqqqpqqqqq9qqqvpeuqafqxu92d8lr6fvg0r5gv0heeeqgcrqlnm6jhphu9y00rrhy4grqszsvpcgpy9qqqqqqgqqqqq7qqzq9qrsgqdfjcdk6w3ak5pca9hwfwfh63zrrz06wwfya0ydlzpgzxkn5xagsqz7x9j4jwe7yj7vaf2k9lqsdk45kts2fd0fkr28am0u4w95tt2nsq76cqw0"; + const SAMPLE_BIP21: &str = "bitcoin:1andreas3batLhQa2FawWjeyjCqyBzypd?amount=50&label=Luke-Jr&message=Donation%20for%20project%20xyz"; + const SAMPLE_BIP21_WITH_INVOICE: &str = "bitcoin:BC1QYLH3U67J673H6Y6ALV70M0PL2YZ53TZHVXGG7U?amount=0.00001&label=sbddesign%3A%20For%20lunch%20Tuesday&message=For%20lunch%20Tuesday&lightning=LNBC10U1P3PJ257PP5YZTKWJCZ5FTL5LAXKAV23ZMZEKAW37ZK6KMV80PK4XAEV5QHTZ7QDPDWD3XGER9WD5KWM36YPRX7U3QD36KUCMGYP282ETNV3SHJCQZPGXQYZ5VQSP5USYC4LK9CHSFP53KVCNVQ456GANH60D89REYKDNGSMTJ6YW3NHVQ9QYYSSQJCEWM5CJWZ4A6RFJX77C490YCED6PEMK0UPKXHY89CMM7SCT66K8GNEANWYKZGDRWRFJE69H9U5U0W57RRCSYSAS7GADWMZXC8C6T0SPJAZUP6"; + const SAMPLE_LNURL: &str = "LNURL1DP68GURN8GHJ7UM9WFMXJCM99E3K7MF0V9CXJ0M385EKVCENXC6R2C35XVUKXEFCV5MKVV34X5EKZD3EV56NYD3HXQURZEPEXEJXXEPNXSCRVWFNV9NXZCN9XQ6XYEFHVGCXXCMYXYMNSERXFQ5FNS"; + + #[test] + fn parse_node_pubkey() { + let pubkey = PublicKey::from_str( + "03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad", + ) + .unwrap(); + let parsed = PaymentParams::from_str(&pubkey.to_string()).unwrap(); + + assert_eq!(parsed.amount(), None); + assert_eq!(parsed.address(), None); + assert_eq!(parsed.memo(), None); + assert_eq!(parsed.network(), None); + assert_eq!(parsed.invoice(), None); + assert_eq!(parsed.node_pubkey(), Some(pubkey)); + assert_eq!(parsed.lnurl(), None); + } + + #[test] + fn parse_address() { + let address = Address::from_str("1andreas3batLhQa2FawWjeyjCqyBzypd").unwrap(); + let parsed = PaymentParams::from_str(&address.to_string()).unwrap(); + + assert_eq!(parsed.address(), Some(address)); + assert_eq!(parsed.amount(), None); + assert_eq!(parsed.memo(), None); + assert_eq!(parsed.network(), Some(Network::Bitcoin)); + assert_eq!(parsed.invoice(), None); + assert_eq!(parsed.node_pubkey(), None); + assert_eq!(parsed.lnurl(), None); + } + + #[test] + fn parse_invoice() { + let parsed = PaymentParams::from_str(SAMPLE_INVOICE).unwrap(); + + let expected_pubkey = PublicKey::from_str( + "03e7156ae33b0a208d0744199163177e909e80176e55d97a2f221ede0f934dd9ad", + ) + .unwrap(); + + assert_eq!(parsed.amount(), Some(Amount::from_sat(2_000_000))); + assert_eq!(parsed.amount_msats(), Some(2_000_000_000)); + assert_eq!(parsed.node_pubkey(), Some(expected_pubkey)); + assert_eq!(parsed.network(), Some(Network::Bitcoin)); + assert_eq!(parsed.address(), None); // todo: add fallback address + assert_eq!(parsed.memo(), None); + assert_eq!(parsed.lnurl(), None); + } + + #[test] + fn parse_bip_21() { + let parsed = PaymentParams::from_str(SAMPLE_BIP21).unwrap(); + + assert_eq!(parsed.amount(), Some(Amount::from_btc(50 as f64).unwrap())); + assert_eq!( + parsed.address(), + Some(Address::from_str("1andreas3batLhQa2FawWjeyjCqyBzypd").unwrap()) + ); + assert_eq!(parsed.memo(), Some("Donation for project xyz".to_string())); + assert_eq!(parsed.network(), Some(Network::Bitcoin)); + assert_eq!(parsed.invoice(), None); + assert_eq!(parsed.node_pubkey(), None); + assert_eq!(parsed.lnurl(), None); + } + + #[test] + fn parse_bip_21_with_invoice() { + let parsed = PaymentParams::from_str(SAMPLE_BIP21_WITH_INVOICE).unwrap(); + + assert_eq!(parsed.amount(), Some(Amount::from_btc(0.00001).unwrap())); + assert_eq!( + parsed.address(), + Some(Address::from_str("BC1QYLH3U67J673H6Y6ALV70M0PL2YZ53TZHVXGG7U").unwrap()) + ); + assert_eq!(parsed.memo(), Some("For lunch Tuesday".to_string())); + assert_eq!(parsed.network(), Some(Network::Bitcoin)); + assert_eq!(parsed.invoice(), Some(Invoice::from_str("LNBC10U1P3PJ257PP5YZTKWJCZ5FTL5LAXKAV23ZMZEKAW37ZK6KMV80PK4XAEV5QHTZ7QDPDWD3XGER9WD5KWM36YPRX7U3QD36KUCMGYP282ETNV3SHJCQZPGXQYZ5VQSP5USYC4LK9CHSFP53KVCNVQ456GANH60D89REYKDNGSMTJ6YW3NHVQ9QYYSSQJCEWM5CJWZ4A6RFJX77C490YCED6PEMK0UPKXHY89CMM7SCT66K8GNEANWYKZGDRWRFJE69H9U5U0W57RRCSYSAS7GADWMZXC8C6T0SPJAZUP6").unwrap())); + assert_eq!(parsed.node_pubkey(), Some(PublicKey::from_str("037cc5f9f1da20ac0d60e83989729a204a33cc2d8e80438969fadf35c1c5f1233b").unwrap())); + assert_eq!(parsed.lnurl(), None); + } + + #[test] + fn parse_lnurl() { + let parsed = PaymentParams::from_str(SAMPLE_LNURL).unwrap(); + + assert_eq!(parsed.amount(), None); + assert_eq!(parsed.address(), None); + assert_eq!(parsed.memo(), None); + assert_eq!(parsed.network(), None); + assert_eq!(parsed.invoice(), None); + assert_eq!(parsed.node_pubkey(), None); + assert_eq!(parsed.lnurl(), Some(LnUrl::from_str(SAMPLE_LNURL).unwrap())); + } + + #[test] + fn parse_lightning_address() { + let parsed = PaymentParams::from_str("ben@opreturnbot.com").unwrap(); + + assert_eq!(parsed.amount(), None); + assert_eq!(parsed.address(), None); + assert_eq!(parsed.memo(), None); + assert_eq!(parsed.network(), None); + assert_eq!(parsed.invoice(), None); + assert_eq!(parsed.node_pubkey(), None); + assert_eq!(parsed.lnurl(), Some(LnUrl::from_str("lnurl1dp68gurn8ghj7mmswfjhgatjde3x7apwvdhk6tewwajkcmpdddhx7amw9akxuatjd3cz7cn9dc94s6d4").unwrap())); + } +}