diff --git a/bitcoin/src/bip21.rs b/bitcoin/src/bip21.rs new file mode 100644 index 0000000..8336b6f --- /dev/null +++ b/bitcoin/src/bip21.rs @@ -0,0 +1,125 @@ +use bdk::bitcoin::{Address, Amount, Denomination}; +use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS}; +use std::collections::BTreeMap; +use std::str::FromStr; +use url::{ParseError, Url}; + +pub struct Bip21 { + pub scheme: String, + pub address: Address, + pub amount: Option, + pub label: Option, + pub message: Option, +} + +impl Bip21 { + pub fn as_str(&self) -> Result { + const FRAGMENT: &AsciiSet = &CONTROLS.add(b' '); + let mut query = BTreeMap::new(); + if let Some(a) = &self.amount { + query.insert("amount", a.as_btc().to_string()); + } + if let Some(l) = &self.label { + let encoded = utf8_percent_encode(l.as_str(), FRAGMENT).to_string(); + query.insert("label", encoded); + } + if let Some(m) = &self.message { + let encoded = utf8_percent_encode(m.as_str(), FRAGMENT).to_string(); + query.insert("message", encoded); + } + let params = query + .into_iter() + .map(|(k, v)| format!("{}={}", k, v)) + .collect::>(); + let url = format!( + "{}:{}?{}", + self.scheme, + self.address.to_string(), + params.join("&") + ); + Ok(url.trim_end_matches("?").to_string()) + } + + pub fn parse(string: &str) -> Result { + let url = Url::parse(string)?; + let mut params: BTreeMap = BTreeMap::new(); + for (k, v) in url.query_pairs().into_owned() { + params.insert(k, v); + } + let scheme = url.scheme().to_string(); + let address = Address::from_str(url.path()).map_err(|_| ParseError::IdnaError)?; + let amount = match params.get("amount") { + None => None, + Some(amount) => Some( + Amount::from_str_in(amount.as_str(), Denomination::Bitcoin) + .map_err(|_| ParseError::IdnaError)?, + ), + }; + let label = params.get("label").cloned(); + let message = params.get("message").cloned(); + Ok(Bip21 { + scheme, + address, + amount, + label, + message, + }) + } +} + +#[cfg(test)] +mod test { + use crate::bip21::Bip21; + use bdk::bitcoin::{Address, Amount, Denomination}; + use std::str::FromStr; + + #[test] + fn serialize() { + let mut bip21 = Bip21 { + scheme: "bitcoin".to_string(), + address: Address::from_str("2NDxuABdg2uqk9MouV6f53acAwaY2GwKVHK").unwrap(), + amount: None, + label: None, + message: None, + }; + assert_eq!( + "bitcoin:2NDxuABdg2uqk9MouV6f53acAwaY2GwKVHK", + bip21.as_str().unwrap() + ); + bip21.label = Some("Luke-Jr".to_string()); + assert_eq!( + "bitcoin:2NDxuABdg2uqk9MouV6f53acAwaY2GwKVHK?label=Luke-Jr", + bip21.as_str().unwrap() + ); + bip21.amount = Some(Amount::from_str_in("20.3", Denomination::Bitcoin).unwrap()); + assert_eq!( + "bitcoin:2NDxuABdg2uqk9MouV6f53acAwaY2GwKVHK?amount=20.3&label=Luke-Jr", + bip21.as_str().unwrap() + ); + bip21.amount = Some(Amount::from_str_in("50", Denomination::Bitcoin).unwrap()); + bip21.message = Some("Donation for project xyz".to_string()); + assert_eq!( + "bitcoin:2NDxuABdg2uqk9MouV6f53acAwaY2GwKVHK?amount=50&label=Luke-Jr&message=Donation%20for%20project%20xyz", + bip21.as_str().unwrap() + ); + } + + #[test] + fn deserialize() { + let url1 = Bip21::parse("bitcoin:2NDxuABdg2uqk9MouV6f53acAwaY2GwKVHK").unwrap(); + assert_eq!( + "bitcoin:2NDxuABdg2uqk9MouV6f53acAwaY2GwKVHK", + url1.as_str().unwrap() + ); + assert_eq!("bitcoin", url1.scheme); + assert_eq!( + "2NDxuABdg2uqk9MouV6f53acAwaY2GwKVHK", + url1.address.to_string() + ); + let url2 = Bip21::parse("bitcoin:2NDxuABdg2uqk9MouV6f53acAwaY2GwKVHK?amount=50&label=Luke-Jr&message=Donation%20for%20project%20xyz").unwrap(); + assert_eq!("bitcoin:2NDxuABdg2uqk9MouV6f53acAwaY2GwKVHK?amount=50&label=Luke-Jr&message=Donation%20for%20project%20xyz", url2.as_str().unwrap()); + assert_eq!(50 as f64, url2.amount.unwrap().as_btc()); + assert_eq!("Luke-Jr", url2.label.unwrap().as_str()); + assert_eq!("Donation for project xyz", url2.message.unwrap().as_str()); + } +} \ No newline at end of file diff --git a/bitcoin/src/lib.rs b/bitcoin/src/lib.rs index 795516b..74cdf87 100644 --- a/bitcoin/src/lib.rs +++ b/bitcoin/src/lib.rs @@ -1,8 +1,10 @@ pub mod config; +pub mod bip21; pub extern crate bdk; -extern crate ini; extern crate structopt; +extern crate percent_encoding; +extern crate url; use bdk::bitcoin::Address; use bdk::blockchain::{ diff --git a/server/src/html.rs b/server/src/html.rs index 860ec49..775cd87 100644 --- a/server/src/html.rs +++ b/server/src/html.rs @@ -8,6 +8,17 @@ use std::str::FromStr; use crate::wallet; use wallet::{Error, gen_err}; +#[derive(Default)] +pub struct Page { + pub url: String, + pub network: String, + pub address: String, + pub amount: Option, + pub label: Option, + pub message: Option, + pub status: Option, +} + const CSS2: &str = include_str!("../../assets/css/style.css"); const CSS1: &str = include_str!("../../assets/css/styles.css"); @@ -29,22 +40,11 @@ fn inner_header(title: &str) -> Markup { return header } -fn inner_address(address: &str) -> Markup { - let partial = html! { - div class="media text-muted pt-3" { - p class="media-body pb-3 mb-0 small lh-125 border-bottom border-gray" { - span { (address) } - } - } - }; - partial -} - -fn inner_status(status: &str) -> Markup { +fn inner_section(text: &str) -> Markup { let partial = html! { div class="media text-muted pt-3" { p class="media-body pb-3 mb-0 small lh-125 border-bottom border-gray" { - span { (status) } + span { (text) } } } }; @@ -107,12 +107,11 @@ fn address_qr(network: &str, address: &str) -> Result { } } -pub fn page(network: &str, address: &str, status: &str) -> Result { - let meta_http_content = format!("{}; URL=/?{}", 10, address); - let address_link = address_link(network, address)?; - let address_qr = address_qr(network, address)?; +pub fn render(page: Page) -> Result { + let meta_http_content = format!("{}; URL=/?{}", 10, page.url); + let address_link = address_link(page.network.as_str(), page.address.as_str())?; + let address_qr = address_qr(page.network.as_str(), page.address.as_str())?; let qr = create_bmp_base64_qr(&address_qr).map_err(|_| gen_err())?; - println!("{}",network); let html = html! { (DOCTYPE) @@ -121,13 +120,13 @@ pub fn page(network: &str, address: &str, status: &str) -> Result meta charset="UTF-8"; meta name="robots" content="noindex"; meta http-equiv="Refresh" content=(meta_http_content); - title { (address) } + title { (page.address) } style { (CSS1) } style { (CSS2) } } body { div.container.center.headings--one-size { - (inner_header(network)) + (inner_header(page.network.as_str())) div.content { div.index-content { @@ -135,11 +134,21 @@ pub fn page(network: &str, address: &str, status: &str) -> Result div class="center" { img class="qr" src=(qr) { } br { } - (inner_address(address)) + (inner_section(page.address.as_str())) } } - - (inner_status(status)) + @if let Some(amount) = &page.amount { + (inner_section(format!("Amount {} sats", amount.to_string().as_str()).as_str())) + } + @if let Some(label) = &page.label { + (inner_section(format!("Label {}", label.to_string().as_str()).as_str())) + } + @if let Some(message) = &page.message { + (inner_section(format!("Message {}", message.to_string().as_str()).as_str())) + } + @if let Some(status) = &page.status { + (inner_section(format!("{}", status.to_string().as_str()).as_str())) + } a href=(address_link) { "Open in wallet app" } } } diff --git a/server/src/server.rs b/server/src/server.rs index 4abc23f..4e66d33 100644 --- a/server/src/server.rs +++ b/server/src/server.rs @@ -4,11 +4,9 @@ use uriparse; use std::convert::TryFrom; use crate::{html, wallet}; -use crate::html::not_found; +use crate::html::{not_found, Page}; use wallet::{Wallet, Error, gen_err}; -use btctipserver_bitcoin::BTCWallet; - pub fn run_server(url: &str, wallet: Wallet) { let wallet_mutex = Arc::new(Mutex::new(wallet)); let server = Server::http(url).unwrap(); @@ -63,30 +61,30 @@ pub fn page( wallet: &mut Wallet, uri: &str, ) -> Result { - let network = wallet.network()?; - let mut address = uri; - - BTCWallet::Bip21::parse(uri).unwrap(); - - - Bip21::parse("bitcoin:2NDxuABdg2uqk9MouV6f53acAwaY2GwKVHK") - let address = match parsed.query().unwrap() { - + let mut page = Page { + network: wallet.network()?, + url: format!("{}", uri), + address: format!("{}", uri), + ..Default::default() }; - - - if parsed.query().unwrap().starts_with(wallet.schema()) { - address = Bip21::parse("bitcoin:2NDxuABdg2uqk9MouV6f53acAwaY2GwKVHK").unwrap(); - } - - let address = uri; - let mine = wallet.is_my_address(address)?; + if uri.starts_with(wallet.schema()) { + println!("{}",wallet.schema()); + if let Ok(bip21) = btctipserver_bitcoin::bip21::Bip21::parse(uri) { + page.address = bip21.address.to_string(); + if let Some(amount) = bip21.amount { + page.amount = Some(amount.as_sat().to_string()); + } + page.label = bip21.label; + page.label = bip21.message; + } + } + let mine = wallet.is_my_address(page.address.as_str())?; if !mine { - return Ok(format!("Address {} is not mine", address)); + return Ok(format!("Address {} is not mine", page.address)); } let results = wallet - .balance_address(&address, Option::from(0)) + .balance_address(&page.address, Option::from(0)) .map_err(|_| gen_err())? .into_iter() .filter(|(_, v)| *v > 0) @@ -95,9 +93,9 @@ pub fn page( .collect::>() .join(", "); - let txt = match results.is_empty() { - true => "No tx found yet".to_string(), - _ => results, + page.status = match results.is_empty() { + true => Some("No tx found yet".to_string()), + _ => Some(results), }; - html::page(network.as_str(), address, txt.as_str()) + html::render(page) }