From fc3ddfa9a3bebf0f8a0d6ef14ba8d7c6f382c11d Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Fri, 23 Sep 2022 14:01:13 -0700 Subject: [PATCH] Allow searching for block hashes, txids, and outputs things (#549) --- Cargo.lock | 1 + Cargo.toml | 3 +- src/index.rs | 75 ++++----- src/main.rs | 3 +- src/subcommand/server.rs | 319 +++++++++++++++++++++------------------ src/test.rs | 25 ++- 6 files changed, 236 insertions(+), 190 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2c844a4d98..1dc548c852 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2149,6 +2149,7 @@ dependencies = [ "jsonrpc-core-client", "jsonrpc-derive", "jsonrpc-http-server", + "lazy_static", "log", "mime_guess", "nix 0.24.2", diff --git a/Cargo.toml b/Cargo.toml index af566137b2..27922da68c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,10 +23,12 @@ env_logger = "0.9.0" futures = "0.3.21" html-escaper = "0.2.0" http = "0.2.6" +lazy_static = "1.4.0" log = "0.4.14" mime_guess = "2.0.4" rayon = "1.5.1" redb = { version = "0.6.0", git = "https://github.com/casey/redb.git", branch = "add-write-lock" } +regex = "1.6.0" rust-embed = "6.4.0" rustls = "0.20.6" rustls-acme = { version = "0.5.0", features = ["axum"] } @@ -49,7 +51,6 @@ jsonrpc-http-server = "18.0.0" log = "0.4.14" nix = "0.24.1" pretty_assertions = "1.2.1" -regex = "1.6.0" reqwest = { version = "0.11.10", features = ["blocking"] } tempfile = "3.2.0" unindent = "0.1.7" diff --git a/src/index.rs b/src/index.rs index 73c7f39643..ee227a0802 100644 --- a/src/index.rs +++ b/src/index.rs @@ -1,6 +1,7 @@ use { super::*, bitcoin::consensus::encode::serialize, + bitcoin::BlockHeader, bitcoincore_rpc::{Auth, Client, RpcApi}, rayon::iter::{IntoParallelRefIterator, ParallelIterator}, redb::WriteStrategy, @@ -39,6 +40,29 @@ impl From for u64 { } } +trait BitcoinCoreRpcResultExt { + fn into_option(self) -> Result>; +} + +impl BitcoinCoreRpcResultExt for Result { + fn into_option(self) -> Result> { + match self { + Ok(ok) => Ok(Some(ok)), + Err(bitcoincore_rpc::Error::JsonRpc(bitcoincore_rpc::jsonrpc::error::Error::Rpc( + bitcoincore_rpc::jsonrpc::error::RpcError { code: -8, .. }, + ))) => Ok(None), + Err(bitcoincore_rpc::Error::JsonRpc(bitcoincore_rpc::jsonrpc::error::Error::Rpc( + bitcoincore_rpc::jsonrpc::error::RpcError { message, .. }, + ))) + if message.ends_with("not found") => + { + Ok(None) + } + Err(err) => Err(err.into()), + } + } +} + impl Index { pub(crate) fn open(options: &Options) -> Result { let rpc_url = options.rpc_url(); @@ -397,47 +421,26 @@ impl Index { } fn block_at_height(&self, height: u64) -> Result> { - match self.client.get_block_hash(height) { - Ok(hash) => Ok(Some(self.client.get_block(&hash)?)), - Err(bitcoincore_rpc::Error::JsonRpc(bitcoincore_rpc::jsonrpc::error::Error::Rpc( - bitcoincore_rpc::jsonrpc::error::RpcError { code: -8, .. }, - ))) => Ok(None), - Err(bitcoincore_rpc::Error::JsonRpc(bitcoincore_rpc::jsonrpc::error::Error::Rpc( - bitcoincore_rpc::jsonrpc::error::RpcError { message, .. }, - ))) - if message == "Block not found" => - { - Ok(None) - } - Err(err) => Err(err.into()), - } + Ok( + self + .client + .get_block_hash(height) + .into_option()? + .map(|hash| self.client.get_block(&hash)) + .transpose()?, + ) } - pub(crate) fn block_with_hash(&self, hash: sha256d::Hash) -> Result> { - match self.client.get_block(&BlockHash::from_hash(hash)) { - Ok(block) => Ok(Some(block)), - Err(bitcoincore_rpc::Error::JsonRpc(bitcoincore_rpc::jsonrpc::error::Error::Rpc( - bitcoincore_rpc::jsonrpc::error::RpcError { code: -8, .. }, - ))) => Ok(None), - Err(bitcoincore_rpc::Error::JsonRpc(bitcoincore_rpc::jsonrpc::error::Error::Rpc( - bitcoincore_rpc::jsonrpc::error::RpcError { message, .. }, - ))) - if message == "Block not found" => - { - Ok(None) - } - Err(err) => Err(err.into()), - } + pub(crate) fn block_header(&self, hash: BlockHash) -> Result> { + self.client.get_block_header(&hash).into_option() + } + + pub(crate) fn block_with_hash(&self, hash: BlockHash) -> Result> { + self.client.get_block(&hash).into_option() } pub(crate) fn transaction(&self, txid: Txid) -> Result> { - match self.client.get_raw_transaction(&txid, None) { - Ok(transaction) => Ok(Some(transaction)), - Err(bitcoincore_rpc::Error::JsonRpc(bitcoincore_rpc::jsonrpc::error::Error::Rpc( - bitcoincore_rpc::jsonrpc::error::RpcError { code: -8, .. }, - ))) => Ok(None), - Err(err) => Err(err.into()), - } + self.client.get_raw_transaction(&txid, None).into_option() } pub(crate) fn find(&self, ordinal: Ordinal) -> Result> { diff --git a/src/main.rs b/src/main.rs index b00b94eec3..9962b7730b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -33,7 +33,7 @@ use { blockdata::{constants::COIN_VALUE, transaction::TxOut}, consensus::{Decodable, Encodable}, hash_types::BlockHash, - hashes::{sha256d, Hash}, + hashes::Hash, secp256k1::{ rand::{self}, Secp256k1, @@ -45,6 +45,7 @@ use { clap::Parser, derive_more::{Display, FromStr}, redb::{Database, ReadableTable, Table, TableDefinition, WriteTransaction}, + regex::Regex, serde::{Deserialize, Serialize}, std::{ collections::VecDeque, diff --git a/src/subcommand/server.rs b/src/subcommand/server.rs index d5d44bbac1..e2310e3aae 100644 --- a/src/subcommand/server.rs +++ b/src/subcommand/server.rs @@ -13,6 +13,7 @@ use { http::header, response::{Redirect, Response}, }, + lazy_static::lazy_static, rust_embed::RustEmbed, rustls_acme::{ acme::{LETS_ENCRYPT_PRODUCTION_DIRECTORY, LETS_ENCRYPT_STAGING_DIRECTORY}, @@ -124,12 +125,22 @@ impl Server { .allow_origin(Any), ); - let (http_result, https_result) = tokio::join!( - self.spawn(&router, &handle, None)?, - self.spawn(&router, &handle, self.acceptor(&options)?)? - ); - - http_result.and(https_result)?.transpose()?; + match (self.http_port(), self.https_port()) { + (Some(http_port), None) => self.spawn(router, handle, http_port, None)?.await??, + (None, Some(https_port)) => { + self + .spawn(router, handle, https_port, Some(self.acceptor(&options)?))? + .await?? + } + (Some(http_port), Some(https_port)) => { + let (http_result, https_result) = tokio::join!( + self.spawn(router.clone(), handle.clone(), http_port, None)?, + self.spawn(router, handle, https_port, Some(self.acceptor(&options)?))? + ); + http_result.and(https_result)??; + } + (None, None) => unreachable!(), + } Ok(()) }) @@ -137,40 +148,28 @@ impl Server { fn spawn( &self, - router: &Router, - handle: &Handle, + router: Router, + handle: Handle, + port: u16, https_acceptor: Option, - ) -> Result>>> { - let addr = if https_acceptor.is_some() { - self.https_port() - } else { - self.http_port() - } - .map(|port| { - (self.address.as_str(), port) - .to_socket_addrs()? - .next() - .ok_or_else(|| anyhow!("Failed to get socket addrs")) - .map(|addr| (addr, router.clone(), handle.clone())) - }) - .transpose()?; + ) -> Result>> { + let addr = (self.address.as_str(), port) + .to_socket_addrs()? + .next() + .ok_or_else(|| anyhow!("Failed to get socket addrs"))?; Ok(tokio::spawn(async move { - if let Some((addr, router, handle)) = addr { - Some(if let Some(acceptor) = https_acceptor { - axum_server::Server::bind(addr) - .handle(handle) - .acceptor(acceptor) - .serve(router.into_make_service()) - .await - } else { - axum_server::Server::bind(addr) - .handle(handle) - .serve(router.into_make_service()) - .await - }) + if let Some(acceptor) = https_acceptor { + axum_server::Server::bind(addr) + .handle(handle) + .acceptor(acceptor) + .serve(router.into_make_service()) + .await } else { - None + axum_server::Server::bind(addr) + .handle(handle) + .serve(router.into_make_service()) + .await } })) } @@ -207,42 +206,38 @@ impl Server { } } - fn acceptor(&self, options: &Options) -> Result> { - if self.https_port().is_some() { - let config = AcmeConfig::new(Self::acme_domains(&self.acme_domain)?) - .contact(&self.acme_contact) - .cache_option(Some(DirCache::new(Self::acme_cache( - self.acme_cache.as_ref(), - options, - )?))) - .directory(if cfg!(test) { - LETS_ENCRYPT_STAGING_DIRECTORY - } else { - LETS_ENCRYPT_PRODUCTION_DIRECTORY - }); - - let mut state = config.state(); - - let acceptor = state.axum_acceptor(Arc::new( - rustls::ServerConfig::builder() - .with_safe_defaults() - .with_no_client_auth() - .with_cert_resolver(state.resolver()), - )); + fn acceptor(&self, options: &Options) -> Result { + let config = AcmeConfig::new(Self::acme_domains(&self.acme_domain)?) + .contact(&self.acme_contact) + .cache_option(Some(DirCache::new(Self::acme_cache( + self.acme_cache.as_ref(), + options, + )?))) + .directory(if cfg!(test) { + LETS_ENCRYPT_STAGING_DIRECTORY + } else { + LETS_ENCRYPT_PRODUCTION_DIRECTORY + }); - tokio::spawn(async move { - while let Some(result) = state.next().await { - match result { - Ok(ok) => log::info!("ACME event: {:?}", ok), - Err(err) => log::error!("ACME error: {:?}", err), - } + let mut state = config.state(); + + let acceptor = state.axum_acceptor(Arc::new( + rustls::ServerConfig::builder() + .with_safe_defaults() + .with_no_client_auth() + .with_cert_resolver(state.resolver()), + )); + + tokio::spawn(async move { + while let Some(result) = state.next().await { + match result { + Ok(ok) => log::info!("ACME event: {:?}", ok), + Err(err) => log::error!("ACME error: {:?}", err), } - }); + } + }); - Ok(Some(acceptor)) - } else { - Ok(None) - } + Ok(acceptor) } async fn clock(index: extract::Extension>) -> impl IntoResponse { @@ -345,7 +340,7 @@ impl Server { } async fn block( - extract::Path(hash): extract::Path, + extract::Path(hash): extract::Path, index: extract::Extension>, ) -> impl IntoResponse { match index.block_with_hash(hash) { @@ -418,12 +413,46 @@ impl Server { ) } - async fn search_by_query(search: extract::Query) -> Redirect { - Redirect::to(&format!("/ordinal/{}", search.query)) + async fn search_by_query( + index: extract::Extension>, + search: extract::Query, + ) -> impl IntoResponse { + Self::search(&index.0, &search.0.query).await + } + + async fn search_by_path( + index: extract::Extension>, + search: extract::Path, + ) -> impl IntoResponse { + Self::search(&index.0, &search.0.query).await } - async fn search_by_path(search: extract::Path) -> Redirect { - Redirect::to(&format!("/ordinal/{}", search.query)) + async fn search(index: &Index, query: &str) -> Response { + match Self::search_inner(index, query) { + Ok(redirect) => redirect.into_response(), + Err(err) => (StatusCode::BAD_REQUEST, Html(err.to_string())).into_response(), + } + } + + fn search_inner(index: &Index, query: &str) -> Result { + lazy_static! { + static ref HASH: Regex = Regex::new(r"^[[:xdigit:]]{64}$").unwrap(); + static ref OUTPOINT: Regex = Regex::new(r"^[[:xdigit:]]{64}:\d+$").unwrap(); + } + + let query = query.trim(); + + if HASH.is_match(query) { + if index.block_header(query.parse()?)?.is_some() { + Ok(Redirect::to(&format!("/block/{query}"))) + } else { + Ok(Redirect::to(&format!("/tx/{query}"))) + } + } else if OUTPOINT.is_match(query) { + Ok(Redirect::to(&format!("/output/{query}"))) + } else { + Ok(Redirect::to(&format!("/ordinal/{query}"))) + } } async fn favicon() -> impl IntoResponse { @@ -486,13 +515,13 @@ impl Server { #[cfg(test)] mod tests { - use {super::*, std::net::TcpListener, tempfile::TempDir}; + use {super::*, reqwest::Url, std::net::TcpListener, tempfile::TempDir}; struct TestServer { bitcoin_rpc_server: BitcoinRpcServerHandle, index: Arc, ord_server_handle: Handle, - port: u16, + url: Url, #[allow(unused)] tempdir: TempDir, } @@ -513,6 +542,8 @@ mod tests { .unwrap() .port(); + let url = Url::parse(&format!("http://127.0.0.1:{port}")).unwrap(); + let (options, server) = parse_server_args(&format!( "ord --chain regtest --rpc-url {} --cookie-file {} --data-dir {} server --http-port {} --address 127.0.0.1", bitcoin_rpc_server.url(), @@ -547,8 +578,8 @@ mod tests { bitcoin_rpc_server, index, ord_server_handle, - port, tempdir, + url, } } @@ -557,8 +588,8 @@ mod tests { reqwest::blocking::get(self.join_url(url)).unwrap() } - fn join_url(&self, url: &str) -> String { - format!("http://127.0.0.1:{}/{url}", self.port) + fn join_url(&self, url: &str) -> Url { + self.url.join(url).unwrap() } fn assert_response(&self, path: &str, status: StatusCode, expected_response: &str) { @@ -572,6 +603,19 @@ mod tests { assert_eq!(response.status(), status); assert_regex_match!(response.text().unwrap(), regex); } + + fn assert_redirect(&self, path: &str, location: &str) { + let response = reqwest::blocking::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap() + .get(self.join_url(path)) + .send() + .unwrap(); + + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers().get(header::LOCATION).unwrap(), location); + } } impl Drop for TestServer { @@ -749,102 +793,75 @@ mod tests { #[test] fn bounties_redirects_to_docs_site() { - let test_server = TestServer::new(); - - let response = reqwest::blocking::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build() - .unwrap() - .get(test_server.join_url("bounties")) - .send() - .unwrap(); - - assert_eq!(response.status(), StatusCode::SEE_OTHER); - assert_eq!( - response.headers().get(header::LOCATION).unwrap(), - "https://docs.ordinals.com/bounties/" - ); + TestServer::new().assert_redirect("/bounties", "https://docs.ordinals.com/bounties/"); } #[test] fn faq_redirects_to_docs_site() { - let test_server = TestServer::new(); - - let response = reqwest::blocking::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build() - .unwrap() - .get(test_server.join_url("faq")) - .send() - .unwrap(); - - assert_eq!(response.status(), StatusCode::SEE_OTHER); - assert_eq!( - response.headers().get(header::LOCATION).unwrap(), - "https://docs.ordinals.com/faq/" - ); + TestServer::new().assert_redirect("/faq", "https://docs.ordinals.com/faq/"); } #[test] fn search_by_query_returns_ordinal() { - let test_server = TestServer::new(); - - let response = reqwest::blocking::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build() - .unwrap() - .get(test_server.join_url("search?query=0")) - .send() - .unwrap(); + TestServer::new().assert_redirect("/search?query=0", "/ordinal/0"); + } - assert_eq!(response.status(), StatusCode::SEE_OTHER); - assert_eq!( - response.headers().get(header::LOCATION).unwrap(), - "/ordinal/0" - ); + #[test] + fn search_is_whitespace_insensitive() { + TestServer::new().assert_redirect("/search/ 0 ", "/ordinal/0"); } #[test] fn search_by_path_returns_ordinal() { - let test_server = TestServer::new(); + TestServer::new().assert_redirect("/search/0", "/ordinal/0"); + } - let response = reqwest::blocking::Client::builder() - .redirect(reqwest::redirect::Policy::none()) - .build() - .unwrap() - .get(test_server.join_url("search/0")) - .send() - .unwrap(); + #[test] + fn search_for_blockhash_returns_block() { + TestServer::new().assert_redirect( + "/search/000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", + "/block/000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", + ); + } - assert_eq!(response.status(), StatusCode::SEE_OTHER); - assert_eq!( - response.headers().get(header::LOCATION).unwrap(), - "/ordinal/0" + #[test] + fn search_for_txid_returns_transaction() { + TestServer::new().assert_redirect( + "/search/0000000000000000000000000000000000000000000000000000000000000000", + "/tx/0000000000000000000000000000000000000000000000000000000000000000", + ); + } + + #[test] + fn search_for_outpoint_returns_output() { + TestServer::new().assert_redirect( + "/search/0000000000000000000000000000000000000000000000000000000000000000:0", + "/output/0000000000000000000000000000000000000000000000000000000000000000:0", ); } #[test] fn status() { - TestServer::new().assert_response("status", StatusCode::OK, "OK"); + TestServer::new().assert_response("/status", StatusCode::OK, "OK"); } #[test] fn height_endpoint() { - TestServer::new().assert_response("height", StatusCode::OK, "0"); + TestServer::new().assert_response("/height", StatusCode::OK, "0"); } #[test] fn height_updates() { let test_server = TestServer::new(); - let response = test_server.get("height"); + let response = test_server.get("/height"); assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.text().unwrap(), "0"); test_server.bitcoin_rpc_server.mine_blocks(1); - let response = test_server.get("height"); + let response = test_server.get("/height"); assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.text().unwrap(), "1"); @@ -853,7 +870,7 @@ mod tests { #[test] fn range_end_before_range_start_returns_400() { TestServer::new().assert_response( - "range/1/0/", + "/range/1/0/", StatusCode::BAD_REQUEST, "Range Start Greater Than Range End", ); @@ -862,7 +879,7 @@ mod tests { #[test] fn invalid_range_start_returns_400() { TestServer::new().assert_response( - "range/=/0", + "/range/=/0", StatusCode::BAD_REQUEST, "Invalid URL: invalid digit found in string", ); @@ -871,7 +888,7 @@ mod tests { #[test] fn invalid_range_end_returns_400() { TestServer::new().assert_response( - "range/0/=", + "/range/0/=", StatusCode::BAD_REQUEST, "Invalid URL: invalid digit found in string", ); @@ -879,13 +896,13 @@ mod tests { #[test] fn empty_range_returns_400() { - TestServer::new().assert_response("range/0/0", StatusCode::BAD_REQUEST, "Empty Range"); + TestServer::new().assert_response("/range/0/0", StatusCode::BAD_REQUEST, "Empty Range"); } #[test] fn range() { TestServer::new().assert_response_regex( - "range/0/1", + "/range/0/1", StatusCode::OK, r".*Ordinal range \[0,1\).*

Ordinal range \[0,1\)

@@ -896,13 +913,13 @@ mod tests { } #[test] fn ordinal_number() { - TestServer::new().assert_response_regex("ordinal/0", StatusCode::OK, ".*

Ordinal 0

.*"); + TestServer::new().assert_response_regex("/ordinal/0", StatusCode::OK, ".*

Ordinal 0

.*"); } #[test] fn ordinal_decimal() { TestServer::new().assert_response_regex( - "ordinal/0.0", + "/ordinal/0.0", StatusCode::OK, ".*

Ordinal 0

.*", ); @@ -911,7 +928,7 @@ mod tests { #[test] fn ordinal_degree() { TestServer::new().assert_response_regex( - "ordinal/0°0′0″0‴", + "/ordinal/0°0′0″0‴", StatusCode::OK, ".*

Ordinal 0

.*", ); @@ -920,7 +937,7 @@ mod tests { #[test] fn ordinal_name() { TestServer::new().assert_response_regex( - "ordinal/nvtdijuwxlp", + "/ordinal/nvtdijuwxlp", StatusCode::OK, ".*

Ordinal 0

.*", ); @@ -929,7 +946,7 @@ mod tests { #[test] fn ordinal() { TestServer::new().assert_response_regex( - "ordinal/0", + "/ordinal/0", StatusCode::OK, ".*0°0′0″0‴.*

Ordinal 0

.*", ); @@ -938,7 +955,7 @@ mod tests { #[test] fn ordinal_out_of_range() { TestServer::new().assert_response( - "ordinal/2099999997690000", + "/ordinal/2099999997690000", StatusCode::BAD_REQUEST, "Invalid URL: Invalid ordinal", ); @@ -947,7 +964,7 @@ mod tests { #[test] fn invalid_outpoint_hash_returns_400() { TestServer::new().assert_response( - "output/foo:0", + "/output/foo:0", StatusCode::BAD_REQUEST, "Invalid URL: error parsing TXID: odd hex string length 3", ); diff --git a/src/test.rs b/src/test.rs index a8cfc09edf..9e8afe7fb1 100644 --- a/src/test.rs +++ b/src/test.rs @@ -7,7 +7,7 @@ use { std::collections::BTreeMap, }; -pub(crate) use {bitcoincore_rpc::RpcApi, regex::Regex, tempfile::TempDir}; +pub(crate) use {bitcoincore_rpc::RpcApi, tempfile::TempDir}; macro_rules! assert_regex_match { ($string:expr, $pattern:expr $(,)?) => { @@ -88,6 +88,13 @@ pub trait BitcoinRpc { #[rpc(name = "getblockhash")] fn getblockhash(&self, height: usize) -> Result; + #[rpc(name = "getblockheader")] + fn getblockheader( + &self, + blockhash: BlockHash, + verbose: bool, + ) -> Result; + #[rpc(name = "getblock")] fn getblock(&self, blockhash: BlockHash, verbosity: u64) -> Result; @@ -109,6 +116,22 @@ impl BitcoinRpc for BitcoinRpcServer { } } + fn getblockheader( + &self, + block_hash: BlockHash, + verbose: bool, + ) -> Result { + assert!(!verbose); + match self.blocks.lock().unwrap().blocks.get(&block_hash) { + Some(block) => Ok(hex::encode(bitcoin::consensus::encode::serialize( + &block.header, + ))), + None => Err(jsonrpc_core::Error::new( + jsonrpc_core::types::error::ErrorCode::ServerError(-8), + )), + } + } + fn getblock(&self, block_hash: BlockHash, verbosity: u64) -> Result { assert_eq!(verbosity, 0, "Verbosity level {verbosity} is unsupported"); match self.blocks.lock().unwrap().blocks.get(&block_hash) {