diff --git a/web/explorer/explorer-server/src/server.rs b/web/explorer/explorer-server/src/server.rs index 5a2c01761..1f97a585c 100644 --- a/web/explorer/explorer-server/src/server.rs +++ b/web/explorer/explorer-server/src/server.rs @@ -1,684 +1,692 @@ use std::path::PathBuf; use std::{ borrow::Cow, collections::{hash_map::Entry, HashMap, HashSet}, }; use askama::Template; use axum::{response::Redirect, routing::get, Router}; use bitcoinsuite_chronik_client::proto::{ token_type, AlpTokenType, ScriptUtxo, SlpTokenType, TokenInfo, TokenTxType, TokenType, }; use bitcoinsuite_chronik_client::{proto::OutPoint, ChronikClient}; use bitcoinsuite_core::{CashAddress, Hashed, Sha256d}; use bitcoinsuite_error::Result; use chrono::{TimeZone, Utc}; use eyre::{bail, eyre}; use futures::future; use crate::{ api::{ block_txs_to_json, calc_tx_stats, tokens_to_json, tx_history_to_json, }, blockchain::{ calculate_block_difficulty, cash_addr_to_script_type_payload, from_be_hex, to_be_hex, to_legacy_address, }, chain::Chain, server_http::{ address, address_qr, block, block_height, blocks, data_address_txs, data_block_txs, data_blocks, search, serve_files, tx, }, server_primitives::{ JsonBalance, JsonBlock, JsonBlocksResponse, JsonTxsResponse, JsonUtxo, }, templating::{ AddressTemplate, BlockTemplate, BlocksTemplate, TransactionTemplate, }, }; pub struct Server { chronik: ChronikClient, base_dir: PathBuf, satoshi_addr_prefix: &'static str, tokens_addr_prefix: &'static str, } impl Server { pub async fn setup( chronik: ChronikClient, base_dir: PathBuf, chain: Chain, ) -> Result { Ok(Server { chronik, base_dir, satoshi_addr_prefix: match chain { Chain::Mainnet => "ecash", Chain::Testnet => "ectest", Chain::Regtest => "ecregtest", }, tokens_addr_prefix: "etoken", }) } pub fn router(&self) -> Router { Router::new() .route("/", get(blocks)) .route("/tx/:hash", get(tx)) .route("/blocks", get(blocks)) .route("/block/:hash", get(block)) .route("/block-height/:height", get(block_height)) .route("/address/:hash", get(address)) .route("/address-qr/:hash", get(address_qr)) .route("/search/:query", get(search)) .route("/api/blocks/:start_height/:end_height", get(data_blocks)) .route( "/api/block/:hash/transactions/:page/:page_size", get(data_block_txs), ) .route("/api/address/:hash/transactions", get(data_address_txs)) .nest("/code", serve_files(&self.base_dir.join("code"))) .nest("/assets", serve_files(&self.base_dir.join("assets"))) .nest( "/favicon.ico", serve_files(&self.base_dir.join("assets").join("favicon.png")), ) } } impl Server { pub async fn blocks(&self) -> Result { let blockchain_info = self.chronik.blockchain_info().await?; let blocks_template = BlocksTemplate { last_block_height: blockchain_info.tip_height as u32, }; Ok(blocks_template.render().unwrap()) } } impl Server { fn is_unknown_slp(token_type_in: &Option) -> Result { let token_type = token_type_in .clone() .ok_or_else(|| eyre!("Malformed token.token_type"))? .token_type .ok_or_else(|| eyre!("Malformed token.token_type.token_type"))?; let is_unknown_slp = match token_type { token_type::TokenType::Slp(slp) => { let slp_token_type = SlpTokenType::from_i32(slp) .ok_or_else(|| eyre!("Malformed SlpTokenType"))?; match slp_token_type { SlpTokenType::None => true, _ => false, } } _ => false, }; Ok(is_unknown_slp) } pub async fn data_blocks( &self, start_height: i32, end_height: i32, ) -> Result { let blocks = self.chronik.blocks(start_height, end_height).await?; let mut json_blocks = Vec::with_capacity(blocks.len()); for block in blocks.into_iter().rev() { json_blocks.push(JsonBlock { hash: to_be_hex(&block.hash), height: block.height, timestamp: block.timestamp, difficulty: calculate_block_difficulty(block.n_bits), size: block.block_size, num_txs: block.num_txs, is_final: block.is_final, }); } Ok(JsonBlocksResponse { data: json_blocks }) } pub async fn data_block_txs( &self, block_hex: &str, page: usize, page_size: usize, ) -> Result { let block_hash = Sha256d::from_hex_be(block_hex)?; let block = self.chronik.block_by_hash(&block_hash).await?; let block_txs = self .chronik .block_txs_by_hash_with_page_size(&block_hash, page, page_size) .await?; let token_ids = block_txs .txs .iter() .filter_map(|tx| { let token_entry = tx.token_entries.get(0)?; if Self::is_unknown_slp(&token_entry.token_type).ok()? { return None; } Some( Sha256d::from_hex_be(&token_entry.token_id) .expect("Impossible"), ) }) .collect::>(); let tokens_by_hex = self.batch_get_chronik_tokens(token_ids).await?; let json_txs = block_txs_to_json(block, block_txs, &tokens_by_hex)?; Ok(JsonTxsResponse { data: json_txs }) } pub async fn data_address_txs( &self, address: &str, query: HashMap, ) -> Result { let address = CashAddress::parse_cow(address.into())?; let (script_type, script_payload) = cash_addr_to_script_type_payload(&address); let script_endpoint = self.chronik.script(script_type, &script_payload); let page: usize = query .get("page") .map(|s| s.as_str()) .unwrap_or("0") .parse()?; let take: usize = query .get("take") .map(|s| s.as_str()) .unwrap_or("200") .parse()?; let address_tx_history = script_endpoint.history_with_page_size(page, take).await?; let token_ids = address_tx_history .txs .iter() .filter_map(|tx| { let token_entry = tx.token_entries.get(0)?; if Self::is_unknown_slp(&token_entry.token_type).ok()? { return None; } Some( Sha256d::from_hex_be(&token_entry.token_id) .expect("Impossible"), ) }) .collect(); let tokens = self.batch_get_chronik_tokens(token_ids).await?; let json_tokens = tokens_to_json(&tokens)?; let json_txs = tx_history_to_json(&address, address_tx_history, &json_tokens)?; Ok(JsonTxsResponse { data: json_txs }) } } impl Server { pub async fn block(&self, block_hex: &str) -> Result { let block_hash = Sha256d::from_hex_be(block_hex)?; let block = self.chronik.block_by_hash(&block_hash).await?; let block_txs = self .chronik .block_txs_by_hash_with_page_size(&block_hash, 0, 1) .await?; let block_info = block.block_info.ok_or_else(|| eyre!("Block has no info"))?; let blockchain_info = self.chronik.blockchain_info().await?; let best_height = blockchain_info.tip_height; let difficulty = calculate_block_difficulty(block_info.n_bits); let timestamp = Utc.timestamp_nanos(block_info.timestamp * 1_000_000_000); let coinbase_data = block_txs.txs[0].inputs[0].input_script.clone(); let confirmations = best_height - block_info.height + 1; let block_template = BlockTemplate { block_hex, block_info, confirmations, timestamp, difficulty, coinbase_data, best_height, }; Ok(block_template.render().unwrap()) } pub async fn tx(&self, tx_hex: &str) -> Result { let tx_hash = Sha256d::from_hex_be(tx_hex)?; let tx = self.chronik.tx(&tx_hash).await?; let token_entry = tx .token_entries .get(0) .and_then(|entry| Some(entry.clone())); let (token_id, token) = match &token_entry { Some(token_entry) => { let token_id = Sha256d::from_hex_be(&token_entry.token_id)?; let mut token = None; let tx_type = TokenTxType::from_i32(token_entry.tx_type) .ok_or_else(|| eyre!("Malformed token_entry.tx_type"))?; if tx_type != TokenTxType::Unknown { token = Some(self.chronik.token(&token_id).await?); } (Some(token_id), token) } None => (None, None), }; let token_ticker = token.as_ref().and_then(|token| { Some(String::from_utf8_lossy( &token.genesis_info.as_ref()?.token_ticker, )) }); let (title, is_token): (Cow, bool) = match &token_ticker { Some(token_ticker) => { (format!("{} Transaction", token_ticker).into(), true) } None => match &tx.token_failed_parsings.get(0) { None => ("eCash Transaction".into(), false), Some(_) => ("Invalid eToken Transaction".into(), true), }, }; let token_hex = token_id.as_ref().map(|token| token.to_hex_be()); let (token_section_title, action_str, specification): ( Cow, Cow, Cow, ) = match &token_entry { Some(token_entry) => { let token_type = token_entry .token_type .clone() .ok_or_else(|| eyre!("Malformed token_entry.token_type"))? .token_type .ok_or_else(|| eyre!("Malformed token_entry.token_type"))?; let tx_type = TokenTxType::from_i32(token_entry.tx_type) .ok_or_else(|| eyre!("Malformed token_entry.tx_type"))?; let action_str = match token_type { token_type::TokenType::Slp(slp) => { let slp_token_type = SlpTokenType::from_i32(slp) .ok_or_else(|| eyre!("Malformed SlpTokenType"))?; match (slp_token_type, tx_type) { (SlpTokenType::Fungible, TokenTxType::Genesis) => { "SLP GENESIS" } (SlpTokenType::Fungible, TokenTxType::Mint) => { "SLP MINT" } (SlpTokenType::Fungible, TokenTxType::Send) => { "SLP SEND" } (SlpTokenType::Fungible, TokenTxType::Burn) => { "SLP BURN" } (SlpTokenType::Nft1Group, TokenTxType::Genesis) => { "NFT1 GROUP GENESIS" } (SlpTokenType::Nft1Group, TokenTxType::Mint) => { "NFT1 GROUP MINT" } (SlpTokenType::Nft1Group, TokenTxType::Send) => { "NFT1 GROUP SEND" } (SlpTokenType::Nft1Group, TokenTxType::Burn) => { "NFT1 GROUP BURN" } (SlpTokenType::Nft1Child, TokenTxType::Genesis) => { "NFT1 Child GENESIS" } (SlpTokenType::Nft1Child, TokenTxType::Send) => { "NFT1 Child SEND" } (SlpTokenType::Nft1Child, TokenTxType::Burn) => { "NFT1 Child BURN" } (SlpTokenType::MintVault, TokenTxType::Genesis) => { "SLPv2 GENESIS" } (SlpTokenType::MintVault, TokenTxType::Mint) => { "SLPv2 MINT" } (SlpTokenType::MintVault, TokenTxType::Send) => { "SLPv2 SEND" } (SlpTokenType::MintVault, TokenTxType::Burn) => { "SLPv2 BURN" } _ => "SLP Unknown", } } token_type::TokenType::Alp(alp) => { let alp_token_type = AlpTokenType::from_i32(alp) .ok_or_else(|| eyre!("Malformed AlpTokenType"))?; match (alp_token_type, tx_type) { (AlpTokenType::Standard, TokenTxType::Genesis) => { "ALP GENESIS" } (AlpTokenType::Standard, TokenTxType::Mint) => { "ALP MINT" } (AlpTokenType::Standard, TokenTxType::Send) => { "ALP SEND" } (AlpTokenType::Standard, TokenTxType::Burn) => { "ALP BURN" } _ => "ALP Unknown", } } }; let specification = match token_type { token_type::TokenType::Slp(slp) => { let slp_token_type = SlpTokenType::from_i32(slp) .ok_or_else(|| eyre!("Malformed SlpTokenType"))?; match slp_token_type { SlpTokenType::Fungible => { "https://github.com/simpleledger/\ slp-specifications/blob/master/\ slp-token-type-1.md" } SlpTokenType::MintVault => { "https://github.com/badger-cash/\ slp-specifications/blob/master/\ slp-token-type-2.md" } SlpTokenType::Nft1Group | SlpTokenType::Nft1Child => { "https://github.com/simpleledger/\ slp-specifications/blob/master/slp-nft-1.md" } _ => "Unknown", } } token_type::TokenType::Alp(_) => { "https://ecashbuilders.notion.site/\ ALP-a862a4130877448387373b9e6a93dd97" } }; ( - format!("Token Details ({} Transaction)", &action_str) - .into(), + format!( + "Token Details ({}{} Transaction)", + if token_entry.is_invalid { + "Invalid " + } else { + "" + }, + &action_str, + ) + .into(), action_str.into(), specification.into(), ) } None => { if tx.token_failed_parsings.get(0).is_some() { ( "Token Details (Invalid Transaction)".into(), "Unknown".into(), "Unknown".into(), ) } else { ("".into(), "Unknown".into(), "Unknown".into()) } } }; let blockchain_info = self.chronik.blockchain_info().await?; let confirmations = match &tx.block { Some(block_meta) => { blockchain_info.tip_height - block_meta.height + 1 } None => 0, }; let timestamp = match &tx.block { Some(block_meta) => block_meta.timestamp, None => tx.time_first_seen, }; let timestamp = Utc.timestamp_nanos(timestamp * 1_000_000_000); let raw_tx = self.chronik.raw_tx(&tx_hash).await?; let raw_tx = raw_tx.hex(); let tx_stats = calc_tx_stats(&tx, None); let transaction_template = TransactionTemplate { title: &title, sats_addr_prefix: &self.satoshi_addr_prefix, tokens_addr_prefix: &self.tokens_addr_prefix, token_section_title: &token_section_title, is_token, tx_hex, token_hex, tx, slp_genesis_info: token.and_then(|token| token.genesis_info), slp_meta: token_entry, sats_input: tx_stats.sats_input, sats_output: tx_stats.sats_output, token_input: tx_stats.token_input, token_output: tx_stats.token_output, raw_tx, confirmations, timestamp, action_str: &action_str, specification: &specification, }; Ok(transaction_template.render().unwrap()) } } impl Server { pub async fn address<'a>(&'a self, address: &str) -> Result { let address = CashAddress::parse_cow(address.into())?; let sats_address = address.with_prefix(self.satoshi_addr_prefix); let token_address = address.with_prefix(self.tokens_addr_prefix); let legacy_address = to_legacy_address(&address); let sats_address = sats_address.as_str(); let token_address = token_address.as_str(); let (script_type, script_payload) = cash_addr_to_script_type_payload(&address); let script_endpoint = self.chronik.script(script_type, &script_payload); let page_size = 1; let address_tx_history = script_endpoint.history_with_page_size(0, page_size).await?; let address_num_txs = address_tx_history.num_pages; let utxos = script_endpoint.utxos().await?; let mut token_dust: i64 = 0; let mut total_xec: i64 = 0; let mut token_ids: HashSet = HashSet::new(); let mut token_utxos: Vec = Vec::new(); let mut json_balances: HashMap = HashMap::new(); let mut main_json_balance: JsonBalance = JsonBalance { token_id: None, sats_amount: 0, token_amount: 0, utxos: Vec::new(), }; for utxo in utxos.into_iter() { let OutPoint { txid, out_idx } = &utxo.outpoint.as_ref().unwrap(); let mut json_utxo = JsonUtxo { tx_hash: to_be_hex(txid), out_idx: *out_idx, sats_amount: utxo.value, token_amount: 0, is_coinbase: utxo.is_coinbase, block_height: utxo.block_height, }; match &utxo.token { Some(token) => { let token_id_hex = hex::encode(&token.token_id); let token_id_hash = Sha256d::from_slice_be_or_null( &token.token_id.as_bytes(), ); json_utxo.token_amount = token.amount; match json_balances.entry(token_id_hex) { Entry::Occupied(mut entry) => { let entry = entry.get_mut(); entry.sats_amount += utxo.value; entry.token_amount += i128::from(token.amount); entry.utxos.push(json_utxo); } Entry::Vacant(entry) => { entry.insert(JsonBalance { token_id: Some(hex::encode(&token.token_id)), sats_amount: utxo.value, token_amount: token.amount.into(), utxos: vec![json_utxo], }); } } token_ids.insert(token_id_hash); token_dust += utxo.value; token_utxos.push(utxo); } _ => { total_xec += utxo.value; main_json_balance.utxos.push(json_utxo); } }; } json_balances.insert(String::from("main"), main_json_balance); let tokens = self.batch_get_chronik_tokens(token_ids).await?; let json_tokens = tokens_to_json(&tokens)?; let encoded_tokens = serde_json::to_string(&json_tokens)?.replace('\'', r"\'"); let encoded_balances = serde_json::to_string(&json_balances)?.replace('\'', r"\'"); let address_template = AddressTemplate { tokens, token_utxos, token_dust, total_xec, address_num_txs, address: address.as_str(), sats_address, token_address, legacy_address, json_balances, encoded_tokens, encoded_balances, }; Ok(address_template.render().unwrap()) } pub async fn batch_get_chronik_tokens( &self, token_ids: HashSet, ) -> Result> { let mut token_calls = Vec::new(); let mut token_map = HashMap::new(); let unknown_token = Sha256d::from_hex( "0000000000000000000000000000000000000000000000000000000000000000", )?; for token_id in token_ids.iter() { if token_id != &unknown_token { token_calls.push(Box::pin(self.chronik.token(token_id))); } } let tokens = future::try_join_all(token_calls).await?; for token in tokens.into_iter() { token_map.insert(hex::encode(&token.token_id), token); } Ok(token_map) } pub async fn address_qr(&self, address: &str) -> Result> { use qrcode_generator::QrCodeEcc; if address.len() > 60 { bail!("Invalid address length"); } let png = qrcode_generator::to_png_to_vec(address, QrCodeEcc::Quartile, 140)?; Ok(png) } pub async fn block_height(&self, height: u32) -> Result { let block = self.chronik.block_by_height(height as i32).await.ok(); match block { Some(block) => { let block_info = block.block_info.expect("Impossible"); Ok(self.redirect(format!( "/block/{}", to_be_hex(&block_info.hash) ))) } None => Ok(self.redirect("/404".into())), } } pub async fn search(&self, query: &str) -> Result { if let Ok(address) = CashAddress::parse_cow(query.into()) { return Ok(self.redirect(format!("/address/{}", address.as_str()))); } // Check for prefixless address search if let Ok(address) = format!("{}:{}", self.satoshi_addr_prefix, query) .parse::() { return Ok(self.redirect(format!("/address/{}", address.as_str()))); } if let Ok(address) = format!("{}:{}", self.tokens_addr_prefix, query) .parse::() { return Ok(self.redirect(format!("/address/{}", address.as_str()))); } if let Ok(height) = query.parse::() { if self.chronik.block_by_height(height).await.is_ok() { return Ok(self.redirect(format!("/block-height/{}", query))); } } let bytes = from_be_hex(query)?; let unknown_hash = Sha256d::from_slice(&bytes)?; if self.chronik.tx(&unknown_hash).await.is_ok() { return Ok(self.redirect(format!("/tx/{}", query))); } if self.chronik.block_by_hash(&unknown_hash).await.is_ok() { return Ok(self.redirect(format!("/block/{}", query))); } Ok(self.redirect("/404".into())) } pub fn redirect(&self, url: String) -> Redirect { Redirect::permanent(&url) } } diff --git a/web/explorer/explorer-server/src/templating/filters.rs b/web/explorer/explorer-server/src/templating/filters.rs index e95644a59..d7724e215 100644 --- a/web/explorer/explorer-server/src/templating/filters.rs +++ b/web/explorer/explorer-server/src/templating/filters.rs @@ -1,218 +1,222 @@ use std::collections::HashMap; use bitcoinsuite_chronik_client::proto::{OutPoint, Token, TokenInfo}; use bitcoinsuite_core::Script; use chrono::DateTime; use chrono_humanize::HumanTime; use humansize::{file_size_opts as options, FileSize}; use maud::{html, PreEscaped}; use num_format::{Locale, ToFormattedString}; use crate::blockchain; fn render_integer_with_small_flag( int: i128, smallify: bool, ) -> askama::Result { let string = int.to_formatted_string(&Locale::en); let parts = string.split(',').collect::>(); let output = html! { @for (idx, part) in parts.iter().enumerate() { @if idx >= 2 && smallify { small.digit-sep[idx < parts.len() - 1] { (part) } } @else { span.digit-sep[idx < parts.len() - 1] { (part) } } } }; Ok(output.into_string()) } pub fn max(value: &i64, maximum: &i64) -> askama::Result { Ok(*value.max(maximum)) } pub fn check_is_coinbase(outpoint: &OutPoint) -> askama::Result { Ok(outpoint.txid == [0; 32] && outpoint.out_idx == 0xffff_ffff) } pub fn destination_from_script<'a>( script: &'a [u8], is_token: &bool, sats_addr_prefix: &'a str, tokens_addr_prefix: &'a str, ) -> askama::Result> { let prefix = if *is_token { tokens_addr_prefix } else { sats_addr_prefix }; Ok(blockchain::destination_from_script(prefix, script)) } pub fn get_script(signature_script: &[u8]) -> askama::Result { let script = Script::from_slice(signature_script); Ok(script.to_string()) } pub fn check_is_token(token: &Option) -> askama::Result { Ok(token .as_ref() .map(|slp| slp.amount > 0 || slp.is_mint_baton) .unwrap_or(false)) } pub fn human_time( timestamp: &DateTime, ) -> askama::Result { Ok(HumanTime::from(*timestamp)) } pub fn render_integer(int: &i128) -> askama::Result { render_integer_with_small_flag(*int, false) } pub fn render_integer_smallify(int: &i128) -> askama::Result { render_integer_with_small_flag(*int, true) } pub fn render_human_size(value: &u64) -> askama::Result { Ok(value.file_size(options::CONVENTIONAL).unwrap()) } pub fn render_difficulty(difficulty: &f64) -> askama::Result { let est_hashrate = difficulty * (0xffffffffu64 as f64) / 600.0; let hashrate = if est_hashrate < 1e12 { html! { (format!("{:.2} GH/s", est_hashrate / 1e9)) } } else if est_hashrate < 1e15 { html! { (format!("{:.2} TH/s", est_hashrate / 1e12)) } } else if est_hashrate < 1e18 { html! { (format!("{:.2} PH/s", est_hashrate / 1e15)) } } else { html! { (format!("{:.2} EH/s", est_hashrate / 1e18)) } }; let num_digits = difficulty.log10().floor(); let exponent = (num_digits / 3.0) as u32; let difficulty = match exponent { 0 => html! { (format!("{:.0}", difficulty)) }, 1 => html! { (format!("{:.2}", difficulty / 1e3)) " ×10" sup { "3" } }, 2 => html! { (format!("{:.2}", difficulty / 1e6)) " ×10" sup { "6" } }, 3 => html! { (format!("{:.2}", difficulty / 1e9)) " ×10" sup { "9" } }, 4 => { html! { (format!("{:.2}", difficulty / 1e12)) " ×10" sup { "12" } } } _ => { html! { (format!("{:.2}", difficulty / 1e15)) " ×10" sup { "15" } } } }; let output = html! { (difficulty) small { " (10 min. blocks = " (hashrate) ")" } }; Ok(output.into_string()) } pub fn render_integer_with_commas(int: &u64) -> askama::Result { let string = int.to_formatted_string(&Locale::en); let parts = string.split(',').collect::>(); let output = html! { @for (idx, part) in parts.iter().enumerate() { @if idx != 0 { span.non-selectable { "," } } span { (part) } } }; Ok(output.into_string()) } pub fn render_sats(sats: &i64) -> askama::Result { let coins = *sats as f64 / 100.0; let fmt = format!("{:.2}", coins); let mut parts = fmt.split('.'); let integer_part: u64 = parts.next().unwrap().parse().unwrap(); let fract_part = parts.next().unwrap(); let output = { let output = html! { (PreEscaped(render_integer_with_commas(&integer_part)?)) "." small { (fract_part) } }; output.into_string() }; Ok(output) } pub fn hexify_u8_vector(value: &[u8]) -> askama::Result { Ok(hex::encode(value)) } pub fn string_from_lossy_utf8(value: &[u8]) -> askama::Result { Ok(String::from_utf8_lossy(value).to_string()) } pub fn to_le_hex(slice: &[u8]) -> askama::Result { Ok(blockchain::to_be_hex(slice)) } pub fn u32_to_u64(value: &u32) -> askama::Result { Ok(*value as u64) } pub fn to_i128 + Copy>(value: &T) -> askama::Result { Ok((*value).into()) } +pub fn string_to_i128(value: &String) -> askama::Result { + Ok(value.parse::().unwrap()) +} + pub fn render_token_amount( base_amount: &i128, decimals: &u32, ) -> askama::Result { let decimals = *decimals as usize; if decimals == 0 { return render_integer(base_amount); } let base_amount_str = format!("{:0digits$}", base_amount, digits = decimals + 1); let decimal_idx = base_amount_str.len() - decimals; let integer_part: i128 = base_amount_str[..decimal_idx].parse().unwrap(); let fract_part = &base_amount_str[decimal_idx..]; let num_fract_sections = (decimals as usize + 2) / 3; let mut all_zeros = true; let mut rendered = html! {}; for section_idx in (0..num_fract_sections).rev() { let offset = section_idx * 3; let section = &fract_part[offset..fract_part.len().min(offset + 3)]; if !section.chars().all(|c| c == '0') { all_zeros = false; } rendered = html! { small.zeros[all_zeros].digit-sep[ section_idx != num_fract_sections - 1] { (section) } (rendered) }; } let output = html! { (PreEscaped(render_integer(&integer_part)?)) "." (rendered) }; Ok(output.into_string()) } pub fn get_token<'a>( tokens: &'a HashMap, token_id: &str, ) -> askama::Result> { Ok(tokens.get(token_id)) } diff --git a/web/explorer/explorer-server/templates/pages/transaction.html b/web/explorer/explorer-server/templates/pages/transaction.html index 5a50d0de3..4a3295ba4 100644 --- a/web/explorer/explorer-server/templates/pages/transaction.html +++ b/web/explorer/explorer-server/templates/pages/transaction.html @@ -1,317 +1,348 @@ {% extends "base.html" %} {% import "components/input.html" as input %} {% import "components/output.html" as output %} {% import "components/token_info_table.html" as token_info_table %} {% block body_classes %}overflow-y-scroll{% endblock %} {% block content %}

{{ title }}

{% if tx.is_coinbase %}
Coinbase
{% endif %}
Advanced
{% if is_token %} {% else %} {% endif %}
Transaction ID {{ tx_hex }}
Copy to clipboard
Token ID {% match token_hex %} {% when Some with (token_hex) %} {{ token_hex }}
Copy to clipboard
{% when None %} {% endmatch %}
Transaction ID {{ tx_hex }}
Copy to clipboard

General Details

Age
{{ timestamp|human_time }}
Block
{% match tx.block %} {% when Some with (block_meta) %} {{ block_meta.height|to_i128|render_integer|safe }} ({{ confirmations|to_i128|render_integer|safe }} confirmation{% if confirmations|to_i128 > 1 %}s{% endif %}) {% when None %} Not mined yet {% endmatch %}
Unix Timestamp
{% match tx.block %} {% when Some with (block_meta) %} {{ block_meta.timestamp|to_i128|render_integer|safe }} {% when None %} Not mined yet {% endmatch %}
Size
{{ tx.size|u32_to_u64|render_human_size }} ({{ tx.size|to_i128|render_integer_smallify|safe }} B)
Mined On
{% match tx.block %} {% when Some with (block_meta) %}
{% when None %}
Not mined yet
{% endmatch %}
Total Input
{{ sats_input|render_sats|safe }} XEC
Total Output
{{ sats_output|render_sats|safe }} XEC
Fee
{{ (sats_input - sats_output)|max(0)|render_sats|safe }} XEC
{% if is_token %}

{{ token_section_title }}

{% call token_info_table::render(tx, slp_genesis_info, slp_meta, token_input, token_output, action_str, specification) %}
+ + {% match slp_meta %}{% when Some with(entry) %}{% match slp_genesis_info + %}{% when Some with(genesis_info) %} {% if entry.is_invalid %} +

Error details

+
+ + + + + + + + + + + + + + + +
Total burnt + {{ + entry.actual_burn_amount|string_to_i128|render_token_amount(genesis_info.decimals)|safe + }} {{ + genesis_info.token_ticker|string_from_lossy_utf8 }} +
Burns mint batons + {% if entry.burns_mint_batons %}Yes{% else %}No{% + endif %} +
Error message{{ entry.burn_summary }}
+
+ {% endif %} {% when None %}{% endmatch %}{% when None %}{% endmatch %} {% endif %}

Transaction

Inputs ({{ tx.inputs.len() }})

{{ timestamp }}
Pre-XEC
(Nov 15, 2020)
Over Five
Years Old
Pre-BCH
(Aug 8, 2017)
{% for input in tx.inputs %} {% call input::render(loop.index0, input, tx, slp_genesis_info, sats_addr_prefix, tokens_addr_prefix) %} {% endfor %}

Outputs ({{ tx.outputs.len() }})

{% for output in tx.outputs %} {% call output::render(loop.index0, output, tx, slp_genesis_info, sats_addr_prefix, tokens_addr_prefix) %} {% endfor %}
{% endblock %}