diff --git a/CHANGELOG.md b/CHANGELOG.md index e829c81..1ce74f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ - Electrum plugin: Compatibility with Electrum v4 — *except for lightning* which is [tricky with personal servers](https://github.com/chris-belcher/electrum-personal-server/issues/174#issuecomment-577619460) (#53) +- Electrum: New welcome banner (#44) + - Scriptable transaction broadcast command via `--tx-broadcast-cmd ` (#7) The command will be used in place of broadcasting transactions using the full node, diff --git a/scripts/setup-env.sh b/scripts/setup-env.sh index 5d000fb..9a56f10 100755 --- a/scripts/setup-env.sh +++ b/scripts/setup-env.sh @@ -114,8 +114,8 @@ if [ -z "$NO_FUNDING" ]; then fi echo Setting up bwt -runbwt --network regtest --bitcoind-dir $BTC_DIR --bitcoind-url http://localhost:$BTC_RPC_PORT/ \ - --bitcoind-wallet bwt \ +runbwt --no-startup-banner --network regtest \ + --bitcoind-dir $BTC_DIR --bitcoind-url http://localhost:$BTC_RPC_PORT/ --bitcoind-wallet bwt \ --electrum-rpc-addr $BWT_ELECTRUM_ADDR \ --unix-listener-path $BWT_SOCKET --poll-interval ${INTERVAL:=120} \ --initial-import-size 30 \ diff --git a/src/app.rs b/src/app.rs index da58624..a4ad1b6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,7 +5,7 @@ use bitcoincore_rpc::{self as rpc, Client as RpcClient, RpcApi}; use crate::bitcoincore_ext::RpcApiExt; use crate::util::debounce_sender; -use crate::{Config, HDWallet, HDWatcher, Indexer, Query, Result}; +use crate::{banner, Config, HDWallet, HDWatcher, Indexer, Query, Result}; #[cfg(feature = "electrum")] use crate::electrum::ElectrumServer; @@ -58,6 +58,10 @@ impl App { wait_bitcoind(&rpc)?; + if config.startup_banner { + println!("{}", banner::get_welcome_banner(&query)?); + } + // do an initial sync without keeping track of updates indexer.write().unwrap().initial_sync()?; @@ -153,12 +157,11 @@ fn wait_bitcoind(rpc: &RpcClient) -> Result<()> { let netinfo = rpc.get_network_info_()?; let mut bcinfo = rpc.get_blockchain_info()?; info!( - "bwt v{} connected to {} on {}, protocolversion={}, pruned={}, bestblock={}", + "bwt v{} connected to {} on {}, protocolversion={}, bestblock={}", crate::BWT_VERSION, netinfo.subversion, bcinfo.chain, netinfo.protocol_version, - bcinfo.pruned, bcinfo.best_block_hash ); diff --git a/src/banner.rs b/src/banner.rs new file mode 100644 index 0000000..1674fe6 --- /dev/null +++ b/src/banner.rs @@ -0,0 +1,287 @@ +use std::time::{Duration as StdDuration, UNIX_EPOCH}; + +use chrono::Duration; + +use bitcoin::{blockdata::constants, Amount}; +use bitcoincore_rpc::RpcApi; + +use crate::bitcoincore_ext::RpcApiExt; +use crate::{Query, Result}; + +const DIFFCHANGE_INTERVAL: u64 = constants::DIFFCHANGE_INTERVAL as u64; +const TARGET_BLOCK_SPACING: u64 = constants::TARGET_BLOCK_SPACING as u64; +const INITIAL_REWARD: u64 = 50 * constants::COIN_VALUE; +const HALVING_INTERVAL: u64 = 210_000; + +pub fn get_welcome_banner(query: &Query) -> Result { + let rpc = query.rpc(); + + let net_info = rpc.get_network_info_()?; + let chain_info = rpc.get_blockchain_info()?; + let mempool_info = rpc.get_mempool_info()?; + let net_totals = rpc.get_net_totals()?; + let peers = rpc.get_peer_info()?; + let hash_rate_7d = rpc.get_network_hash_ps(1008)?; + let uptime = dur_from_secs(rpc.uptime()?); + let tip = rpc.get_block_stats(&rpc.get_best_block_hash()?)?; + + let est_fee = |target| { + query + .estimate_fee(target) + .ok() + .flatten() + .map_or("ₙ.ₐ.".into(), |rate| format!("{:.1}", rate)) + }; + let est_20m = est_fee(2u16); + let est_3h = est_fee(18u16); + let est_1d = est_fee(144u16); + + let mut chain_name = chain_info.chain; + if chain_name == "main" || chain_name == "test" { + chain_name = format!("{}net", chain_name) + }; + + // 24 hour average bandwidth usage + let num_days = uptime.num_seconds() as f64 / 86400f64; + let bandwidth_up = (net_totals.total_bytes_sent as f64 / num_days) as u64; + let bandwidth_down = (net_totals.total_bytes_recv as f64 / num_days) as u64; + + // Time until the next difficulty adjustment + let retarget_blocks = DIFFCHANGE_INTERVAL - (tip.height % DIFFCHANGE_INTERVAL); + let retarget_dur = dur_from_secs(retarget_blocks * TARGET_BLOCK_SPACING); + + // Current reward era and time until next halving + let reward_era = tip.height / HALVING_INTERVAL; + let block_reward = Amount::from_sat(INITIAL_REWARD / 2u64.pow(reward_era as u32)); + let halving_blocks = HALVING_INTERVAL - (tip.height % HALVING_INTERVAL); + let halving_dur = dur_from_secs(halving_blocks * TARGET_BLOCK_SPACING); + + // Time since last block + let tip_ago = match (UNIX_EPOCH + StdDuration::from_secs(tip.time as u64)).elapsed() { + Ok(elapsed) => format!("{} ago", format_dur(&Duration::from_std(elapsed).unwrap())), + Err(_) => "just now".to_string(), // account for blocks with a timestamp slightly in the future + }; + + + // sat/kb -> sat/vB + let mempool_min_fee = mempool_info.mempool_min_fee.as_sat() as f64 / 1000f64; + + let has_inbound = peers.iter().any(|p| p.inbound); + + let modes = [ + if chain_info.pruned { + "✂️ ᴘʀᴜɴᴇᴅ" + } else { + "🗄️ ᴀʀᴄʜɪᴠᴀʟ" + }, + if net_info.local_relay { + "🗣️ ᴍᴇᴍᴘᴏᴏʟ ʀᴇʟᴀʏ" + } else { + "📦 ʙʟᴏᴄᴋsᴏɴʟʏ" + }, + if has_inbound { + "👂 ʟɪsᴛᴇɴs" + } else { + "🙉 ɴᴏʟɪsᴛᴇɴ" + }, + ]; + + let ver_lines = big_numbers(crate::BWT_VERSION); + + Ok(format!( + r#" + ██████  ██  ██ ████████  + ██   ██ ██  ██    ██     + ██████  ██  █  ██  ██  {ver_line1} + ██   ██ ██ ███ ██  ██  █ █ {ver_line2} + ██████   ███ ███   ██  ▀▄▀ {ver_line3} + + {client_name} + + {modes} + + NETWORK: 🌐 {chain_name} + CONNECTED: 💻 {connected_peers} ᴘᴇᴇʀs + UPTIME: ⏱️ {uptime} + + BANDWIDTH: 📶 {bandwidth_up} 🔼 {bandwidth_down} 🔽 (24ʜ ᴀᴠɢ) + CHAIN SIZE: 💾 {chain_size} + + HASHRATE: ⛏️ {hash_rate} (7ᴅ ᴀᴠɢ) + DIFFICULTY: 🏋️ {difficulty} (ʀᴇ-🎯 ɪɴ {retarget_dur} ⏳) + REWARD ERA: 🎁 {block_reward:.2} ʙᴛᴄ (½ ɪɴ {halving_dur} ⏳) + + LAST BLOCK: ⛓️ {tip_height} / {tip_ago} / {tip_size} / {tip_n_tx} + Fᴇᴇ ʀᴀᴛᴇ {tip_fee_per10}-{tip_fee_per90} sᴀᴛ/ᴠʙ / ᴀᴠɢ {tip_fee_avg} sᴀᴛ/ᴠʙ / ᴛᴏᴛᴀʟ {tip_fee_total:.3} ʙᴛᴄ + MEMPOOL: 💭 {mempool_size} / {mempool_n_tx} / ᴍɪɴ {mempool_min_fee} sᴀᴛ/ᴠʙ + FEES EST: 🏷️ 20 ᴍɪɴᴜᴛᴇs: {est_20m} / 3 ʜᴏᴜʀs: {est_3h} / 1 ᴅᴀʏ: {est_1d} (sᴀᴛ/ᴠʙ) + + SUPPORT DEV: 🚀 bc1qmuagsjvq0lh3admnafk0qnlql0vvxv08au9l2d / https://btcpay.shesek.info +"#, + client_name = to_widetext(&net_info.subversion), + chain_name = to_smallcaps(&chain_name), + connected_peers = net_info.connections, + uptime = to_smallcaps(&format_dur(&uptime).to_uppercase()), + bandwidth_up = to_smallcaps(&format_bytes(bandwidth_up)), + bandwidth_down = to_smallcaps(&format_bytes(bandwidth_down)), + chain_size = to_smallcaps(&format_bytes(chain_info.size_on_disk)), + hash_rate = to_smallcaps(&format_metric(hash_rate_7d, " ", "H/s")), + difficulty = to_smallcaps(&format_metric(chain_info.difficulty as f64, " ", "")), + retarget_dur = to_smallcaps(&format_dur(&retarget_dur).to_uppercase()), + halving_dur = to_smallcaps(&format_dur(&halving_dur).to_uppercase()), + block_reward = block_reward.as_btc(), + tip_height = tip.height, + tip_ago = to_smallcaps(&tip_ago), + tip_size = to_smallcaps(&format_bytes(tip.total_size as u64)), + tip_n_tx = to_smallcaps(&format_metric(tip.txs as f64, "", " txs")), + tip_fee_per10 = tip.feerate_percentiles.0, + tip_fee_per90 = tip.feerate_percentiles.4, + tip_fee_avg = tip.avg_fee_rate, + tip_fee_total = tip.total_fee.as_btc(), + mempool_size = to_smallcaps(&format_bytes(mempool_info.bytes)), + mempool_n_tx = to_smallcaps(&format_metric(mempool_info.size as f64, "", " txs")), + mempool_min_fee = mempool_min_fee, + est_20m = est_20m, + est_3h = est_3h, + est_1d = est_1d, + modes = modes.join(" "), + ver_line1 = ver_lines.0, + ver_line2 = ver_lines.1, + ver_line3 = ver_lines.2, + )) +} + +/* Disabled because this takes too long + + let utxo_info = query.rpc().get_tx_out_set_info()?; + let total_supply = utxo_info.total_amount.to_string(); + + 𝚄𝚃𝚇𝙾 𝚂𝙸𝚉𝙴: 🗃️ {utxo_size} + + ✔️ 𝚅𝙴𝚁𝙸𝙵𝙸𝙴𝙳 ✔️ + 𝙲𝙸𝚁𝙲𝚄𝙻𝙰𝚃𝙸𝙽𝙶 {total_supply} + 𝚂𝚄𝙿𝙿𝙻𝚈 {total_supply_line} + + utxo_size = to_smallcaps(&format_bytes(utxo_info.disk_size)), + total_supply = to_smallcaps(&total_supply), + total_supply_line = "‾".repeat(total_supply.len()), + height = utxo_info.height, +*/ + +fn dur_from_secs(seconds: u64) -> Duration { + Duration::from_std(StdDuration::from_secs(seconds)).unwrap() +} + +fn format_dur(dur: &Duration) -> String { + let days = dur.num_days(); + if days > 90 { + return format!("{} months", days / 30); + } + if days > 21 { + return format!("{} weeks", days / 7); + } + if days > 3 { + return format!("{} days", days); + } + let hours = dur.num_hours(); + if hours > 3 { + return format!("{} hours", hours); + } + let minutes = dur.num_minutes(); + if minutes > 3 { + return format!("{} minutes", minutes); + } + format!("{} seconds", dur.num_seconds()) +} + +fn format_bytes(bytes: u64) -> String { + format_metric(bytes as f64, " ", "B") +} + +fn format_metric(num: f64, space: &str, suf: &str) -> String { + if num >= 1000000000000000000f64 { + format!( + "{}{}E{}", + format_dec(num / 1000000000000000000f64), + space, + suf + ) + } else if num >= 1000000000000000f64 { + format!("{}{}P{}", format_dec(num / 1000000000000000f64), space, suf) + } else if num >= 1000000000000f64 { + format!("{}{}T{}", format_dec(num / 1000000000000f64), space, suf) + } else if num >= 1000000000f64 { + format!("{}{}G{}", format_dec(num / 1000000000f64), space, suf) + } else if num >= 1000000f64 { + format!("{}{}M{}", format_dec(num / 1000000f64), space, suf) + } else if num >= 1000f64 { + format!("{}{}K{}", format_dec(num / 1000f64), space, suf) + } else { + format!("{}{}{}", format_dec(num), space, suf) + } +} + +// format with 1 decimal digit and no unnecessary trailing 0s or dots +fn format_dec(num: f64) -> String { + format!("{:.1}", num) + .trim_end_matches('0') + .trim_end_matches('.') + .into() +} + +lazy_static! { + static ref SMALLCAPS_ALPHABET: Vec = + "ᴀʙᴄᴅᴇFɢʜɪᴊᴋʟᴍɴᴏᴘQʀsᴛᴜᴠᴡxʏᴢᴀʙᴄᴅᴇFɢʜɪᴊᴋʟᴍɴᴏᴘQʀsᴛᴜᴠᴡxʏᴢ01234567890./:".chars().collect::>(); + static ref WIDETEXT_ALPHABET: Vec = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01234567890./:" + .chars() + .collect::>(); +} + +fn convert_alphabet(s: &str, alphabet: &[char]) -> String { + s.chars() + .map(|c| match c { + 'a'..='z' => alphabet[c as usize - 97], + 'A'..='Z' => alphabet[c as usize - 65 + 26], + '0'..='9' => alphabet[c as usize - 48 + 26 * 2], + '.' => alphabet[63], + '/' => alphabet[64], + ':' => alphabet[65], + c => c, + }) + .collect() +} + +fn to_smallcaps(s: &str) -> String { + convert_alphabet(s, &SMALLCAPS_ALPHABET[..]) +} +fn to_widetext(s: &str) -> String { + convert_alphabet(s, &WIDETEXT_ALPHABET[..]) +} + +fn big_numbers(s: &str) -> (String, String, String) { + let mut lines = ("".to_string(), "".to_string(), "".to_string()); + for c in s.chars() { + let char_lines = match c { + '0' => ("█▀▀█", "█ █", "█▄▄█"), + '1' => ("▄█ ", " █ ", "▄█▄"), + '2' => ("█▀█", " ▄▀", "█▄▄"), + '3' => ("█▀▀█", " ▀▄", "█▄▄█"), + '4' => (" █▀█ ", "█▄▄█▄", " █ "), + '5' => ("█▀▀", "▀▀▄", "▄▄▀"), + '6' => ("▄▀▀▄", "█▄▄ ", "▀▄▄▀"), + '7' => ("▀▀▀█", " █ ", " ▐▌ "), + '8' => ("▄▀▀▄", "▄▀▀▄", "▀▄▄▀"), + '9' => ("▄▀▀▄", "▀▄▄█", " ▄▄▀"), + '.' => (" ", " ", "█"), + _ => continue, + }; + lines.0.push_str(char_lines.0); + lines.1.push_str(char_lines.1); + lines.2.push_str(char_lines.2); + lines.0.push_str(" "); + lines.1.push_str(" "); + lines.2.push_str(" "); + } + lines +} diff --git a/src/bitcoincore_ext.rs b/src/bitcoincore_ext.rs index dd98834..21d0050 100644 --- a/src/bitcoincore_ext.rs +++ b/src/bitcoincore_ext.rs @@ -30,27 +30,22 @@ pub trait RpcApiExt: RpcApi { self.call("getnetworkinfo", &[]) } - /// Pending https://github.com/rust-bitcoin/rust-bitcoincore-rpc/pull/131 + // Pending https://github.com/rust-bitcoin/rust-bitcoincore-rpc/pull/131 fn get_net_totals(&self) -> Result { self.call("getnettotals", &[]) } - /// Pending https://github.com/rust-bitcoin/rust-bitcoincore-rpc/pull/129 + // Pending https://github.com/rust-bitcoin/rust-bitcoincore-rpc/pull/129 fn uptime(&self) -> Result { self.call("uptime", &[]) } - /// Pending https://github.com/rust-bitcoin/rust-bitcoincore-rpc/pull/130 + // Pending https://github.com/rust-bitcoin/rust-bitcoincore-rpc/pull/130 fn get_network_hash_ps(&self, nblocks: u64) -> Result { self.call("getnetworkhashps", &[json!(nblocks)]) } - /// Pending https://github.com/rust-bitcoin/rust-bitcoincore-rpc/pull/132 - fn get_tx_out_set_info(&self) -> Result { - self.call("gettxoutsetinfo", &[]) - } - - // Only supports the fields we're interested in, not upstremable + // Only supports the fields we're interested in (so not currently upstremable) fn get_block_stats(&self, blockhash: &bitcoin::BlockHash) -> Result { let fields = ( "height", @@ -64,6 +59,10 @@ pub trait RpcApiExt: RpcApi { ); self.call("getblockstats", &[json!(blockhash), json!(fields)]) } + + fn get_mempool_info(&self) -> Result { + self.call("getmempoolinfo", &[]) + } } impl RpcApiExt for Client {} @@ -190,29 +189,6 @@ pub struct GetNetTotalsResultUploadTarget { pub time_left_in_cycle: u64, } -#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] -pub struct GetTxOutSetInfoResult { - /// The current block height (index) - pub height: u64, - /// The hash of the block at the tip of the chain - //#[serde(with = "::serde_hex", rename = "bestblock")] - //pub best_block: Vec, - /// The number of transactions with unspent outputs - pub transactions: u64, - /// The number of unspent transaction outputs - #[serde(rename = "txouts")] - pub tx_outs: u64, - /// A meaningless metric for UTXO set size - pub bogosize: u64, - /// The serialized hash - pub hash_serialized_2: bitcoin::hashes::sha256::Hash, - /// The estimated size of the chainstate on disk - pub disk_size: u64, - /// The total amount - #[serde(with = "bitcoin::util::amount::serde::as_btc")] - pub total_amount: bitcoin::Amount, -} - #[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] pub struct GetBlockStatsResult { pub height: u64, @@ -226,3 +202,14 @@ pub struct GetBlockStatsResult { pub avg_fee_rate: u64, pub feerate_percentiles: (u64, u64, u64, u64, u64), } + +#[derive(Clone, PartialEq, Eq, Debug, Deserialize, Serialize)] +pub struct GetMempoolInfoResult { + pub size: u64, + pub bytes: u64, + #[serde( + rename = "mempoolminfee", + with = "bitcoin::util::amount::serde::as_btc" + )] + pub mempool_min_fee: bitcoin::Amount, +} diff --git a/src/config.rs b/src/config.rs index c55260e..c302a2c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -36,7 +36,7 @@ pub struct Config { )] pub verbose: usize, - // XXX this is not settable as an env var due to https://github.com/TeXitoi/structopt/issues/305 + // XXX not settable as an env var due to https://github.com/TeXitoi/structopt/issues/305 #[structopt( short = "t", long, @@ -157,7 +157,7 @@ pub struct Config { )] pub electrum_rpc_addr: Option, - // XXX this is not settable as an env var due to https://github.com/TeXitoi/structopt/issues/305 + // XXX not settable as an env var due to https://github.com/TeXitoi/structopt/issues/305 #[cfg(feature = "electrum")] #[structopt( long, @@ -209,6 +209,15 @@ pub struct Config { )] pub broadcast_cmd: Option, + // XXX this is not settable as an env var due to https://github.com/clap-rs/clap/issues/1476 + #[structopt( + long = "no-startup-banner", + help = "Disable the startup banner", + parse(from_flag = std::ops::Not::not), + display_order(92) + )] + pub startup_banner: bool, + #[cfg(unix)] #[structopt( long, diff --git a/src/electrum/server.rs b/src/electrum/server.rs index a0a0b9e..2066ecc 100644 --- a/src/electrum/server.rs +++ b/src/electrum/server.rs @@ -9,6 +9,7 @@ use std::thread; use bitcoin::Txid; use serde_json::{from_str, from_value, Value}; +use crate::banner::get_welcome_banner; use crate::electrum::{electrum_height, QueryExt}; use crate::error::{fmt_error_chain, BwtError, Context, Result}; use crate::indexer::IndexChange; @@ -69,15 +70,15 @@ impl Connection { fn server_version(&self) -> Result { // TODO check the versions are compatible and disconnect otherwise - Ok(json!([format!("bwt {}", BWT_VERSION), PROTOCOL_VERSION])) + Ok(json!([format!("bwt v{}", BWT_VERSION), PROTOCOL_VERSION])) } fn server_banner(&self) -> Result { - Ok(json!("Welcome to bwt 🚀🌑")) + Ok(json!(get_welcome_banner(&self.query)?)) } fn server_donation_address(&self) -> Result { - Ok(Value::Null) + Ok(json!("bc1qmuagsjvq0lh3admnafk0qnlql0vvxv08au9l2d")) } fn server_peers_subscribe(&self) -> Result { diff --git a/src/lib.rs b/src/lib.rs index d02dbde..19e128a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -15,6 +15,7 @@ extern crate serde; mod macros; pub mod app; +pub mod banner; pub mod bitcoincore_ext; pub mod config; pub mod error; diff --git a/src/query.rs b/src/query.rs index ace9300..4a20ff5 100644 --- a/src/query.rs +++ b/src/query.rs @@ -52,6 +52,10 @@ impl Query { } } + pub fn rpc(&self) -> &RpcClient { + &self.rpc + } + pub fn debug_index(&self) -> String { format!("{:#?}", self.indexer.read().unwrap().store()) }