diff --git a/Cargo.lock b/Cargo.lock index 0089ed5dda..30f559314a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1563,6 +1563,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.9" @@ -2024,6 +2033,7 @@ dependencies = [ "http", "hyper", "indicatif", + "itertools", "lazy_static", "log", "mime", diff --git a/Cargo.toml b/Cargo.toml index eb4e6b6498..e852c4aa04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,7 @@ html-escaper = "0.2.0" http = "0.2.6" hyper = { version = "0.14.24", features = ["http1", "client"] } indicatif = "0.17.1" +itertools = "0.11.0" lazy_static = "1.4.0" log = "0.4.14" mime = "0.3.16" diff --git a/src/chain.rs b/src/chain.rs index 1f72115bbb..2d100d6dde 100644 --- a/src/chain.rs +++ b/src/chain.rs @@ -2,7 +2,7 @@ use {super::*, clap::ValueEnum}; #[derive(Default, ValueEnum, Copy, Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] -pub(crate) enum Chain { +pub enum Chain { #[default] #[clap(alias("main"))] Mainnet, diff --git a/src/index.rs b/src/index.rs index f366f3c899..19f6f7c513 100644 --- a/src/index.rs +++ b/src/index.rs @@ -13,6 +13,7 @@ use { bitcoincore_rpc::{json::GetBlockHeaderResult, Client}, chrono::SubsecRound, indicatif::{ProgressBar, ProgressStyle}, + itertools::Itertools, log::log_enabled, redb::{ Database, MultimapTable, MultimapTableDefinition, ReadableMultimapTable, ReadableTable, Table, @@ -57,7 +58,7 @@ define_table! { STATISTIC_TO_COUNT, u64, u64 } define_table! { WRITE_TRANSACTION_STARTING_BLOCK_COUNT_TO_TIMESTAMP, u64, u128 } #[derive(Debug, PartialEq)] -pub(crate) enum List { +pub enum List { Spent, Unspent(Vec<(u64, u64)>), } @@ -877,19 +878,23 @@ impl Index { &self, n: usize, from: Option, - ) -> Result<(Vec, Option, Option)> { + ) -> Result<(Vec, Option, Option, i64, i64)> { let rtx = self.database.begin_read()?; let inscription_number_to_inscription_id = rtx.open_table(INSCRIPTION_NUMBER_TO_INSCRIPTION_ID)?; - let latest = match inscription_number_to_inscription_id.iter()?.next_back() { + let highest = match inscription_number_to_inscription_id.iter()?.next_back() { Some(Ok((number, _id))) => number.value(), - Some(Err(_)) => return Ok(Default::default()), - None => return Ok(Default::default()), + Some(Err(_)) | None => return Ok(Default::default()), }; - let from = from.unwrap_or(latest); + let lowest = match inscription_number_to_inscription_id.iter()?.next() { + Some(Ok((number, _id))) => number.value(), + Some(Err(_)) | None => return Ok(Default::default()), + }; + + let from = from.unwrap_or(highest); let prev = if let Some(prev) = from.checked_sub(n.try_into()?) { inscription_number_to_inscription_id @@ -899,12 +904,12 @@ impl Index { None }; - let next = if from < latest { + let next = if from < highest { Some( from .checked_add(n.try_into()?) - .unwrap_or(latest) - .min(latest), + .unwrap_or(highest) + .min(highest), ) } else { None @@ -917,7 +922,32 @@ impl Index { .flat_map(|result| result.map(|(_number, id)| Entry::load(*id.value()))) .collect(); - Ok((inscriptions, prev, next)) + Ok((inscriptions, prev, next, lowest, highest)) + } + + pub(crate) fn get_inscriptions_in_block(&self, block_height: u64) -> Result> { + // This is a naive approach and will require optimization, but we don't have an index by block + let block_inscriptions = self + .database + .begin_read()? + .open_table(INSCRIPTION_ID_TO_INSCRIPTION_ENTRY)? + .iter()? + .filter_map(|result| match result { + Ok((key, entry_value)) => { + let entry = InscriptionEntry::load(entry_value.value()); + if entry.height == block_height { + Some((InscriptionId::load(*key.value()), entry.number)) + } else { + None + } + } + Err(_) => None, + }) + .sorted_by_key(|&(_id, number)| number) + .map(|(id, _)| id) + .collect(); + + Ok(block_inscriptions) } pub(crate) fn get_feed_inscriptions(&self, n: usize) -> Result> { @@ -2447,7 +2477,7 @@ mod tests { context.mine_blocks(1); - let (inscriptions, prev, next) = context + let (inscriptions, prev, next, _, _) = context .index .get_latest_inscriptions_with_prev_and_next(100, None) .unwrap(); @@ -2476,15 +2506,17 @@ mod tests { ids.reverse(); - let (inscriptions, prev, next) = context + let (inscriptions, prev, next, lowest, highest) = context .index .get_latest_inscriptions_with_prev_and_next(100, None) .unwrap(); assert_eq!(inscriptions, &ids[..100]); assert_eq!(prev, Some(2)); assert_eq!(next, None); + assert_eq!(highest, 102); + assert_eq!(lowest, 0); - let (inscriptions, prev, next) = context + let (inscriptions, prev, next, _lowest, _highest) = context .index .get_latest_inscriptions_with_prev_and_next(100, Some(101)) .unwrap(); @@ -2492,7 +2524,7 @@ mod tests { assert_eq!(prev, Some(1)); assert_eq!(next, Some(102)); - let (inscriptions, prev, next) = context + let (inscriptions, prev, next, _lowest, _highest) = context .index .get_latest_inscriptions_with_prev_and_next(100, Some(0)) .unwrap(); diff --git a/src/inscription.rs b/src/inscription.rs index e647999fc6..5506c5f361 100644 --- a/src/inscription.rs +++ b/src/inscription.rs @@ -23,7 +23,7 @@ pub(crate) enum Curse { } #[derive(Debug, PartialEq, Clone)] -pub(crate) struct Inscription { +pub struct Inscription { body: Option>, content_type: Option>, } diff --git a/src/inscription_id.rs b/src/inscription_id.rs index 5ff9a347c4..998faeb3c3 100644 --- a/src/inscription_id.rs +++ b/src/inscription_id.rs @@ -2,8 +2,8 @@ use super::*; #[derive(Debug, PartialEq, Copy, Clone, Hash, Eq)] pub struct InscriptionId { - pub(crate) txid: Txid, - pub(crate) index: u32, + pub txid: Txid, + pub index: u32, } impl<'de> Deserialize<'de> for InscriptionId { diff --git a/src/subcommand/server.rs b/src/subcommand/server.rs index 59d46286cf..8cc8f672a1 100644 --- a/src/subcommand/server.rs +++ b/src/subcommand/server.rs @@ -7,9 +7,10 @@ use { super::*, crate::page_config::PageConfig, crate::templates::{ - BlockHtml, ClockSvg, HomeHtml, InputHtml, InscriptionHtml, InscriptionsHtml, OutputHtml, - PageContent, PageHtml, PreviewAudioHtml, PreviewImageHtml, PreviewPdfHtml, PreviewTextHtml, - PreviewUnknownHtml, PreviewVideoHtml, RangeHtml, RareTxt, SatHtml, SatJson, TransactionHtml, + BlockHtml, ClockSvg, HomeHtml, InputHtml, InscriptionHtml, InscriptionJson, InscriptionsHtml, + InscriptionsJson, OutputHtml, OutputJson, PageContent, PageHtml, PreviewAudioHtml, + PreviewImageHtml, PreviewPdfHtml, PreviewTextHtml, PreviewUnknownHtml, PreviewVideoHtml, + RangeHtml, RareTxt, SatHtml, SatJson, TransactionHtml, }, axum::{ body, @@ -28,7 +29,7 @@ use { caches::DirCache, AcmeConfig, }, - std::{cmp::Ordering, str}, + std::{cmp::Ordering, str, sync::Arc}, tokio_stream::StreamExt, tower_http::{ compression::CompressionLayer, @@ -40,6 +41,11 @@ use { mod accept_json; mod error; +#[derive(Clone)] +pub struct ServerConfig { + pub is_json_api_enabled: bool, +} + enum BlockQuery { Height(u64), Hash(BlockHash), @@ -148,6 +154,10 @@ impl Server { domain: acme_domains.first().cloned(), }); + let server_config = Arc::new(ServerConfig { + is_json_api_enabled: index.is_json_api_enabled(), + }); + let router = Router::new() .route("/", get(Self::home)) .route("/block/:query", get(Self::block)) @@ -165,7 +175,9 @@ impl Server { .route("/input/:block/:transaction/:input", get(Self::input)) .route("/inscription/:inscription_id", get(Self::inscription)) .route("/inscriptions", get(Self::inscriptions)) + .route("/inscriptions/block/:n", get(Self::inscriptions_in_block)) .route("/inscriptions/:from", get(Self::inscriptions_from)) + .route("/inscriptions/:from/:n", get(Self::inscriptions_from_n)) .route("/install.sh", get(Self::install_script)) .route("/ordinal/:sat", get(Self::ordinal)) .route("/output/:output", get(Self::output)) @@ -194,7 +206,8 @@ impl Server { .allow_methods([http::Method::GET]) .allow_origin(Any), ) - .layer(CompressionLayer::new()); + .layer(CompressionLayer::new()) + .with_state(server_config); match (self.http_port(), self.https_port()) { (Some(http_port), None) => { @@ -386,31 +399,34 @@ impl Server { Path(DeserializeFromStr(sat)): Path>, accept_json: AcceptJson, ) -> ServerResult { - let satpoint = index.rare_sat_satpoint(sat)?; - let blocktime = index.block_time(sat.height())?; let inscriptions = index.get_inscription_ids_by_sat(sat)?; + let satpoint = index.rare_sat_satpoint(sat)?.or_else(|| { + inscriptions.first().and_then(|&first_inscription_id| { + index + .get_inscription_satpoint_by_id(first_inscription_id) + .ok() + .flatten() + }) + }); + let blocktime = index.block_time(sat.height())?; Ok(if accept_json.0 { - if index.is_json_api_enabled() { - Json(SatJson { - number: sat.0, - decimal: sat.decimal().to_string(), - degree: sat.degree().to_string(), - name: sat.name(), - block: sat.height().0, - cycle: sat.cycle(), - epoch: sat.epoch().0, - period: sat.period(), - offset: sat.third(), - rarity: sat.rarity(), - percentile: sat.percentile(), - satpoint, - timestamp: blocktime.timestamp().to_string(), - inscriptions, - }) - .into_response() - } else { - StatusCode::NOT_ACCEPTABLE.into_response() - } + Json(SatJson { + number: sat.0, + decimal: sat.decimal().to_string(), + degree: sat.degree().to_string(), + name: sat.name(), + block: sat.height().0, + cycle: sat.cycle(), + epoch: sat.epoch().0, + period: sat.period(), + offset: sat.third(), + rarity: sat.rarity(), + percentile: sat.percentile(), + satpoint, + timestamp: blocktime.timestamp().timestamp(), + inscriptions, + }) + .into_response() } else { SatHtml { sat, @@ -431,7 +447,8 @@ impl Server { Extension(page_config): Extension>, Extension(index): Extension>, Path(outpoint): Path, - ) -> ServerResult> { + accept_json: AcceptJson, + ) -> ServerResult { let list = if index.has_sat_index()? { index.list(outpoint)? } else { @@ -463,7 +480,16 @@ impl Server { let inscriptions = index.get_inscriptions_on_output(outpoint)?; - Ok( + Ok(if accept_json.0 { + Json(OutputJson::new( + outpoint, + list, + page_config.chain, + output, + inscriptions, + )) + .into_response() + } else { OutputHtml { outpoint, inscriptions, @@ -471,8 +497,9 @@ impl Server { chain: page_config.chain, output, } - .page(page_config, index.has_sat_index()?), - ) + .page(page_config, index.has_sat_index()?) + .into_response() + }) } async fn range( @@ -901,7 +928,8 @@ impl Server { Extension(page_config): Extension>, Extension(index): Extension>, Path(inscription_id): Path, - ) -> ServerResult> { + accept_json: AcceptJson, + ) -> ServerResult { let entry = index .get_inscription_entry(inscription_id)? .ok_or_not_found(|| format!("inscription {inscription_id}"))?; @@ -932,7 +960,23 @@ impl Server { let next = index.get_inscription_id_by_inscription_number(entry.number + 1)?; - Ok( + Ok(if accept_json.0 { + Json(InscriptionJson::new( + page_config.chain, + entry.fee, + entry.height, + inscription, + inscription_id, + next, + entry.number, + output, + previous, + entry.sat, + satpoint, + timestamp(entry.timestamp), + )) + .into_response() + } else { InscriptionHtml { chain: page_config.chain, genesis_fee: entry.fee, @@ -947,39 +991,84 @@ impl Server { satpoint, timestamp: timestamp(entry.timestamp), } - .page(page_config, index.has_sat_index()?), - ) + .page(page_config, index.has_sat_index()?) + .into_response() + }) } async fn inscriptions( Extension(page_config): Extension>, Extension(index): Extension>, - ) -> ServerResult> { - Self::inscriptions_inner(page_config, index, None).await + accept_json: AcceptJson, + ) -> ServerResult { + Self::inscriptions_inner(page_config, index, None, 100, accept_json).await + } + + async fn inscriptions_in_block( + Extension(page_config): Extension>, + Extension(index): Extension>, + Path(block_height): Path, + accept_json: AcceptJson, + ) -> ServerResult { + let inscriptions = index.get_inscriptions_in_block(block_height)?; + Ok(if accept_json.0 { + Json(InscriptionsJson::new(inscriptions, None, None, None, None)).into_response() + } else { + InscriptionsHtml { + inscriptions, + prev: None, + next: None, + } + .page(page_config, index.has_sat_index()?) + .into_response() + }) } async fn inscriptions_from( Extension(page_config): Extension>, Extension(index): Extension>, Path(from): Path, - ) -> ServerResult> { - Self::inscriptions_inner(page_config, index, Some(from)).await + accept_json: AcceptJson, + ) -> ServerResult { + Self::inscriptions_inner(page_config, index, Some(from), 100, accept_json).await + } + + async fn inscriptions_from_n( + Extension(page_config): Extension>, + Extension(index): Extension>, + Path((from, n)): Path<(i64, usize)>, + accept_json: AcceptJson, + ) -> ServerResult { + Self::inscriptions_inner(page_config, index, Some(from), n, accept_json).await } async fn inscriptions_inner( page_config: Arc, index: Arc, from: Option, - ) -> ServerResult> { - let (inscriptions, prev, next) = index.get_latest_inscriptions_with_prev_and_next(100, from)?; - Ok( + n: usize, + accept_json: AcceptJson, + ) -> ServerResult { + let (inscriptions, prev, next, lowest, highest) = + index.get_latest_inscriptions_with_prev_and_next(n, from)?; + Ok(if accept_json.0 { + Json(InscriptionsJson::new( + inscriptions, + prev, + next, + Some(lowest), + Some(highest), + )) + .into_response() + } else { InscriptionsHtml { inscriptions, next, prev, } - .page(page_config, index.has_sat_index()?), - ) + .page(page_config, index.has_sat_index()?) + .into_response() + }) } async fn redirect_http_to_https( @@ -2033,7 +2122,7 @@ mod tests { fn commits_are_tracked() { let server = TestServer::new(); - thread::sleep(Duration::from_millis(25)); + thread::sleep(Duration::from_millis(100)); assert_eq!(server.index.statistic(crate::index::Statistic::Commits), 3); let info = server.index.info().unwrap(); diff --git a/src/subcommand/server/accept_json.rs b/src/subcommand/server/accept_json.rs index e01edf29c2..a471c3d049 100644 --- a/src/subcommand/server/accept_json.rs +++ b/src/subcommand/server/accept_json.rs @@ -1,24 +1,32 @@ -use super::*; +use {super::*, axum::extract::FromRef}; pub(crate) struct AcceptJson(pub(crate) bool); #[async_trait::async_trait] impl axum::extract::FromRequestParts for AcceptJson where + Arc: FromRef, S: Send + Sync, { - type Rejection = (); + type Rejection = (StatusCode, &'static str); async fn from_request_parts( parts: &mut http::request::Parts, - _state: &S, + state: &S, ) -> Result { - Ok(Self( - parts - .headers - .get("accept") - .map(|value| value == "application/json") - .unwrap_or_default(), - )) + let state = Arc::from_ref(state); + let json_api_enabled = state.is_json_api_enabled; + let json_header = parts + .headers + .get("accept") + .map(|value| value == "application/json") + .unwrap_or_default(); + if json_header && json_api_enabled { + Ok(Self(true)) + } else if json_header && !json_api_enabled { + Err((StatusCode::NOT_ACCEPTABLE, "JSON API not enabled")) + } else { + Ok(Self(false)) + } } } diff --git a/src/templates.rs b/src/templates.rs index e4f008f6a8..259ad50370 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -6,9 +6,9 @@ pub(crate) use { home::HomeHtml, iframe::Iframe, input::InputHtml, - inscription::InscriptionHtml, - inscriptions::InscriptionsHtml, - output::OutputHtml, + inscription::{InscriptionHtml, InscriptionJson}, + inscriptions::{InscriptionsHtml, InscriptionsJson}, + output::{OutputHtml, OutputJson}, page_config::PageConfig, preview::{ PreviewAudioHtml, PreviewImageHtml, PreviewPdfHtml, PreviewTextHtml, PreviewUnknownHtml, @@ -25,9 +25,9 @@ mod clock; mod home; mod iframe; mod input; -mod inscription; -mod inscriptions; -mod output; +pub mod inscription; +pub mod inscriptions; +pub mod output; mod preview; mod range; mod rare; diff --git a/src/templates/inscription.rs b/src/templates/inscription.rs index c19e5bf4b2..8700e82a9c 100644 --- a/src/templates/inscription.rs +++ b/src/templates/inscription.rs @@ -16,6 +16,59 @@ pub(crate) struct InscriptionHtml { pub(crate) timestamp: DateTime, } +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub struct InscriptionJson { + pub inscription_id: InscriptionId, + pub number: i64, + pub genesis_height: u64, + pub genesis_fee: u64, + pub output_value: Option, + pub address: Option, + pub sat: Option, + pub satpoint: SatPoint, + pub content_type: Option, + pub content_length: Option, + pub timestamp: i64, + pub previous: Option, + pub next: Option, +} + +impl InscriptionJson { + pub fn new( + chain: Chain, + genesis_fee: u64, + genesis_height: u64, + inscription: Inscription, + inscription_id: InscriptionId, + next: Option, + number: i64, + output: Option, + previous: Option, + sat: Option, + satpoint: SatPoint, + timestamp: DateTime, + ) -> Self { + Self { + inscription_id, + number, + genesis_height, + genesis_fee, + output_value: output.as_ref().map(|o| o.value), + address: output + .as_ref() + .and_then(|o| chain.address_from_script(&o.script_pubkey).ok()) + .map(|address| address.to_string()), + sat, + satpoint, + content_type: inscription.content_type().map(|s| s.to_string()), + content_length: inscription.content_length(), + timestamp: timestamp.timestamp(), + previous, + next, + } + } +} + impl PageContent for InscriptionHtml { fn title(&self) -> String { format!("Inscription {}", self.number) diff --git a/src/templates/inscriptions.rs b/src/templates/inscriptions.rs index 8399e8fa59..eb2a0e091f 100644 --- a/src/templates/inscriptions.rs +++ b/src/templates/inscriptions.rs @@ -7,6 +7,33 @@ pub(crate) struct InscriptionsHtml { pub(crate) next: Option, } +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub struct InscriptionsJson { + pub inscriptions: Vec, + pub prev: Option, + pub next: Option, + pub lowest: Option, + pub highest: Option, +} + +impl InscriptionsJson { + pub fn new( + inscriptions: Vec, + prev: Option, + next: Option, + lowest: Option, + highest: Option, + ) -> Self { + Self { + inscriptions, + prev, + next, + lowest, + highest, + } + } +} + impl PageContent for InscriptionsHtml { fn title(&self) -> String { "Inscriptions".into() diff --git a/src/templates/output.rs b/src/templates/output.rs index 2172795b18..fac57cc1d7 100644 --- a/src/templates/output.rs +++ b/src/templates/output.rs @@ -9,6 +9,41 @@ pub(crate) struct OutputHtml { pub(crate) inscriptions: Vec, } +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub struct OutputJson { + pub value: u64, + pub script_pubkey: String, + pub address: Option, + pub transaction: String, + pub sat_ranges: Option>, + pub inscriptions: Vec, +} + +impl OutputJson { + pub fn new( + outpoint: OutPoint, + list: Option, + chain: Chain, + output: TxOut, + inscriptions: Vec, + ) -> Self { + Self { + value: output.value, + script_pubkey: output.script_pubkey.to_asm_string(), + address: chain + .address_from_script(&output.script_pubkey) + .ok() + .map(|address| address.to_string()), + transaction: outpoint.txid.to_string(), + sat_ranges: match list { + Some(List::Unspent(ranges)) => Some(ranges), + _ => None, + }, + inscriptions, + } + } +} + impl PageContent for OutputHtml { fn title(&self) -> String { format!("Output {}", self.outpoint) diff --git a/src/templates/sat.rs b/src/templates/sat.rs index c42b839750..c79c717749 100644 --- a/src/templates/sat.rs +++ b/src/templates/sat.rs @@ -22,7 +22,7 @@ pub struct SatJson { pub rarity: Rarity, pub percentile: String, pub satpoint: Option, - pub timestamp: String, + pub timestamp: i64, pub inscriptions: Vec, } diff --git a/tests/json_api.rs b/tests/json_api.rs index 3cbba92188..893ede9327 100644 --- a/tests/json_api.rs +++ b/tests/json_api.rs @@ -1,6 +1,8 @@ use { - super::*, ord::inscription_id::InscriptionId, ord::rarity::Rarity, ord::templates::sat::SatJson, - ord::SatPoint, + super::*, ord::inscription_id::InscriptionId, ord::rarity::Rarity, + ord::templates::inscription::InscriptionJson, ord::templates::inscriptions::InscriptionsJson, + ord::templates::output::OutputJson, ord::templates::sat::SatJson, ord::SatPoint, + test_bitcoincore_rpc::TransactionTemplate, }; #[test] @@ -15,7 +17,7 @@ fn get_sat_without_sat_index() { let mut sat_json: SatJson = serde_json::from_str(&response.text().unwrap()).unwrap(); // this is a hack to ignore the timestamp, since it changes for every request - sat_json.timestamp = "".into(); + sat_json.timestamp = 0; pretty_assert_eq!( sat_json, @@ -32,7 +34,7 @@ fn get_sat_without_sat_index() { rarity: Rarity::Uncommon, percentile: "100%".into(), satpoint: None, - timestamp: "".into(), + timestamp: 0, inscriptions: vec![], } ) @@ -69,12 +71,318 @@ fn get_sat_with_inscription_and_sat_index() { rarity: Rarity::Uncommon, percentile: "0.00023809523835714296%".into(), satpoint: Some(SatPoint::from_str(&format!("{}:{}:{}", reveal, 0, 0)).unwrap()), - timestamp: "1970-01-01 00:00:01 UTC".into(), + timestamp: 1, inscriptions: vec![inscription_id], } ) } +#[test] +fn get_sat_with_inscription_on_common_sat_and_more_inscriptions() { + let rpc_server = test_bitcoincore_rpc::spawn(); + + create_wallet(&rpc_server); + + inscribe(&rpc_server); + + let txid = rpc_server.mine_blocks(1)[0].txdata[0].txid(); + + let Inscribe { reveal, .. } = CommandBuilder::new(format!( + "wallet inscribe --satpoint {}:0:1 --fee-rate 1 foo.txt", + txid + )) + .write("foo.txt", "FOO") + .rpc_server(&rpc_server) + .run_and_check_output(); + + rpc_server.mine_blocks(1); + let inscription_id = InscriptionId::from(reveal); + + let response = TestServer::spawn_with_args(&rpc_server, &["--index-sats", "--enable-json-api"]) + .json_request(format!("/sat/{}", 3 * 50 * COIN_VALUE + 1)); + + assert_eq!(response.status(), StatusCode::OK); + + let sat_json: SatJson = serde_json::from_str(&response.text().unwrap()).unwrap(); + + pretty_assert_eq!( + sat_json, + SatJson { + number: 3 * 50 * COIN_VALUE + 1, + decimal: "3.1".into(), + degree: "0°3′3″1‴".into(), + name: "nvtblvikkiq".into(), + block: 3, + cycle: 0, + epoch: 0, + period: 0, + offset: 1, + rarity: Rarity::Common, + percentile: "0.000714285715119048%".into(), + satpoint: Some(SatPoint::from_str(&format!("{}:{}:{}", reveal, 0, 0)).unwrap()), + timestamp: 3, + inscriptions: vec![inscription_id], + } + ) +} + +#[test] +fn get_inscription() { + let rpc_server = test_bitcoincore_rpc::spawn(); + + create_wallet(&rpc_server); + + let Inscribe { reveal, .. } = inscribe(&rpc_server); + let inscription_id = InscriptionId::from(reveal); + + let response = TestServer::spawn_with_args(&rpc_server, &["--index-sats", "--enable-json-api"]) + .json_request(format!("/inscription/{}", inscription_id)); + + assert_eq!(response.status(), StatusCode::OK); + + let mut inscription_json: InscriptionJson = + serde_json::from_str(&response.text().unwrap()).unwrap(); + assert_regex_match!(inscription_json.address.unwrap(), r"bc1p.*"); + inscription_json.address = None; + + pretty_assert_eq!( + inscription_json, + InscriptionJson { + inscription_id, + number: 0, + genesis_height: 2, + genesis_fee: 138, + output_value: Some(10000), + address: None, + sat: Some(ord::Sat(50 * COIN_VALUE)), + satpoint: SatPoint::from_str(&format!("{}:{}:{}", reveal, 0, 0)).unwrap(), + content_type: Some("text/plain;charset=utf-8".to_string()), + content_length: Some(3), + timestamp: 2, + previous: None, + next: None + } + ) +} + +fn create_210_inscriptions( + rpc_server: &test_bitcoincore_rpc::Handle, +) -> (Vec, Vec) { + let witness = envelope(&[b"ord", &[1], b"text/plain;charset=utf-8", &[], b"bar"]); + + let mut blessed_inscriptions = Vec::new(); + let mut cursed_inscriptions = Vec::new(); + + // Create 150 inscriptions, 50 non-cursed and 100 cursed + for i in 0..50 { + rpc_server.mine_blocks(1); + rpc_server.mine_blocks(1); + rpc_server.mine_blocks(1); + + let txid = rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[(i * 3 + 1, 0, 0), (i * 3 + 2, 0, 0), (i * 3 + 3, 0, 0)], + witness: witness.clone(), + ..Default::default() + }); + + blessed_inscriptions.push(InscriptionId { txid, index: 0 }); + cursed_inscriptions.push(InscriptionId { txid, index: 1 }); + cursed_inscriptions.push(InscriptionId { txid, index: 2 }); + } + + rpc_server.mine_blocks(1); + + // Create another 60 non cursed + for _ in 0..60 { + let Inscribe { reveal, .. } = CommandBuilder::new("wallet inscribe --fee-rate 1 foo.txt") + .write("foo.txt", "FOO") + .rpc_server(rpc_server) + .run_and_check_output(); + rpc_server.mine_blocks(1); + blessed_inscriptions.push(InscriptionId::from(reveal)); + } + + rpc_server.mine_blocks(1); + + (blessed_inscriptions, cursed_inscriptions) +} + +#[test] +fn get_inscriptions() { + let rpc_server = test_bitcoincore_rpc::spawn(); + + create_wallet(&rpc_server); + let (blessed_inscriptions, cursed_inscriptions) = create_210_inscriptions(&rpc_server); + + let server = TestServer::spawn_with_args(&rpc_server, &["--index-sats", "--enable-json-api"]); + + let response = server.json_request("/inscriptions"); + assert_eq!(response.status(), StatusCode::OK); + let inscriptions_json: InscriptionsJson = + serde_json::from_str(&response.text().unwrap()).unwrap(); + + // 100 latest (blessed) inscriptions + assert_eq!(inscriptions_json.inscriptions.len(), 100); + pretty_assert_eq!( + inscriptions_json, + InscriptionsJson { + inscriptions: blessed_inscriptions[10..110] + .iter() + .cloned() + .rev() + .collect(), + prev: Some(9), + next: None, + lowest: Some(-100), + highest: Some(109), + } + ); + + // get all inscriptions + let response = server.json_request(format!("/inscriptions/{}/{}", 200, 400)); + assert_eq!(response.status(), StatusCode::OK); + + let inscriptions_json: InscriptionsJson = + serde_json::from_str(&response.text().unwrap()).unwrap(); + + assert_eq!( + inscriptions_json.inscriptions.len(), + blessed_inscriptions.len() + cursed_inscriptions.len() + ); + pretty_assert_eq!( + inscriptions_json.inscriptions, + blessed_inscriptions + .iter() + .cloned() + .rev() + .chain(cursed_inscriptions.clone()) + .collect::>() + ); + + // iterate over all inscriptions 1 by 1 + let all_inscriptions = cursed_inscriptions + .clone() + .iter() + .cloned() + .rev() + .chain(blessed_inscriptions.clone()) + .collect::>(); // from lowest to highest inscription number + + let (lowest, highest) = ( + inscriptions_json.lowest.unwrap(), + inscriptions_json.highest.unwrap(), + ); + for i in lowest..=highest { + let response = server.json_request(format!("/inscriptions/{}/1", i)); + assert_eq!(response.status(), StatusCode::OK); + + let inscriptions_json: InscriptionsJson = + serde_json::from_str(&response.text().unwrap()).unwrap(); + + assert_eq!(inscriptions_json.inscriptions.len(), 1); + assert_eq!( + inscriptions_json.inscriptions[0], + all_inscriptions[(i - lowest) as usize] + ); + + let response = server.json_request(format!( + "/inscription/{}", + inscriptions_json.inscriptions[0] + )); + assert_eq!(response.status(), StatusCode::OK); + + let inscription_json: InscriptionJson = + serde_json::from_str(&response.text().unwrap()).unwrap(); + + assert_eq!( + inscription_json.inscription_id, + inscriptions_json.inscriptions[0] + ); + assert_eq!(inscription_json.number, i); + } +} + +#[test] +fn get_inscriptions_in_block() { + let rpc_server = test_bitcoincore_rpc::spawn(); + + create_wallet(&rpc_server); + rpc_server.mine_blocks(10); + + let txid = rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[(1, 0, 0), (2, 0, 0), (3, 0, 0)], + witness: envelope(&[b"ord", &[1], b"text/plain;charset=utf-8", &[], b"bar"]), + ..Default::default() + }); + rpc_server.mine_blocks(1); + + for _ in 0..10 { + inscribe(&rpc_server); + } + rpc_server.mine_blocks(1); + + let server = TestServer::spawn_with_args(&rpc_server, &["--index-sats", "--enable-json-api"]); + + // get all inscriptions from block 11 + let response = server.json_request(format!("/inscriptions/block/{}", 11)); + assert_eq!(response.status(), StatusCode::OK); + + let inscriptions_json: InscriptionsJson = + serde_json::from_str(&response.text().unwrap()).unwrap(); + + assert_eq!(inscriptions_json.inscriptions.len(), 3); + pretty_assert_eq!( + inscriptions_json.inscriptions, + vec![ + InscriptionId { txid, index: 2 }, + InscriptionId { txid, index: 1 }, + InscriptionId { txid, index: 0 } + ] + ); +} + +#[test] +fn get_output() { + let rpc_server = test_bitcoincore_rpc::spawn(); + + create_wallet(&rpc_server); + rpc_server.mine_blocks(3); + + let txid = rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[(1, 0, 0), (2, 0, 0), (3, 0, 0)], + witness: envelope(&[b"ord", &[1], b"text/plain;charset=utf-8", &[], b"bar"]), + ..Default::default() + }); + rpc_server.mine_blocks(1); + + let server = TestServer::spawn_with_args(&rpc_server, &["--index-sats", "--enable-json-api"]); + + let response = server.json_request(format!("/output/{}:0", txid)); + assert_eq!(response.status(), StatusCode::OK); + + let output_json: OutputJson = serde_json::from_str(&response.text().unwrap()).unwrap(); + + pretty_assert_eq!( + output_json, + OutputJson { + value: 3 * 50 * COIN_VALUE, + script_pubkey: "".to_string(), + address: None, + transaction: txid.to_string(), + sat_ranges: Some(vec![ + (5000000000, 10000000000,), + (10000000000, 15000000000,), + (15000000000, 20000000000,), + ],), + inscriptions: vec![ + InscriptionId { txid, index: 0 }, + InscriptionId { txid, index: 2 }, + InscriptionId { txid, index: 1 } + ] + } + ); +} + #[test] fn json_request_fails_when_not_enabled() { let rpc_server = test_bitcoincore_rpc::spawn(); diff --git a/tests/lib.rs b/tests/lib.rs index 6ad150a58e..037b97110c 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -62,6 +62,24 @@ fn inscribe(rpc_server: &test_bitcoincore_rpc::Handle) -> Inscribe { output } +fn envelope(payload: &[&[u8]]) -> bitcoin::Witness { + let mut builder = bitcoin::script::Builder::new() + .push_opcode(bitcoin::opcodes::OP_FALSE) + .push_opcode(bitcoin::opcodes::all::OP_IF); + + for data in payload { + let mut buf = bitcoin::script::PushBytesBuf::new(); + buf.extend_from_slice(data).unwrap(); + builder = builder.push_slice(buf); + } + + let script = builder + .push_opcode(bitcoin::opcodes::all::OP_ENDIF) + .into_script(); + + bitcoin::Witness::from_slice(&[script.into_bytes(), Vec::new()]) +} + #[derive(Deserialize)] struct Create { mnemonic: Mnemonic,