diff --git a/Cargo.lock b/Cargo.lock index 4835796711..d0cf7811be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1971,6 +1971,7 @@ dependencies = [ "http", "lazy_static", "log", + "mime", "mime_guess", "nix", "pretty_assertions", diff --git a/Cargo.toml b/Cargo.toml index d357261423..40c71674a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,15 +29,17 @@ html-escaper = "0.2.0" http = "0.2.6" lazy_static = "1.4.0" log = "0.4.14" +mime = "0.3.16" mime_guess = "2.0.4" rayon = "1.5.1" redb = "0.7.0" regex = "1.6.0" +reqwest = { version = "0.11.10", features = ["blocking"] } rust-embed = "6.4.0" rustls = "0.20.6" rustls-acme = { version = "0.5.0", features = ["axum"] } serde = { version = "1.0.137", features = ["derive"] } -serde_json = "1.0.81" +serde_json = { version = "1.0.81", features = ["arbitrary_precision"] } sys-info = "0.9.1" tokio = { version = "1.17.0", features = ["rt-multi-thread"] } tokio-stream = "0.1.9" @@ -49,7 +51,6 @@ tower-http = { version = "0.3.3", features = ["cors"] } executable-path = "1.0.0" nix = "0.25.0" pretty_assertions = "1.2.1" -reqwest = { version = "0.11.10", features = ["blocking"] } tempfile = "3.2.0" test-bitcoincore-rpc = { path = "test-bitcoincore-rpc" } unindent = "0.1.7" diff --git a/justfile b/justfile index a0e3ea4df4..a32fe725cb 100644 --- a/justfile +++ b/justfile @@ -1,3 +1,6 @@ +watch +args='test': + cargo watch --clear --exec '{{args}}' + ci: clippy forbid cargo fmt -- --check cargo test --all @@ -14,9 +17,6 @@ clippy: bench: cargo criterion -watch +args='test': - cargo watch --clear --exec '{{args}}' - install-dev-deps: cargo install cargo-criterion diff --git a/src/index.rs b/src/index.rs index f571ea9267..6901f87cf5 100644 --- a/src/index.rs +++ b/src/index.rs @@ -2,7 +2,7 @@ use { super::*, bitcoin::consensus::encode::deserialize, bitcoin::BlockHeader, - bitcoincore_rpc::{json::GetBlockHeaderResult, Auth, Client, RpcApi}, + bitcoincore_rpc::{json::GetBlockHeaderResult, Auth, Client}, rayon::iter::{IntoParallelRefIterator, ParallelIterator}, redb::WriteStrategy, std::sync::atomic::{AtomicBool, Ordering}, @@ -10,12 +10,13 @@ use { mod rtx; +const HASH_TO_RUNE: TableDefinition<[u8; 32], str> = TableDefinition::new("HASH_TO_RUNE"); const HEIGHT_TO_HASH: TableDefinition = TableDefinition::new("HEIGHT_TO_HASH"); +const ORDINAL_TO_SATPOINT: TableDefinition = + TableDefinition::new("ORDINAL_TO_SATPOINT"); const OUTPOINT_TO_ORDINAL_RANGES: TableDefinition<[u8; 36], [u8]> = TableDefinition::new("OUTPOINT_TO_ORDINAL_RANGES"); const STATISTICS: TableDefinition = TableDefinition::new("STATISTICS"); -const ORDINAL_TO_SATPOINT: TableDefinition = - TableDefinition::new("ORDINAL_TO_SATPOINT"); fn encode_outpoint(outpoint: OutPoint) -> [u8; 36] { let mut array = [0; 36]; @@ -124,6 +125,7 @@ impl Index { tx }; + tx.open_table(HASH_TO_RUNE)?; tx.open_table(HEIGHT_TO_HASH)?; tx.open_table(ORDINAL_TO_SATPOINT)?; tx.open_table(OUTPOINT_TO_ORDINAL_RANGES)?; @@ -534,6 +536,30 @@ impl Index { ) } + pub(crate) fn rune(&self, hash: sha256::Hash) -> Result> { + Ok( + self + .database + .begin_read()? + .open_table(HASH_TO_RUNE)? + .get(hash.as_inner())? + .map(serde_json::from_str) + .transpose()?, + ) + } + + pub(crate) fn insert_rune(&self, rune: &Rune) -> Result<(bool, sha256::Hash)> { + let json = serde_json::to_string(rune)?; + let hash = sha256::Hash::hash(json.as_ref()); + let wtx = self.database.begin_write()?; + let created = wtx + .open_table(HASH_TO_RUNE)? + .insert(hash.as_inner(), &json)? + .is_none(); + wtx.commit()?; + Ok((created, hash)) + } + pub(crate) fn find(&self, ordinal: u64) -> Result> { if self.height()? < Ordinal(ordinal).height() { return Ok(None); diff --git a/src/main.rs b/src/main.rs index a9cd311665..87eb41fb2b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,20 +13,20 @@ use { options::Options, ordinal::Ordinal, rarity::Rarity, + rune::Rune, sat_point::SatPoint, subcommand::Subcommand, tally::Tally, }, anyhow::{anyhow, bail, Context, Error}, - axum::{extract, http::StatusCode, response::Html, response::IntoResponse, routing::get, Router}, - axum_server::Handle, bitcoin::{ blockdata::constants::COIN_VALUE, consensus::{Decodable, Encodable}, hash_types::BlockHash, - hashes::Hash, + hashes::{sha256, Hash}, Address, Block, Network, OutPoint, Transaction, TxOut, Txid, }, + bitcoincore_rpc::RpcApi, chrono::{DateTime, NaiveDateTime, Utc}, clap::Parser, derive_more::{Display, FromStr}, @@ -72,6 +72,7 @@ mod index; mod options; mod ordinal; mod rarity; +mod rune; mod sat_point; mod subcommand; mod tally; @@ -84,7 +85,7 @@ const SUBSIDY_HALVING_INTERVAL: u64 = const CYCLE_EPOCHS: u64 = 6; static INTERRUPTS: AtomicU64 = AtomicU64::new(0); -static LISTENERS: Mutex> = Mutex::new(Vec::new()); +static LISTENERS: Mutex> = Mutex::new(Vec::new()); fn main() { env_logger::init(); diff --git a/src/options.rs b/src/options.rs index 352127f87f..eae6097e3c 100644 --- a/src/options.rs +++ b/src/options.rs @@ -128,6 +128,14 @@ impl Options { Client::new(&rpc_url, Auth::CookieFile(cookie_file)) .context("Failed to connect to Bitcoin Core RPC at {rpc_url}") } + + pub(crate) fn bitcoin_rpc_client_mainnet_forbidden(&self, command: &str) -> Result { + let client = self.bitcoin_rpc_client()?; + if self.chain.network() == Network::Bitcoin || client.get_blockchain_info()?.chain == "main" { + bail!("`{command}` is unstable and not yet supported on mainnet."); + } + Ok(client) + } } #[cfg(test)] diff --git a/src/rune.rs b/src/rune.rs new file mode 100644 index 0000000000..fc63383e9d --- /dev/null +++ b/src/rune.rs @@ -0,0 +1,8 @@ +use super::*; + +#[derive(Debug, Serialize, Deserialize)] +pub(crate) struct Rune { + pub(crate) name: String, + pub(crate) network: Network, + pub(crate) ordinal: Ordinal, +} diff --git a/src/subcommand.rs b/src/subcommand.rs index 9865bae3f9..5710d7cd22 100644 --- a/src/subcommand.rs +++ b/src/subcommand.rs @@ -7,6 +7,7 @@ mod info; mod list; mod parse; mod range; +mod rune; mod server; mod supply; mod traits; @@ -21,6 +22,8 @@ pub(crate) enum Subcommand { List(list::List), Parse(parse::Parse), Range(range::Range), + #[clap(subcommand)] + Rune(rune::Rune), Server(server::Server), Supply, Traits(traits::Traits), @@ -38,9 +41,10 @@ impl Subcommand { Self::List(list) => list.run(options), Self::Parse(parse) => parse.run(), Self::Range(range) => range.run(), + Self::Rune(rune) => rune.run(options), Self::Server(server) => { let index = Arc::new(Index::open(&options)?); - let handle = Handle::new(); + let handle = axum_server::Handle::new(); LISTENERS.lock().unwrap().push(handle.clone()); server.run(options, index, handle) } diff --git a/src/subcommand/rune.rs b/src/subcommand/rune.rs new file mode 100644 index 0000000000..10cf3d7782 --- /dev/null +++ b/src/subcommand/rune.rs @@ -0,0 +1,16 @@ +use super::*; + +mod publish; + +#[derive(Debug, Parser)] +pub(crate) enum Rune { + Publish(publish::Publish), +} + +impl Rune { + pub(crate) fn run(self, options: Options) -> Result<()> { + match self { + Self::Publish(publish) => publish.run(options), + } + } +} diff --git a/src/subcommand/rune/publish.rs b/src/subcommand/rune/publish.rs new file mode 100644 index 0000000000..6200ac3749 --- /dev/null +++ b/src/subcommand/rune/publish.rs @@ -0,0 +1,51 @@ +use {super::*, reqwest::Url}; + +#[derive(Debug, Parser)] +pub(crate) struct Publish { + #[clap(long, help = "Give rune .")] + name: String, + #[clap(long, help = "Inscribe rune on .")] + ordinal: Ordinal, + #[clap( + long, + default_value = "https://ordinals.com/", + help = "Publish rune to ." + )] + publish_url: Url, +} + +impl Publish { + pub(crate) fn run(self, options: Options) -> Result { + options.bitcoin_rpc_client_mainnet_forbidden("ord rune publish")?; + + let rune = crate::Rune { + network: options.chain.network(), + name: self.name, + ordinal: self.ordinal, + }; + + let json = serde_json::to_string(&rune)?; + + let url = self.publish_url.join("rune")?; + + let response = reqwest::blocking::Client::new() + .put(url.clone()) + .header( + reqwest::header::CONTENT_TYPE, + mime::APPLICATION_JSON.as_ref(), + ) + .body(json) + .send()?; + + let status = response.status(); + + if !status.is_success() { + bail!("Failed to post rune to `{}`:\n{}", url, response.text()?) + } + + eprintln!("Rune published: {}", response.status()); + println!("{}", response.text()?); + + Ok(()) + } +} diff --git a/src/subcommand/server.rs b/src/subcommand/server.rs index 5171794d2a..07f4772d4b 100644 --- a/src/subcommand/server.rs +++ b/src/subcommand/server.rs @@ -5,14 +5,19 @@ use { deserialize_ordinal_from_str::DeserializeOrdinalFromStr, templates::{ block::BlockHtml, clock::ClockSvg, home::HomeHtml, ordinal::OrdinalHtml, output::OutputHtml, - range::RangeHtml, rare::RareTxt, transaction::TransactionHtml, Content, PageHtml, + range::RangeHtml, rare::RareTxt, rune::RuneHtml, transaction::TransactionHtml, Content, + PageHtml, }, }, axum::{ body, - http::header, - response::{Redirect, Response}, + extract::{Extension, Json, Path, Query}, + http::{header, StatusCode}, + response::{IntoResponse, Redirect, Response}, + routing::{get, put}, + Router, }, + axum_server::Handle, lazy_static::lazy_static, rust_embed::RustEmbed, rustls_acme::{ @@ -31,37 +36,36 @@ mod deserialize_ordinal_from_str; mod templates; enum ServerError { - InternalError(Error), + Internal(Error), NotFound(String), + UnprocessableEntity(String), + BadRequest(String), } +type ServerResult = Result; + impl IntoResponse for ServerError { fn into_response(self) -> Response { match self { - Self::InternalError(error) => { + Self::Internal(error) => { eprintln!("error serving request: {error}"); ( StatusCode::INTERNAL_SERVER_ERROR, - Html( - StatusCode::INTERNAL_SERVER_ERROR - .canonical_reason() - .unwrap_or_default(), - ), + StatusCode::INTERNAL_SERVER_ERROR + .canonical_reason() + .unwrap_or_default(), ) .into_response() } - Self::NotFound(message) => (StatusCode::NOT_FOUND, Html(message)).into_response(), + Self::NotFound(message) => (StatusCode::NOT_FOUND, message).into_response(), + Self::UnprocessableEntity(message) => { + (StatusCode::UNPROCESSABLE_ENTITY, message).into_response() + } + Self::BadRequest(message) => (StatusCode::BAD_REQUEST, message).into_response(), } } } -fn html_status(status_code: StatusCode) -> (StatusCode, Html<&'static str>) { - ( - status_code, - Html(status_code.canonical_reason().unwrap_or_default()), - ) -} - #[derive(Deserialize)] struct Search { query: String, @@ -145,13 +149,15 @@ impl Server { .route("/output/:output", get(Self::output)) .route("/range/:start/:end", get(Self::range)) .route("/rare.txt", get(Self::rare_txt)) + .route("/rune/:hash", get(Self::rune_get)) + .route("/rune", put(Self::rune_put)) .route("/search", get(Self::search_by_query)) .route("/search/:query", get(Self::search_by_path)) .route("/static/*path", get(Self::static_asset)) .route("/status", get(Self::status)) .route("/tx/:txid", get(Self::transaction)) - .layer(extract::Extension(index)) - .layer(extract::Extension(options.chain.network())) + .layer(Extension(index)) + .layer(Extension(options.chain.network())) .layer( CorsLayer::new() .allow_methods([http::Method::GET]) @@ -273,42 +279,40 @@ impl Server { Ok(acceptor) } - async fn clock(index: extract::Extension>) -> impl IntoResponse { - match index.height() { - Ok(height) => ClockSvg::new(height).into_response(), - Err(err) => { - eprintln!("Failed to retrieve height from index: {err}"); - html_status(StatusCode::INTERNAL_SERVER_ERROR).into_response() - } - } + async fn clock(Extension(index): Extension>) -> ServerResult { + Ok(ClockSvg::new(index.height().map_err(|err| { + ServerError::Internal(anyhow!("Failed to retrieve height from index: {err}")) + })?)) } async fn ordinal( - index: extract::Extension>, - extract::Path(DeserializeOrdinalFromStr(ordinal)): extract::Path, - ) -> impl IntoResponse { - match index.blocktime(ordinal.height()) { - Ok(blocktime) => OrdinalHtml { ordinal, blocktime }.page().into_response(), - Err(err) => { - eprintln!("Failed to retrieve blocktime from index: {err}"); - html_status(StatusCode::INTERNAL_SERVER_ERROR).into_response() + Extension(index): Extension>, + Path(DeserializeOrdinalFromStr(ordinal)): Path, + ) -> ServerResult { + Ok( + OrdinalHtml { + ordinal, + blocktime: index.blocktime(ordinal.height()).map_err(|err| { + ServerError::Internal(anyhow!("Failed to retrieve blocktime from index: {err}")) + })?, } - } + .page(), + ) } async fn output( - index: extract::Extension>, - extract::Path(outpoint): extract::Path, - network: extract::Extension, - ) -> Result { + Extension(index): Extension>, + Path(outpoint): Path, + Extension(network): Extension, + ) -> ServerResult { let list = index .list(outpoint) - .map_err(ServerError::InternalError)? + .map_err(ServerError::Internal)? .ok_or_else(|| ServerError::NotFound(format!("Output {outpoint} unknown")))?; let output = index .transaction(outpoint.txid) - .map_err(ServerError::InternalError)? + .map_err(ServerError::Internal)? .ok_or_else(|| ServerError::NotFound(format!("Output {outpoint} unknown")))? .output .into_iter() @@ -319,7 +323,7 @@ impl Server { OutputHtml { outpoint, list, - network: network.0, + network, output, } .page(), @@ -327,113 +331,121 @@ impl Server { } async fn range( - extract::Path((DeserializeOrdinalFromStr(start), DeserializeOrdinalFromStr(end))): extract::Path< - (DeserializeOrdinalFromStr, DeserializeOrdinalFromStr), - >, - ) -> impl IntoResponse { + Path((DeserializeOrdinalFromStr(start), DeserializeOrdinalFromStr(end))): Path<( + DeserializeOrdinalFromStr, + DeserializeOrdinalFromStr, + )>, + ) -> ServerResult { match start.cmp(&end) { - Ordering::Equal => (StatusCode::BAD_REQUEST, Html("Empty Range".to_string())).into_response(), - Ordering::Greater => ( - StatusCode::BAD_REQUEST, - Html("Range Start Greater Than Range End".to_string()), - ) - .into_response(), - Ordering::Less => RangeHtml { start, end }.page().into_response(), + Ordering::Equal => Err(ServerError::BadRequest("Empty Range".to_string())), + Ordering::Greater => Err(ServerError::BadRequest( + "Range Start Greater Than Range End".to_string(), + )), + Ordering::Less => Ok(RangeHtml { start, end }.page()), } } - async fn rare_txt(index: extract::Extension>) -> impl IntoResponse { - match index.rare_ordinal_satpoints() { - Ok(rare_ordinal_satpoints) => RareTxt(rare_ordinal_satpoints).into_response(), - Err(err) => { - eprintln!("Error getting rare ordinal satpoints: {err}"); - html_status(StatusCode::INTERNAL_SERVER_ERROR).into_response() - } - } + async fn rare_txt(Extension(index): Extension>) -> ServerResult { + Ok(RareTxt(index.rare_ordinal_satpoints().map_err(|err| { + ServerError::Internal(anyhow!("Error getting rare ordinal satpoints: {err}")) + })?)) } - async fn home(index: extract::Extension>) -> impl IntoResponse { - match index.blocks(100) { - Ok(blocks) => HomeHtml::new(blocks).page().into_response(), - Err(err) => { - eprintln!("Error getting blocks: {err}"); - html_status(StatusCode::INTERNAL_SERVER_ERROR).into_response() - } + async fn rune_put( + Extension(index): Extension>, + Extension(network): Extension, + Json(rune): Json, + ) -> ServerResult<(StatusCode, String)> { + if rune.network != network { + return Err(ServerError::UnprocessableEntity(format!( + "This ord instance only accepts {network} runes for publication" + ))); } + let (created, hash) = index.insert_rune(&rune).map_err(ServerError::Internal)?; + Ok(( + if created { + StatusCode::CREATED + } else { + StatusCode::OK + }, + hash.to_string(), + )) } - async fn block( - extract::Path(hash): extract::Path, - index: extract::Extension>, - ) -> impl IntoResponse { - let info = match index.block_header_info(hash) { - Ok(Some(info)) => info, - Ok(None) => { - return ( - StatusCode::NOT_FOUND, - Html( - StatusCode::NOT_FOUND - .canonical_reason() - .unwrap_or_default() - .to_string(), - ), - ) - .into_response() - } - Err(error) => { - eprintln!("Error serving request for block with hash {hash}: {error}"); - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Html( - StatusCode::INTERNAL_SERVER_ERROR - .canonical_reason() - .unwrap_or_default() - .to_string(), - ), - ) - .into_response(); + async fn rune_get( + Extension(index): Extension>, + Path(hash): Path, + ) -> ServerResult { + Ok( + RuneHtml { + hash, + rune: index + .rune(hash) + .map_err(ServerError::Internal)? + .ok_or_else(|| ServerError::NotFound(format!("Rune {hash} unknown")))?, } - }; + .page(), + ) + } - match index.block_with_hash(hash) { - Ok(Some(block)) => BlockHtml::new(block, Height(info.height as u64)) - .page() - .into_response(), - Ok(None) => ( - StatusCode::NOT_FOUND, - Html( - StatusCode::NOT_FOUND - .canonical_reason() - .unwrap_or_default() - .to_string(), - ), + async fn home(Extension(index): Extension>) -> ServerResult { + Ok( + HomeHtml::new( + index + .blocks(100) + .map_err(|err| ServerError::Internal(anyhow!("Error getting blocks: {err}")))?, ) - .into_response(), - Err(error) => { - eprintln!("Error serving request for block with hash {hash}: {error}"); - html_status(StatusCode::INTERNAL_SERVER_ERROR).into_response() - } - } + .page(), + ) + } + + async fn block( + Path(hash): Path, + index: Extension>, + ) -> ServerResult { + let info = index + .block_header_info(hash) + .map_err(|err| { + ServerError::Internal(anyhow!( + "Error serving request for block with hash {hash}: {err}" + )) + })? + .ok_or_else(|| ServerError::NotFound(format!("Block {hash} unknown")))?; + + let block = index + .block_with_hash(hash) + .map_err(|err| { + ServerError::Internal(anyhow!( + "Error serving request for block with hash {hash}: {err}" + )) + })? + .ok_or_else(|| ServerError::NotFound(format!("Block {hash} unknown")))?; + + Ok(BlockHtml::new(block, Height(info.height as u64)).page()) } async fn transaction( - index: extract::Extension>, - network: extract::Extension, - extract::Path(txid): extract::Path, - ) -> impl IntoResponse { - match index.transaction(txid) { - Ok(Some(transaction)) => TransactionHtml::new(transaction, network.0) - .page() - .into_response(), - Ok(None) => html_status(StatusCode::NOT_FOUND).into_response(), - Err(error) => { - eprintln!("Error serving request for transaction with txid {txid}: {error}"); - html_status(StatusCode::INTERNAL_SERVER_ERROR).into_response() - } - } + Extension(index): Extension>, + Extension(network): Extension, + Path(txid): Path, + ) -> ServerResult { + Ok( + TransactionHtml::new( + index + .transaction(txid) + .map_err(|err| { + ServerError::Internal(anyhow!( + "Error serving request for transaction {txid}: {err}" + )) + })? + .ok_or_else(|| ServerError::NotFound(format!("Transaction {txid} unknown")))?, + network, + ) + .page(), + ) } - async fn status(index: extract::Extension>) -> impl IntoResponse { + async fn status(Extension(index): Extension>) -> (StatusCode, &'static str) { if index.is_reorged() { ( StatusCode::OK, @@ -448,27 +460,24 @@ impl Server { } async fn search_by_query( - index: extract::Extension>, - search: extract::Query, - ) -> impl IntoResponse { - Self::search(&index.0, &search.0.query).await + Extension(index): Extension>, + Query(search): Query, + ) -> ServerResult { + Self::search(&index, &search.query).await } async fn search_by_path( - index: extract::Extension>, - search: extract::Path, - ) -> impl IntoResponse { - Self::search(&index.0, &search.0.query).await + Extension(index): Extension>, + Path(search): Path, + ) -> ServerResult { + Self::search(&index, &search.query).await } - 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(), - } + async fn search(index: &Index, query: &str) -> ServerResult { + Self::search_inner(index, query) } - fn search_inner(index: &Index, query: &str) -> Result { + fn search_inner(index: &Index, query: &str) -> ServerResult { lazy_static! { static ref HASH: Regex = Regex::new(r"^[[:xdigit:]]{64}$").unwrap(); static ref OUTPOINT: Regex = Regex::new(r"^[[:xdigit:]]{64}:\d+$").unwrap(); @@ -477,7 +486,15 @@ impl Server { let query = query.trim(); if HASH.is_match(query) { - if index.block_header(query.parse()?)?.is_some() { + if index + .block_header(query.parse().unwrap()) + .map_err(|err| { + ServerError::Internal(anyhow!( + "Failed to retrieve block {query} from index: {err}" + )) + })? + .is_some() + { Ok(Redirect::to(&format!("/block/{query}"))) } else { Ok(Redirect::to(&format!("/tx/{query}"))) @@ -489,60 +506,43 @@ impl Server { } } - async fn favicon() -> impl IntoResponse { - Self::static_asset(extract::Path("/favicon.png".to_string())).await + async fn favicon() -> ServerResult { + Self::static_asset(Path("/favicon.png".to_string())).await } - async fn static_asset(extract::Path(path): extract::Path) -> impl IntoResponse { - match StaticAssets::get(if let Some(stripped) = path.strip_prefix('/') { + async fn static_asset(Path(path): Path) -> ServerResult { + let content = StaticAssets::get(if let Some(stripped) = path.strip_prefix('/') { stripped } else { &path - }) { - Some(content) => { - let body = body::boxed(body::Full::from(content.data)); - let mime = mime_guess::from_path(path).first_or_octet_stream(); - Response::builder() - .header(header::CONTENT_TYPE, mime.as_ref()) - .body(body) - .unwrap() - } - None => ( - StatusCode::NOT_FOUND, - Html( - StatusCode::NOT_FOUND - .canonical_reason() - .unwrap_or_default() - .to_string(), - ), - ) - .into_response(), - } + }) + .ok_or_else(|| ServerError::NotFound(format!("Asset {path} unknown")))?; + let body = body::boxed(body::Full::from(content.data)); + let mime = mime_guess::from_path(path).first_or_octet_stream(); + Ok( + Response::builder() + .header(header::CONTENT_TYPE, mime.as_ref()) + .body(body) + .unwrap(), + ) } - async fn height(index: extract::Extension>) -> impl IntoResponse { - match index.height() { - Ok(height) => (StatusCode::OK, Html(format!("{}", height))), - Err(err) => { - eprintln!("Failed to retrieve height from index: {err}"); - ( - StatusCode::INTERNAL_SERVER_ERROR, - Html( - StatusCode::INTERNAL_SERVER_ERROR - .canonical_reason() - .unwrap_or_default() - .to_string(), - ), - ) - } - } + async fn height(Extension(index): Extension>) -> ServerResult { + Ok( + index + .height() + .map_err(|err| { + ServerError::Internal(anyhow!("Failed to retrieve height from index: {err}")) + })? + .to_string(), + ) } - async fn faq() -> impl IntoResponse { + async fn faq() -> Redirect { Redirect::to("https://docs.ordinals.com/faq/") } - async fn bounties() -> impl IntoResponse { + async fn bounties() -> Redirect { Redirect::to("https://docs.ordinals.com/bounty/") } } @@ -617,11 +617,37 @@ mod tests { } } - fn get(&self, url: &str) -> reqwest::blocking::Response { + fn get(&self, path: &str) -> reqwest::blocking::Response { + if let Err(error) = self.index.index() { + log::error!("{error}"); + } + reqwest::blocking::get(self.join_url(path)).unwrap() + } + + fn put(&self, path: &str, content_type: &str, body: &str) -> reqwest::blocking::Response { if let Err(error) = self.index.index() { log::error!("{error}"); } - reqwest::blocking::get(self.join_url(url)).unwrap() + + reqwest::blocking::Client::new() + .put(self.join_url(path)) + .header(reqwest::header::CONTENT_TYPE, content_type) + .body(body.to_owned()) + .send() + .unwrap() + } + + fn assert_put( + &self, + path: &str, + content_type: &str, + body: &str, + expected_status: StatusCode, + expected_response: &str, + ) { + let response = self.put(path, content_type, body); + assert_eq!(response.status(), expected_status); + assert_eq!(response.text().unwrap(), expected_response); } fn join_url(&self, url: &str) -> Url { @@ -1085,7 +1111,7 @@ mod tests { TestServer::new().assert_response( "/block/467a86f0642b1d284376d13a98ef58310caa49502b0f9a560ee222e0a122fe16", StatusCode::NOT_FOUND, - "Not Found", + "Block 467a86f0642b1d284376d13a98ef58310caa49502b0f9a560ee222e0a122fe16 unknown", ); } @@ -1215,4 +1241,77 @@ mod tests { ", ); } + + #[test] + fn rune_not_found() { + TestServer::new().assert_response( + "/rune/4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b", + StatusCode::NOT_FOUND, + "Rune 4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b unknown", + ); + } + + #[test] + fn malformed_runes_are_rejected() { + TestServer::new().assert_put( + "/rune", + "application/json", + "{}", + StatusCode::UNPROCESSABLE_ENTITY, + "Failed to deserialize the JSON body into the target type: missing field `name` at line 1 column 2", + ); + } + + #[test] + fn rune_already_published() { + let test_server = TestServer::new(); + + test_server.assert_put( + "/rune", + "application/json", + r#"{"name": "foo", "network": "regtest", "ordinal": 0}"#, + StatusCode::CREATED, + "8198d907f096767ffe030e08e4d6c86758573a19f895f97b98b49befaadb2e54", + ); + + test_server.assert_put( + "/rune", + "application/json", + r#"{"name": "foo", "network": "regtest", "ordinal": 0}"#, + StatusCode::OK, + "8198d907f096767ffe030e08e4d6c86758573a19f895f97b98b49befaadb2e54", + ); + } + + #[test] + fn rune_hash_is_calculated_from_server_serialization() { + let test_server = TestServer::new(); + + test_server.assert_put( + "/rune", + "application/json", + r#"{"name": "foo", "network": "regtest", "ordinal": 0}"#, + StatusCode::CREATED, + "8198d907f096767ffe030e08e4d6c86758573a19f895f97b98b49befaadb2e54", + ); + + test_server.assert_put( + "/rune", + "application/json", + r#"{"network": "regtest", "name": "foo", "ordinal": 0}"#, + StatusCode::OK, + "8198d907f096767ffe030e08e4d6c86758573a19f895f97b98b49befaadb2e54", + ); + } + + #[test] + fn runes_with_incorrect_network_are_forbidden() { + TestServer::new().assert_put( + "/rune", + "application/json", + r#"{"name": "foo", "network": "testnet", "ordinal": 0}"#, + StatusCode::UNPROCESSABLE_ENTITY, + r#"This ord instance only accepts regtest runes for publication"#, + ); + } } diff --git a/src/subcommand/server/templates.rs b/src/subcommand/server/templates.rs index 2506067a6c..957d447e32 100644 --- a/src/subcommand/server/templates.rs +++ b/src/subcommand/server/templates.rs @@ -11,6 +11,7 @@ pub(crate) mod ordinal; pub(crate) mod output; pub(crate) mod range; pub(crate) mod rare; +pub(crate) mod rune; pub(crate) mod transaction; #[derive(Boilerplate)] diff --git a/src/subcommand/server/templates/rune.rs b/src/subcommand/server/templates/rune.rs new file mode 100644 index 0000000000..4f8d0416d1 --- /dev/null +++ b/src/subcommand/server/templates/rune.rs @@ -0,0 +1,72 @@ +use super::*; + +#[derive(Boilerplate)] +pub(crate) struct RuneHtml { + pub(crate) hash: sha256::Hash, + pub(crate) rune: Rune, +} + +impl Content for RuneHtml { + fn title(&self) -> String { + format!("Rune {}", self.hash) + } +} + +#[cfg(test)] +mod tests { + use {super::*, pretty_assertions::assert_eq, unindent::Unindent}; + + #[test] + fn rune_html_mainnet() { + assert_eq!( + RuneHtml { + rune: Rune { + network: Network::Bitcoin, + name: "foo".into(), + ordinal: Ordinal(0), + }, + hash: "0000000000000000000000000000000000000000000000000000000000000000" + .parse() + .unwrap(), + } + .to_string(), + " +

Rune 0000000000000000000000000000000000000000000000000000000000000000

+
+
hash
0000000000000000000000000000000000000000000000000000000000000000
+
name
foo
+
network
mainnet
+
ordinal
0
+
+ " + .unindent() + ); + } + + #[test] + fn rune_html_othernet() { + assert_eq!( + RuneHtml { + rune: Rune { + network: Network::Testnet, + name: "foo".into(), + ordinal: Ordinal(0), + }, + hash: "0000000000000000000000000000000000000000000000000000000000000000" + .parse() + .unwrap(), + } + .to_string(), + " +

Rune 0000000000000000000000000000000000000000000000000000000000000000

+
+
hash
0000000000000000000000000000000000000000000000000000000000000000
+
name
foo
+
network
testnet
+
ordinal
0
+
+ " + .unindent() + ); + } +} diff --git a/src/subcommand/wallet.rs b/src/subcommand/wallet.rs index 4a9f5000ec..c915895838 100644 --- a/src/subcommand/wallet.rs +++ b/src/subcommand/wallet.rs @@ -1,4 +1,4 @@ -use {super::*, bitcoincore_rpc::RpcApi}; +use super::*; mod identify; mod list; @@ -40,6 +40,3 @@ impl Wallet { } } } - -#[cfg(test)] -mod tests {} diff --git a/src/subcommand/wallet/send.rs b/src/subcommand/wallet/send.rs index 4c9912e9e8..67a0dd77fa 100644 --- a/src/subcommand/wallet/send.rs +++ b/src/subcommand/wallet/send.rs @@ -8,12 +8,7 @@ pub(crate) struct Send { impl Send { pub(crate) fn run(self, options: Options) -> Result { - let client = options.bitcoin_rpc_client()?; - if options.chain.network() == Network::Bitcoin - || client.get_blockchain_info().unwrap().chain == "main" - { - bail!("Send command is not allowed on mainnet yet. Try on regtest/signet/testnet.") - } + let client = options.bitcoin_rpc_client_mainnet_forbidden("ord wallet send")?; let index = Index::open(&options)?; index.index()?; diff --git a/templates/rune.html b/templates/rune.html new file mode 100644 index 0000000000..5faac523ec --- /dev/null +++ b/templates/rune.html @@ -0,0 +1,7 @@ +

Rune {{self.hash}}

+
+
hash
{{self.hash}}
+
name
{{self.rune.name}}
+
network
{% if let Network::Bitcoin = self.rune.network { %}mainnet{% } else { %}{{self.rune.network}}{% } %}
+
ordinal
{{self.rune.ordinal}}
+
diff --git a/tests/command_builder.rs b/tests/command_builder.rs index e9cc5e9e3f..cc1291ad0c 100644 --- a/tests/command_builder.rs +++ b/tests/command_builder.rs @@ -10,8 +10,30 @@ pub(crate) struct Output { pub(crate) stdout: String, } +pub(crate) trait ToArgs { + fn to_args(&self) -> Vec; +} + +impl ToArgs for String { + fn to_args(&self) -> Vec { + self.as_str().to_args() + } +} + +impl ToArgs for &str { + fn to_args(&self) -> Vec { + self.split_whitespace().map(str::to_string).collect() + } +} + +impl ToArgs for [&str; N] { + fn to_args(&self) -> Vec { + self.iter().cloned().map(str::to_string).collect() + } +} + pub(crate) struct CommandBuilder { - args: &'static str, + args: Vec, expected_exit_status: ExpectedExitStatus, expected_stderr: Expected, expected_stdout: Expected, @@ -20,9 +42,9 @@ pub(crate) struct CommandBuilder { } impl CommandBuilder { - pub(crate) fn new(args: &'static str) -> Self { + pub(crate) fn new(args: impl ToArgs) -> Self { Self { - args, + args: args.to_args(), expected_exit_status: ExpectedExitStatus::Code(0), expected_stderr: Expected::String(String::new()), expected_stdout: Expected::String(String::new()), @@ -100,7 +122,7 @@ impl CommandBuilder { .stderr(Stdio::piped()) .env("HOME", self.tempdir.path()) .current_dir(&self.tempdir) - .args(self.args.split_whitespace()); + .args(&self.args); command } diff --git a/tests/lib.rs b/tests/lib.rs index 2c1b766753..f2db24f363 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -5,16 +5,35 @@ use { executable_path::executable_path, nix::{sys::signal::Signal, unistd::Pid}, regex::Regex, + reqwest::{StatusCode, Url}, std::{ fs, + net::TcpListener, os::unix::process::ExitStatusExt, + process::Child, process::{Command, Stdio}, - str, + str, thread, + time::Duration, }, tempfile::TempDir, + test_server::TestServer, unindent::Unindent, }; +macro_rules! assert_regex_match { + ($string:expr, $pattern:expr $(,)?) => { + let regex = Regex::new(&format!("^(?s){}$", $pattern)).unwrap(); + let string = $string; + + if !regex.is_match(string.as_ref()) { + panic!( + "Regex:\n\n{}\n\n…did not match string:\n\n{}", + regex, string + ); + } + }; +} + mod command_builder; mod epochs; mod expected; @@ -24,8 +43,10 @@ mod info; mod list; mod parse; mod range; +mod rune; mod server; mod supply; +mod test_server; mod traits; mod version; mod wallet; diff --git a/tests/rune.rs b/tests/rune.rs new file mode 100644 index 0000000000..01c00594b2 --- /dev/null +++ b/tests/rune.rs @@ -0,0 +1,44 @@ +use super::*; + +#[test] +fn publish_success() { + let rpc_server = test_bitcoincore_rpc::spawn(); + + let ord_server = TestServer::spawn(&rpc_server); + + let url = ord_server.url(); + + CommandBuilder::new(format!( + "--chain regtest rune publish --name foo --ordinal 0 --publish-url {}", + url, + )) + .expected_stderr("Rune published: 201 Created\n") + .expected_stdout("8198d907f096767ffe030e08e4d6c86758573a19f895f97b98b49befaadb2e54\n") + .rpc_server(&rpc_server) + .run(); + + ord_server.assert_response_regex( + "/rune/8198d907f096767ffe030e08e4d6c86758573a19f895f97b98b49befaadb2e54", + StatusCode::OK, + ".*Rune 8198d907f096767ffe030e08e4d6c86758573a19f895f97b98b49befaadb2e54.* +

Rune 8198d907f096767ffe030e08e4d6c86758573a19f895f97b98b49befaadb2e54

+
+
hash
8198d907f096767ffe030e08e4d6c86758573a19f895f97b98b49befaadb2e54
+
name
foo
+
network
regtest
+
ordinal
0
+
+.*", + ); +} + +#[test] +fn publish_forbidden() { + let rpc_server = test_bitcoincore_rpc::spawn(); + + CommandBuilder::new("rune publish --name foo --ordinal 0") + .rpc_server(&rpc_server) + .expected_stderr("error: `ord rune publish` is unstable and not yet supported on mainnet.\n") + .expected_exit_code(1) + .run(); +} diff --git a/tests/test_server.rs b/tests/test_server.rs new file mode 100644 index 0000000000..5e8015e6ae --- /dev/null +++ b/tests/test_server.rs @@ -0,0 +1,65 @@ +use super::*; + +pub(crate) struct TestServer { + child: Child, + port: u16, + #[allow(unused)] + tempdir: TempDir, +} + +impl TestServer { + pub(crate) fn spawn(rpc_server: &test_bitcoincore_rpc::Handle) -> Self { + let tempdir = TempDir::new().unwrap(); + fs::create_dir(tempdir.path().join("regtest")).unwrap(); + fs::write(tempdir.path().join("regtest/.cookie"), "foo:bar").unwrap(); + let port = TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + let child = CommandBuilder::new(format!( + "--chain regtest --rpc-url {} --bitcoin-data-dir {} --data-dir {} server --http-port {port} --address 127.0.0.1", + rpc_server.url(), + tempdir.path().display(), + tempdir.path().display() + )) + .command() + .spawn() + .unwrap(); + + for i in 0.. { + match reqwest::blocking::get(&format!("http://127.0.0.1:{port}/status")) { + Ok(_) => break, + Err(err) => { + if i == 400 { + panic!("Server failed to start: {err}"); + } + } + } + + thread::sleep(Duration::from_millis(25)); + } + + Self { + child, + tempdir, + port, + } + } + + pub(crate) fn url(&self) -> Url { + format!("http://127.0.0.1:{}", self.port).parse().unwrap() + } + + pub(crate) fn assert_response_regex(&self, path: &str, status: StatusCode, regex: &str) { + let response = reqwest::blocking::get(self.url().join(path).unwrap()).unwrap(); + assert_eq!(response.status(), status); + assert_regex_match!(response.text().unwrap(), regex); + } +} + +impl Drop for TestServer { + fn drop(&mut self) { + self.child.kill().unwrap() + } +} diff --git a/tests/wallet.rs b/tests/wallet.rs index d842bde60b..cda7fa8a5b 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -57,9 +57,7 @@ fn send_not_allowed_on_mainnet() { CommandBuilder::new("wallet send 5000000000 tb1qx4gf3ya0cxfcwydpq8vr2lhrysneuj5d7lqatw") .rpc_server(&rpc_server) - .expected_stderr( - "error: Send command is not allowed on mainnet yet. Try on regtest/signet/testnet.\n", - ) + .expected_stderr("error: `ord wallet send` is unstable and not yet supported on mainnet.\n") .expected_exit_code(1) .run(); }