diff --git a/.gitignore b/.gitignore index cf10b555b5..632b51ef39 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ /.vagrant /.vscode /docs/build +/env /fuzz/artifacts /fuzz/corpus /fuzz/coverage diff --git a/Cargo.lock b/Cargo.lock index ae9b1f5fa5..8794d23904 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2165,6 +2165,7 @@ dependencies = [ "chrono", "ciborium", "clap", + "colored", "criterion", "ctrlc", "dirs", @@ -3432,6 +3433,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61c5bb1d698276a2443e5ecfabc1008bf15a36c12e6a7176e7bf089ea9131140" dependencies = [ "async-compression", + "base64 0.21.6", "bitflags 2.4.1", "bytes", "futures-core", @@ -3439,6 +3441,7 @@ dependencies = [ "http 0.2.11", "http-body", "http-range-header", + "mime", "pin-project-lite", "tokio", "tokio-util 0.7.10", diff --git a/Cargo.toml b/Cargo.toml index 871d538ade..19b8b36646 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ brotli = "3.4.0" chrono = { version = "0.4.19", features = ["serde"] } ciborium = "0.2.1" clap = { version = "4.4.2", features = ["derive"] } +colored = "2.0.4" ctrlc = { version = "3.2.1", features = ["termination"] } dirs = "5.0.0" env_logger = "0.10.0" @@ -65,7 +66,7 @@ tempfile = "3.2.0" tokio = { version = "1.17.0", features = ["rt-multi-thread"] } tokio-stream = "0.1.9" tokio-util = {version = "0.7.3", features = ["compat"] } -tower-http = { version = "0.4.0", features = ["compression-br", "compression-gzip", "cors", "set-header"] } +tower-http = { version = "0.4.0", features = ["auth", "compression-br", "compression-gzip", "cors", "set-header"] } [dev-dependencies] criterion = "0.5.1" diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 4396d327ba..f284d5f0cc 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -10,6 +10,7 @@ Summary - [Pointer](inscriptions/pointer.md) - [Provenance](inscriptions/provenance.md) - [Recursion](inscriptions/recursion.md) + - [Rendering](inscriptions/rendering.md) - [FAQ](faq.md) - [Contributing](contributing.md) - [Donate](donate.md) diff --git a/docs/src/inscriptions/recursion.md b/docs/src/inscriptions/recursion.md index 023568c005..1deab60581 100644 --- a/docs/src/inscriptions/recursion.md +++ b/docs/src/inscriptions/recursion.md @@ -29,6 +29,7 @@ The recursive endpoints are: - `/r/blocktime`: UNIX time stamp of latest block. - `/r/children/`: the first 100 child inscription ids. - `/r/children//`: the set of 100 child inscription ids on ``. +- `/r/inscription/:inscription_id`: information about an inscription - `/r/metadata/`: JSON string containing the hex-encoded CBOR metadata. - `/r/sat/`: the first 100 inscription ids on a sat. - `/r/sat//`: the set of 100 inscription ids on ``. @@ -50,16 +51,38 @@ plain-text responses. Examples -------- +- `/r/blockhash/0`: + +```json +"000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" +``` + - `/r/blockheight`: ```json 777000 ``` -- `/r/blockhash/0`: +- `/r/blockinfo/0`: ```json -"000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f" +{ + "bits": 486604799, + "chainwork": 0, + "confirmations": 0, + "difficulty": 0.0, + "hash": "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", + "height": 0, + "median_time": null, + "merkle_root": "0000000000000000000000000000000000000000000000000000000000000000", + "next_block": null, + "nonce": 0, + "previous_block": null, + "target": "00000000ffff0000000000000000000000000000000000000000000000000000", + "timestamp": 0, + "transaction_count": 0, + "version": 1 +} ``` - `/r/blocktime`: @@ -68,6 +91,40 @@ Examples 1700770905 ``` +- `/r/children/60bcf821240064a9c55225c4f01711b0ebbcab39aa3fafeefe4299ab158536fai0/49`: + +```json +{ + "ids":[ + "7cd66b8e3a63dcd2fada917119830286bca0637267709d6df1ca78d98a1b4487i4900", + "7cd66b8e3a63dcd2fada917119830286bca0637267709d6df1ca78d98a1b4487i4901", + ... + "7cd66b8e3a63dcd2fada917119830286bca0637267709d6df1ca78d98a1b4487i4935", + "7cd66b8e3a63dcd2fada917119830286bca0637267709d6df1ca78d98a1b4487i4936" + ], + "more":false, + "page":49 +} +``` + +- `r/inscription/3bd72a7ef68776c9429961e43043ff65efa7fb2d8bb407386a9e3b19f149bc36i0` + +```json +{ + "charms": [], + "content_type": "image/png", + "content_length": 144037, + "fee": 36352, + "height": 209, + "number": 2, + "output": "3bd72a7ef68776c9429961e43043ff65efa7fb2d8bb407386a9e3b19f149bc36:0", + "sat": null, + "satpoint": "3bd72a7ef68776c9429961e43043ff65efa7fb2d8bb407386a9e3b19f149bc36:0:0", + "timestamp": 1708312562, + "value": 10000 +} +``` + - `/r/metadata/35b66389b44535861c44b2b18ed602997ee11db9a30d384ae89630c9fc6f011fi3`: ```json @@ -109,25 +166,3 @@ Examples "page":49 } ``` - -- `/r/blockinfo/0`: - -```json -{ - "bits": 486604799, - "chainwork": 0, - "confirmations": 0, - "difficulty": 0.0, - "hash": "000000000019d6689c085ae165831e934ff763ae46a2a6c172b3f1b60a8ce26f", - "height": 0, - "median_time": null, - "merkle_root": "0000000000000000000000000000000000000000000000000000000000000000", - "next_block": null, - "nonce": 0, - "previous_block": null, - "target": "00000000ffff0000000000000000000000000000000000000000000000000000", - "timestamp": 0, - "transaction_count": 0, - "version": 1 -} -``` diff --git a/docs/src/inscriptions/rendering.md b/docs/src/inscriptions/rendering.md new file mode 100644 index 0000000000..1f2deea223 --- /dev/null +++ b/docs/src/inscriptions/rendering.md @@ -0,0 +1,35 @@ +Rendering +========= + +Aspect Ratio +------------ + +Inscriptions should be rendered with a square aspect ratio. Non-square aspect +ratio inscriptions should not be cropped, and should instead be centered and +resized to fit within their container. + +Maximum Size +------------ + +The `ord` explorer, used by [ordinals.com](https://ordinals.com/), displays +inscription previews with a maximum size of 576 by 576 pixels, making it a +reasonable choice when choosing a maximum display size. + +Image Rendering +--------------- + +The CSS `image-rendering` property controls how images are resampled when +upscaled and downscaled. + +When downscaling image inscriptions, `image-rendering: auto`, should be used. +This is desirable even when downscaling pixel art. + +When upscaling image inscriptions other than AVIF, `image-rendering: pixelated` +should be used. This is desriable when upscaling pixel art, since it preserves +the sharp edges of pixels. It is undesirable when upscaling non-pixel art, but +should still be used for visual compatibility with the `ord` explorer. + +When upscaling AVIF and JPEG XL inscriptions, `image-rendering: auto` should be +used. This allows inscribers to opt-in to non-pixelated upscaling for non-pixel +art inscriptions. Until such time as JPEG XL is widely supported by browsers, +it is not a recommended image format. diff --git a/src/index.rs b/src/index.rs index 03a9a542b6..b5f3d779c6 100644 --- a/src/index.rs +++ b/src/index.rs @@ -43,7 +43,7 @@ mod updater; #[cfg(test)] pub(crate) mod testing; -const SCHEMA_VERSION: u64 = 17; +const SCHEMA_VERSION: u64 = 18; macro_rules! define_table { ($name:ident, $key:ty, $value:ty) => { @@ -61,6 +61,7 @@ macro_rules! define_multimap_table { define_multimap_table! { SATPOINT_TO_SEQUENCE_NUMBER, &SatPointValue, u32 } define_multimap_table! { SAT_TO_SEQUENCE_NUMBER, u64, u32 } define_multimap_table! { SEQUENCE_NUMBER_TO_CHILDREN, u32, u32 } +define_table! { CONTENT_TYPE_TO_COUNT, Option<&[u8]>, u64 } define_table! { HEIGHT_TO_BLOCK_HEADER, u32, &HeaderValue } define_table! { HEIGHT_TO_LAST_SEQUENCE_NUMBER, u32, u32 } define_table! { HOME_INSCRIPTIONS, u32, InscriptionIdValue } @@ -327,6 +328,7 @@ impl Index { tx.open_multimap_table(SATPOINT_TO_SEQUENCE_NUMBER)?; tx.open_multimap_table(SAT_TO_SEQUENCE_NUMBER)?; tx.open_multimap_table(SEQUENCE_NUMBER_TO_CHILDREN)?; + tx.open_table(CONTENT_TYPE_TO_COUNT)?; tx.open_table(HEIGHT_TO_BLOCK_HEADER)?; tx.open_table(HEIGHT_TO_LAST_SEQUENCE_NUMBER)?; tx.open_table(HOME_INSCRIPTIONS)?; @@ -472,9 +474,20 @@ impl Index { let blessed_inscriptions = statistic(Statistic::BlessedInscriptions)?; let cursed_inscriptions = statistic(Statistic::CursedInscriptions)?; + let mut content_type_counts = rtx + .open_table(CONTENT_TYPE_TO_COUNT)? + .iter()? + .map(|result| { + result.map(|(key, value)| (key.value().map(|slice| slice.into()), value.value())) + }) + .collect::>, u64)>, StorageError>>()?; + + content_type_counts.sort_by_key(|(_content_type, count)| Reverse(*count)); + Ok(StatusHtml { blessed_inscriptions, chain: self.options.chain(), + content_type_counts, cursed_inscriptions, height, inscriptions: blessed_inscriptions + cursed_inscriptions, diff --git a/src/index/updater.rs b/src/index/updater.rs index 06723fb462..ace80b31fb 100644 --- a/src/index/updater.rs +++ b/src/index/updater.rs @@ -375,6 +375,7 @@ impl<'index> Updater<'_> { } } + let mut content_type_to_count = wtx.open_table(CONTENT_TYPE_TO_COUNT)?; let mut height_to_block_header = wtx.open_table(HEIGHT_TO_BLOCK_HEADER)?; let mut height_to_last_sequence_number = wtx.open_table(HEIGHT_TO_LAST_SEQUENCE_NUMBER)?; let mut home_inscriptions = wtx.open_table(HOME_INSCRIPTIONS)?; @@ -423,6 +424,7 @@ impl<'index> Updater<'_> { let mut inscription_updater = InscriptionUpdater { blessed_inscription_count, chain: self.index.options.chain(), + content_type_to_count: &mut content_type_to_count, cursed_inscription_count, event_sender: self.index.event_sender.as_ref(), flotsam: Vec::new(), diff --git a/src/index/updater/inscription_updater.rs b/src/index/updater/inscription_updater.rs index f146b2e8cb..ff7ad94f4d 100644 --- a/src/index/updater/inscription_updater.rs +++ b/src/index/updater/inscription_updater.rs @@ -40,6 +40,7 @@ enum Origin { pub(super) struct InscriptionUpdater<'a, 'db, 'tx> { pub(super) blessed_inscription_count: u64, pub(super) chain: Chain, + pub(super) content_type_to_count: &'a mut Table<'db, 'tx, Option<&'static [u8]>, u64>, pub(super) cursed_inscription_count: u64, pub(super) event_sender: Option<&'a Sender>, pub(super) flotsam: Vec, @@ -195,6 +196,18 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { .filter(|&pointer| pointer < total_output_value) .unwrap_or(offset); + let content_type = inscription.payload.content_type.as_deref(); + + let content_type_count = self + .content_type_to_count + .get(content_type)? + .map(|entry| entry.value()) + .unwrap_or_default(); + + self + .content_type_to_count + .insert(content_type, content_type_count + 1)?; + floating_inscriptions.push(Flotsam { inscription_id, offset, diff --git a/src/inscriptions/inscription_id.rs b/src/inscriptions/inscription_id.rs index 684a2f9183..773b0ac715 100644 --- a/src/inscriptions/inscription_id.rs +++ b/src/inscriptions/inscription_id.rs @@ -1,6 +1,6 @@ use super::*; -#[derive(Debug, PartialEq, Copy, Clone, Hash, Eq)] +#[derive(Debug, PartialEq, Copy, Clone, Hash, Eq, PartialOrd, Ord)] pub struct InscriptionId { pub txid: Txid, pub index: u32, diff --git a/src/inscriptions/media.rs b/src/inscriptions/media.rs index 20acd04c49..69d9718f5f 100644 --- a/src/inscriptions/media.rs +++ b/src/inscriptions/media.rs @@ -1,7 +1,8 @@ use { + self::{ImageRendering::*, Language::*, Media::*}, super::*, brotli::enc::backward_references::BrotliEncoderMode::{ - self, BROTLI_MODE_FONT, BROTLI_MODE_GENERIC, BROTLI_MODE_TEXT, + self, BROTLI_MODE_FONT as FONT, BROTLI_MODE_GENERIC as GENERIC, BROTLI_MODE_TEXT as TEXT, }, mp4::{MediaType, Mp4Reader, TrackType}, std::{fs::File, io::BufReader}, @@ -13,7 +14,7 @@ pub(crate) enum Media { Code(Language), Font, Iframe, - Image, + Image(ImageRendering), Markdown, Model, Pdf, @@ -47,45 +48,65 @@ impl Display for Language { } } +#[derive(Debug, PartialEq, Copy, Clone)] +pub(crate) enum ImageRendering { + Auto, + Pixelated, +} + +impl Display for ImageRendering { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!( + f, + "{}", + match self { + Self::Auto => "auto", + Self::Pixelated => "pixelated", + } + ) + } +} + impl Media { #[rustfmt::skip] const TABLE: &'static [(&'static str, BrotliEncoderMode, Media, &'static [&'static str])] = &[ - ("application/cbor", BROTLI_MODE_GENERIC, Media::Unknown, &["cbor"]), - ("application/json", BROTLI_MODE_TEXT, Media::Code(Language::Json), &["json"]), - ("application/octet-stream", BROTLI_MODE_GENERIC, Media::Unknown, &["bin"]), - ("application/pdf", BROTLI_MODE_GENERIC, Media::Pdf, &["pdf"]), - ("application/pgp-signature", BROTLI_MODE_TEXT, Media::Text, &["asc"]), - ("application/protobuf", BROTLI_MODE_GENERIC, Media::Unknown, &["binpb"]), - ("application/x-javascript", BROTLI_MODE_TEXT, Media::Code(Language::JavaScript), &[]), - ("application/yaml", BROTLI_MODE_TEXT, Media::Code(Language::Yaml), &["yaml", "yml"]), - ("audio/flac", BROTLI_MODE_GENERIC, Media::Audio, &["flac"]), - ("audio/mpeg", BROTLI_MODE_GENERIC, Media::Audio, &["mp3"]), - ("audio/wav", BROTLI_MODE_GENERIC, Media::Audio, &["wav"]), - ("font/otf", BROTLI_MODE_GENERIC, Media::Font, &["otf"]), - ("font/ttf", BROTLI_MODE_GENERIC, Media::Font, &["ttf"]), - ("font/woff", BROTLI_MODE_GENERIC, Media::Font, &["woff"]), - ("font/woff2", BROTLI_MODE_FONT, Media::Font, &["woff2"]), - ("image/apng", BROTLI_MODE_GENERIC, Media::Image, &["apng"]), - ("image/avif", BROTLI_MODE_GENERIC, Media::Image, &["avif"]), - ("image/gif", BROTLI_MODE_GENERIC, Media::Image, &["gif"]), - ("image/jpeg", BROTLI_MODE_GENERIC, Media::Image, &["jpg", "jpeg"]), - ("image/png", BROTLI_MODE_GENERIC, Media::Image, &["png"]), - ("image/svg+xml", BROTLI_MODE_TEXT, Media::Iframe, &["svg"]), - ("image/webp", BROTLI_MODE_GENERIC, Media::Image, &["webp"]), - ("model/gltf+json", BROTLI_MODE_TEXT, Media::Model, &["gltf"]), - ("model/gltf-binary", BROTLI_MODE_GENERIC, Media::Model, &["glb"]), - ("model/stl", BROTLI_MODE_GENERIC, Media::Unknown, &["stl"]), - ("text/css", BROTLI_MODE_TEXT, Media::Code(Language::Css), &["css"]), - ("text/html", BROTLI_MODE_TEXT, Media::Iframe, &[]), - ("text/html;charset=utf-8", BROTLI_MODE_TEXT, Media::Iframe, &["html"]), - ("text/javascript", BROTLI_MODE_TEXT, Media::Code(Language::JavaScript), &["js"]), - ("text/markdown", BROTLI_MODE_TEXT, Media::Markdown, &[]), - ("text/markdown;charset=utf-8", BROTLI_MODE_TEXT, Media::Markdown, &["md"]), - ("text/plain", BROTLI_MODE_TEXT, Media::Text, &[]), - ("text/plain;charset=utf-8", BROTLI_MODE_TEXT, Media::Text, &["txt"]), - ("text/x-python", BROTLI_MODE_TEXT, Media::Code(Language::Python), &["py"]), - ("video/mp4", BROTLI_MODE_GENERIC, Media::Video, &["mp4"]), - ("video/webm", BROTLI_MODE_GENERIC, Media::Video, &["webm"]), + ("application/cbor", GENERIC, Unknown, &["cbor"]), + ("application/json", TEXT, Code(Json), &["json"]), + ("application/octet-stream", GENERIC, Unknown, &["bin"]), + ("application/pdf", GENERIC, Pdf, &["pdf"]), + ("application/pgp-signature", TEXT, Text, &["asc"]), + ("application/protobuf", GENERIC, Unknown, &["binpb"]), + ("application/x-javascript", TEXT, Code(JavaScript), &[]), + ("application/yaml", TEXT, Code(Yaml), &["yaml", "yml"]), + ("audio/flac", GENERIC, Audio, &["flac"]), + ("audio/mpeg", GENERIC, Audio, &["mp3"]), + ("audio/wav", GENERIC, Audio, &["wav"]), + ("font/otf", GENERIC, Font, &["otf"]), + ("font/ttf", GENERIC, Font, &["ttf"]), + ("font/woff", GENERIC, Font, &["woff"]), + ("font/woff2", FONT, Font, &["woff2"]), + ("image/apng", GENERIC, Image(Pixelated), &["apng"]), + ("image/avif", GENERIC, Image(Auto), &["avif"]), + ("image/gif", GENERIC, Image(Pixelated), &["gif"]), + ("image/jpeg", GENERIC, Image(Pixelated), &["jpg", "jpeg"]), + ("image/jxl", GENERIC, Image(Auto), &[]), + ("image/png", GENERIC, Image(Pixelated), &["png"]), + ("image/svg+xml", TEXT, Iframe, &["svg"]), + ("image/webp", GENERIC, Image(Pixelated), &["webp"]), + ("model/gltf+json", TEXT, Model, &["gltf"]), + ("model/gltf-binary", GENERIC, Model, &["glb"]), + ("model/stl", GENERIC, Unknown, &["stl"]), + ("text/css", TEXT, Code(Css), &["css"]), + ("text/html", TEXT, Iframe, &[]), + ("text/html;charset=utf-8", TEXT, Iframe, &["html"]), + ("text/javascript", TEXT, Code(JavaScript), &["js"]), + ("text/markdown", TEXT, Markdown, &[]), + ("text/markdown;charset=utf-8", TEXT, Markdown, &["md"]), + ("text/plain", TEXT, Text, &[]), + ("text/plain;charset=utf-8", TEXT, Text, &["txt"]), + ("text/x-python", TEXT, Code(Python), &["py"]), + ("video/mp4", GENERIC, Video, &["mp4"]), + ("video/webm", GENERIC, Video, &["webm"]), ]; pub(crate) fn content_type_for_path( diff --git a/src/lib.rs b/src/lib.rs index 47b31f4d88..af21862ef5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17,7 +17,10 @@ use { blocktime::Blocktime, config::Config, decimal::Decimal, - inscriptions::{media, teleburn, Charm, Media, ParsedEnvelope}, + inscriptions::{ + media::{self, ImageRendering, Media}, + teleburn, Charm, ParsedEnvelope, + }, representation::Representation, runes::{Etching, Pile, SpacedRune}, subcommand::{Subcommand, SubcommandResult}, @@ -49,7 +52,7 @@ use { regex::Regex, serde::{Deserialize, Deserializer, Serialize, Serializer}, std::{ - cmp, + cmp::{self, Reverse}, collections::{BTreeMap, BTreeSet, HashMap, HashSet, VecDeque}, env, fmt::{self, Display, Formatter}, @@ -58,7 +61,7 @@ use { mem, net::ToSocketAddrs, path::{Path, PathBuf}, - process, + process::{self, Command, Stdio}, str::FromStr, sync::{ atomic::{self, AtomicBool}, diff --git a/src/options.rs b/src/options.rs index 22c978e46d..ad69a44ec8 100644 --- a/src/options.rs +++ b/src/options.rs @@ -62,6 +62,12 @@ pub struct Options { help = "Do not index inscriptions." )] pub(crate) no_index_inscriptions: bool, + #[arg( + long, + requires = "username", + help = "Require basic HTTP authentication with . Credentials are sent in cleartext. Consider using authentication in conjunction with HTTPS." + )] + pub(crate) password: Option, #[arg(long, short, help = "Use regtest. Equivalent to `--chain regtest`.")] pub(crate) regtest: bool, #[arg(long, help = "Connect to Bitcoin Core RPC at .")] @@ -70,6 +76,12 @@ pub struct Options { pub(crate) signet: bool, #[arg(long, short, help = "Use testnet. Equivalent to `--chain testnet`.")] pub(crate) testnet: bool, + #[arg( + long, + requires = "password", + help = "Require basic HTTP authentication with . Credentials are sent in cleartext. Consider using authentication in conjunction with HTTPS." + )] + pub(crate) username: Option, } impl Options { @@ -141,6 +153,10 @@ impl Options { Ok(path.join(".cookie")) } + pub(crate) fn credentials(&self) -> Option<(&str, &str)> { + self.username.as_deref().zip(self.password.as_deref()) + } + fn default_data_dir() -> PathBuf { dirs::data_dir() .map(|dir| dir.join("ord")) @@ -233,14 +249,32 @@ impl Options { } let client = Client::new(&rpc_url, auth) - .with_context(|| format!("failed to connect to Bitcoin Core RPC at {rpc_url}"))?; - - let rpc_chain = match client.get_blockchain_info()?.chain.as_str() { - "main" => Chain::Mainnet, - "test" => Chain::Testnet, - "regtest" => Chain::Regtest, - "signet" => Chain::Signet, - other => bail!("Bitcoin RPC server on unknown chain: {other}"), + .with_context(|| format!("failed to connect to Bitcoin Core RPC at `{rpc_url}`"))?; + + let mut checks = 0; + let rpc_chain = loop { + match client.get_blockchain_info() { + Ok(blockchain_info) => { + break match blockchain_info.chain.as_str() { + "main" => Chain::Mainnet, + "test" => Chain::Testnet, + "regtest" => Chain::Regtest, + "signet" => Chain::Signet, + other => bail!("Bitcoin RPC server on unknown chain: {other}"), + } + } + Err(bitcoincore_rpc::Error::JsonRpc(bitcoincore_rpc::jsonrpc::Error::Rpc(err))) + if err.code == -28 => {} + Err(err) => bail!("Failed to connect to Bitcoin Core RPC at `{rpc_url}`: {err}"), + } + + ensure! { + checks < 100, + "Failed to connect to Bitcoin Core RPC at `{rpc_url}`", + } + + checks += 1; + thread::sleep(Duration::from_millis(100)); }; let ord_chain = self.chain(); diff --git a/src/runes/pile.rs b/src/runes/pile.rs index f3f004f1fc..fbf429bec5 100644 --- a/src/runes/pile.rs +++ b/src/runes/pile.rs @@ -1,6 +1,6 @@ use super::*; -#[derive(Debug, Deserialize, Serialize, PartialEq)] +#[derive(Debug, Deserialize, Serialize, PartialEq, Clone, Copy)] pub struct Pile { pub amount: u128, pub divisibility: u8, diff --git a/src/subcommand.rs b/src/subcommand.rs index 36abd81436..06c105e611 100644 --- a/src/subcommand.rs +++ b/src/subcommand.rs @@ -2,6 +2,7 @@ use super::*; pub mod balances; pub mod decode; +pub mod env; pub mod epochs; pub mod find; pub mod index; @@ -21,6 +22,8 @@ pub(crate) enum Subcommand { Balances, #[command(about = "Decode a transaction")] Decode(decode::Decode), + #[command(about = "Start a regtest ord and bitcoind instance")] + Env(env::Env), #[command(about = "List the first satoshis of each reward epoch")] Epochs, #[command(about = "Find a satoshi's current location")] @@ -52,6 +55,7 @@ impl Subcommand { match self { Self::Balances => balances::run(options), Self::Decode(decode) => decode.run(options), + Self::Env(env) => env.run(), Self::Epochs => epochs::run(), Self::Find(find) => find.run(options), Self::Index(index) => index.run(options), diff --git a/src/subcommand/env.rs b/src/subcommand/env.rs new file mode 100644 index 0000000000..f1ca3c07cd --- /dev/null +++ b/src/subcommand/env.rs @@ -0,0 +1,131 @@ +use {super::*, colored::Colorize, std::net::TcpListener}; + +struct KillOnDrop(process::Child); + +impl Drop for KillOnDrop { + fn drop(&mut self) { + assert!(Command::new("kill") + .arg(self.0.id().to_string()) + .status() + .unwrap() + .success()); + self.0.wait().unwrap(); + } +} + +#[derive(Debug, Parser)] +pub(crate) struct Env { + #[arg(default_value = "env", help = "Create env in .")] + directory: PathBuf, +} + +impl Env { + pub(crate) fn run(self) -> SubcommandResult { + let (bitcoind_port, ord_port) = ( + TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(), + TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(), + ); + + let env = std::env::current_dir()?.join(&self.directory); + + fs::create_dir_all(&env)?; + + let env_string = env + .to_str() + .with_context(|| format!("directory `{}` is not valid unicode", env.display()))?; + + let config = env.join("bitcoin.conf").to_str().unwrap().to_string(); + + fs::write( + env.join("bitcoin.conf"), + format!( + "regtest=1 +datadir={env_string} +listen=0 +txindex=1 +[regtest] +rpcport={bitcoind_port} +", + ), + )?; + + let _bitcoind = KillOnDrop( + Command::new("bitcoind") + .arg(format!("-conf={config}")) + .stdout(Stdio::null()) + .spawn()?, + ); + + loop { + if env.join("regtest/.cookie").try_exists()? { + break; + } + } + + let ord = std::env::current_exe()?; + + let rpc_url = format!("http://localhost:{bitcoind_port}"); + + let _ord = KillOnDrop( + Command::new(&ord) + .arg("--regtest") + .arg("--bitcoin-data-dir") + .arg(&env) + .arg("--data-dir") + .arg(&env) + .arg("--rpc-url") + .arg(&rpc_url) + .arg("server") + .arg("--http-port") + .arg(ord_port.to_string()) + .spawn()?, + ); + + thread::sleep(Duration::from_millis(250)); + + if !env.join("regtest/wallets/ord").try_exists()? { + let status = Command::new(&ord) + .arg("--regtest") + .arg("--bitcoin-data-dir") + .arg(&env) + .arg("--data-dir") + .arg(&env) + .arg("--rpc-url") + .arg(&rpc_url) + .arg("wallet") + .arg("create") + .status()?; + + ensure!(status.success(), "failed to create wallet: {status}"); + } + + let directory = self.directory.to_str().unwrap().to_string(); + + eprintln!( + "{} +bitcoin-cli -datadir='{directory}' getblockchaininfo +{} +{} --regtest --bitcoin-data-dir '{directory}' --data-dir '{directory}' --rpc-url '{}' wallet --server-url http://127.0.0.1:{ord_port} balance", + "Example `bitcoin-cli` command:".blue().bold(), + "Example `ord` command:".blue().bold(), + ord.display(), + rpc_url, + ); + + loop { + if SHUTTING_DOWN.load(atomic::Ordering::Relaxed) { + break Ok(None); + } + + thread::sleep(Duration::from_millis(100)); + } + } +} diff --git a/src/subcommand/server.rs b/src/subcommand/server.rs index 5ddab416d1..a3433b6bfa 100644 --- a/src/subcommand/server.rs +++ b/src/subcommand/server.rs @@ -10,12 +10,12 @@ use { templates::{ BlockHtml, BlockInfoJson, BlockJson, BlocksHtml, BlocksJson, ChildrenHtml, ChildrenJson, ClockSvg, CollectionsHtml, HomeHtml, InputHtml, InscriptionHtml, InscriptionJson, - InscriptionsBlockHtml, InscriptionsHtml, InscriptionsJson, OutputHtml, OutputJson, - PageContent, PageHtml, PreviewAudioHtml, PreviewCodeHtml, PreviewFontHtml, PreviewImageHtml, - PreviewMarkdownHtml, PreviewModelHtml, PreviewPdfHtml, PreviewTextHtml, PreviewUnknownHtml, - PreviewVideoHtml, RangeHtml, RareTxt, RuneBalancesHtml, RuneHtml, RuneJson, RunesHtml, - RunesJson, SatHtml, SatInscriptionJson, SatInscriptionsJson, SatJson, TransactionHtml, - TransactionJson, + InscriptionRecursiveJson, InscriptionsBlockHtml, InscriptionsHtml, InscriptionsJson, + OutputHtml, OutputJson, PageContent, PageHtml, PreviewAudioHtml, PreviewCodeHtml, + PreviewFontHtml, PreviewImageHtml, PreviewMarkdownHtml, PreviewModelHtml, PreviewPdfHtml, + PreviewTextHtml, PreviewUnknownHtml, PreviewVideoHtml, RangeHtml, RareTxt, RuneBalancesHtml, + RuneHtml, RuneJson, RunesHtml, RunesJson, SatHtml, SatInscriptionJson, SatInscriptionsJson, + SatJson, TransactionHtml, TransactionJson, }, }, axum::{ @@ -41,6 +41,7 @@ use { compression::CompressionLayer, cors::{Any, CorsLayer}, set_header::SetResponseHeaderLayer, + validate_request::ValidateRequestHeaderLayer, }, }; @@ -150,6 +151,13 @@ pub struct Server { help = "Use in Content-Security-Policy header. Set this to the public-facing URL of your ord instance." )] pub(crate) csp_origin: Option, + #[arg( + long, + help = "Decompress encoded content. Currently only supports brotli. Be careful using this on production instances. A decompressed inscription may be arbitrarily large, making decompression a DoS vector." + )] + pub(crate) decompress: bool, + #[arg(long, help = "Disable JSON API.")] + pub(crate) disable_json_api: bool, #[arg( long, help = "Listen on for incoming HTTP requests. [default: 80]" @@ -171,15 +179,14 @@ pub struct Server { pub(crate) https: bool, #[arg(long, help = "Redirect HTTP traffic to HTTPS.")] pub(crate) redirect_http_to_https: bool, - #[arg(long, help = "Disable JSON API.")] - pub(crate) disable_json_api: bool, + #[arg(long, alias = "nosync", help = "Do not update the index.")] + pub(crate) no_sync: bool, #[arg( long, - help = "Decompress encoded content. Currently only supports brotli. Be careful using this on production instances. A decompressed inscription may be arbitrarily large, making decompression a DoS vector." + default_value = "5s", + help = "Poll Bitcoin Core every ." )] - pub(crate) decompress: bool, - #[arg(long, alias = "nosync", help = "Do not update the index.")] - pub(crate) no_sync: bool, + pub(crate) polling_interval: humantime::Duration, } impl Server { @@ -198,11 +205,11 @@ impl Server { } } - if integration_test() { - thread::sleep(Duration::from_millis(100)); + thread::sleep(if integration_test() { + Duration::from_millis(100) } else { - thread::sleep(Duration::from_millis(5000)); - } + self.polling_interval.into() + }); }); INDEXER.lock().unwrap().replace(index_thread); @@ -265,6 +272,10 @@ impl Server { .route("/r/blockheight", get(Self::block_height)) .route("/r/blocktime", get(Self::block_time)) .route("/r/blockinfo/:query", get(Self::block_info)) + .route( + "/r/inscription/:inscription_id", + get(Self::inscription_recursive), + ) .route("/r/children/:inscription_id", get(Self::children_recursive)) .route( "/r/children/:inscription_id/:page", @@ -310,6 +321,12 @@ impl Server { .layer(CompressionLayer::new()) .with_state(server_config); + let router = if let Some((username, password)) = options.credentials() { + router.layer(ValidateRequestHeaderLayer::basic(username, password)) + } else { + router + }; + match (self.http_port(), self.https_port()) { (Some(http_port), None) => { self @@ -860,6 +877,65 @@ impl Server { }) } + async fn inscription_recursive( + Extension(index): Extension>, + Path(inscription_id): Path, + ) -> ServerResult { + task::block_in_place(|| { + let inscription = index + .get_inscription_by_id(inscription_id)? + .ok_or_not_found(|| format!("inscription {inscription_id}"))?; + + let entry = index + .get_inscription_entry(inscription_id) + .unwrap() + .unwrap(); + + let satpoint = index + .get_inscription_satpoint_by_id(inscription_id) + .ok() + .flatten() + .unwrap(); + + let output = if satpoint.outpoint == unbound_outpoint() { + None + } else { + Some( + index + .get_transaction(satpoint.outpoint.txid)? + .ok_or_not_found(|| format!("inscription {inscription_id} current transaction"))? + .output + .into_iter() + .nth(satpoint.outpoint.vout.try_into().unwrap()) + .ok_or_not_found(|| { + format!("inscription {inscription_id} current transaction output") + })?, + ) + }; + + Ok( + Json(InscriptionRecursiveJson { + charms: Charm::ALL + .iter() + .filter(|charm| charm.is_set(entry.charms)) + .map(|charm| charm.title().into()) + .collect(), + content_type: inscription.content_type().map(|s| s.to_string()), + content_length: inscription.content_length(), + fee: entry.fee, + height: entry.height, + number: entry.inscription_number, + output: satpoint.outpoint, + value: output.as_ref().map(|o| o.value), + sat: entry.sat, + satpoint, + timestamp: timestamp(entry.timestamp).timestamp(), + }) + .into_response(), + ) + }) + } + async fn status( Extension(server_config): Extension>, Extension(index): Extension>, @@ -1306,13 +1382,16 @@ impl Server { .ok_or_not_found(|| format!("inscription {inscription_id} content"))? .into_response(), ), - Media::Image => Ok( + Media::Image(image_rendering) => Ok( ( [( header::CONTENT_SECURITY_POLICY, "default-src 'self' 'unsafe-inline'", )], - PreviewImageHtml { inscription_id }, + PreviewImageHtml { + image_rendering, + inscription_id, + }, ) .into_response(), ), @@ -2726,21 +2805,55 @@ mod tests { #[test] fn status() { - let test_server = TestServer::new(); + let server = TestServer::new_with_regtest(); - test_server.assert_response_regex( + server.mine_blocks(3); + + server.bitcoin_rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[( + 1, + 0, + 0, + inscription("text/plain;charset=utf-8", "hello").to_witness(), + )], + ..Default::default() + }); + + server.bitcoin_rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[( + 2, + 0, + 0, + inscription("text/plain;charset=utf-8", "hello").to_witness(), + )], + ..Default::default() + }); + + server.bitcoin_rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[( + 3, + 0, + 0, + Inscription::new(None, Some("hello".as_bytes().into())).to_witness(), + )], + ..Default::default() + }); + + server.mine_blocks(1); + + server.assert_response_regex( "/status", StatusCode::OK, ".*

Status

chain
-
mainnet
+
regtest
height
-
0
+
4
inscriptions
-
0
+
3
blessed inscriptions
-
0
+
3
cursed inscriptions
0
runes
@@ -2752,7 +2865,7 @@ mod tests {
uptime
.*
minimum rune for next block
-
AAAAAAAAAAAAA
+
.*
version
.*
unrecoverably reorged
@@ -2771,6 +2884,15 @@ mod tests { [[:xdigit:]]{40} +
inscription content types
+
+
+
text/plain;charset=utf-8
+
2 +
none
+
1 +
+
.*", ); @@ -5299,4 +5421,14 @@ next }, ) } + + #[test] + fn authentication_requires_username_and_password() { + assert!(Arguments::try_parse_from(["ord", "--username", "server", "foo"]).is_err()); + assert!(Arguments::try_parse_from(["ord", "--password", "server", "bar"]).is_err()); + assert!( + Arguments::try_parse_from(["ord", "--username", "foo", "--password", "bar", "server"]) + .is_ok() + ); + } } diff --git a/src/subcommand/wallet.rs b/src/subcommand/wallet.rs index e8a78669c5..8a54219d63 100644 --- a/src/subcommand/wallet.rs +++ b/src/subcommand/wallet.rs @@ -71,27 +71,32 @@ pub(crate) enum Subcommand { impl WalletCommand { pub(crate) fn run(self, options: Options) -> SubcommandResult { - let wallet = Wallet { - name: self.name.clone(), - no_sync: self.no_sync, - options, - ord_url: self.server_url, + match self.subcommand { + Subcommand::Create(create) => return create.run(self.name, &options), + Subcommand::Restore(restore) => return restore.run(self.name, &options), + _ => {} }; + let wallet = Wallet::build( + self.name.clone(), + self.no_sync, + options.clone(), + self.server_url, + )?; + match self.subcommand { Subcommand::Balance => balance::run(wallet), - Subcommand::Create(create) => create.run(wallet), Subcommand::Dump => dump::run(wallet), Subcommand::Etch(etch) => etch.run(wallet), Subcommand::Inscribe(inscribe) => inscribe.run(wallet), Subcommand::Inscriptions => inscriptions::run(wallet), Subcommand::Receive => receive::run(wallet), - Subcommand::Restore(restore) => restore.run(wallet), Subcommand::Sats(sats) => sats.run(wallet), Subcommand::Send(send) => send.run(wallet), Subcommand::Transactions(transactions) => transactions.run(wallet), Subcommand::Outputs => outputs::run(wallet), Subcommand::Cardinals => cardinals::run(wallet), + Subcommand::Create(_) | Subcommand::Restore(_) => unreachable!(), } } } diff --git a/src/subcommand/wallet/balance.rs b/src/subcommand/wallet/balance.rs index bc2b8be9bd..475559aab1 100644 --- a/src/subcommand/wallet/balance.rs +++ b/src/subcommand/wallet/balance.rs @@ -12,10 +12,10 @@ pub struct Output { } pub(crate) fn run(wallet: Wallet) -> SubcommandResult { - let unspent_outputs = wallet.get_unspent_outputs()?; + let unspent_outputs = wallet.utxos(); let inscription_outputs = wallet - .get_inscriptions()? + .inscriptions() .keys() .map(|satpoint| satpoint.outpoint) .collect::>(); @@ -26,9 +26,9 @@ pub(crate) fn run(wallet: Wallet) -> SubcommandResult { let mut runic = 0; for (output, txout) in unspent_outputs { - let rune_balances = wallet.get_runes_balances_for_output(&output)?; + let rune_balances = wallet.get_runes_balances_for_output(output)?; - if inscription_outputs.contains(&output) { + if inscription_outputs.contains(output) { ordinal += txout.value; } else if !rune_balances.is_empty() { for (spaced_rune, pile) in rune_balances { @@ -43,8 +43,8 @@ pub(crate) fn run(wallet: Wallet) -> SubcommandResult { Ok(Some(Box::new(Output { cardinal, ordinal, - runes: wallet.has_rune_index()?.then_some(runes), - runic: wallet.has_rune_index()?.then_some(runic), + runes: wallet.has_rune_index().then_some(runes), + runic: wallet.has_rune_index().then_some(runic), total: cardinal + ordinal + runic, }))) } diff --git a/src/subcommand/wallet/cardinals.rs b/src/subcommand/wallet/cardinals.rs index 7fca308286..d441a94831 100644 --- a/src/subcommand/wallet/cardinals.rs +++ b/src/subcommand/wallet/cardinals.rs @@ -7,10 +7,10 @@ pub struct CardinalUtxo { } pub(crate) fn run(wallet: Wallet) -> SubcommandResult { - let unspent_outputs = wallet.get_unspent_outputs()?; + let unspent_outputs = wallet.utxos(); let inscribed_utxos = wallet - .get_inscriptions()? + .inscriptions() .keys() .map(|satpoint| satpoint.outpoint) .collect::>(); diff --git a/src/subcommand/wallet/create.rs b/src/subcommand/wallet/create.rs index 4a99c4d991..10612704c8 100644 --- a/src/subcommand/wallet/create.rs +++ b/src/subcommand/wallet/create.rs @@ -20,13 +20,13 @@ pub(crate) struct Create { } impl Create { - pub(crate) fn run(self, wallet: Wallet) -> SubcommandResult { + pub(crate) fn run(self, name: String, options: &Options) -> SubcommandResult { let mut entropy = [0; 16]; rand::thread_rng().fill_bytes(&mut entropy); let mnemonic = Mnemonic::from_entropy(&entropy)?; - wallet.initialize(mnemonic.to_seed(&self.passphrase))?; + Wallet::initialize(name, options, mnemonic.to_seed(&self.passphrase))?; Ok(Some(Box::new(Output { mnemonic, diff --git a/src/subcommand/wallet/dump.rs b/src/subcommand/wallet/dump.rs index bd9641b8d4..16cf1393b5 100644 --- a/src/subcommand/wallet/dump.rs +++ b/src/subcommand/wallet/dump.rs @@ -9,6 +9,6 @@ pub(crate) fn run(wallet: Wallet) -> SubcommandResult { ); Ok(Some(Box::new( - wallet.bitcoin_client()?.list_descriptors(Some(true))?, + wallet.bitcoin_client().list_descriptors(Some(true))?, ))) } diff --git a/src/subcommand/wallet/etch.rs b/src/subcommand/wallet/etch.rs index d3dc5659a3..f1f2d04bb2 100644 --- a/src/subcommand/wallet/etch.rs +++ b/src/subcommand/wallet/etch.rs @@ -23,13 +23,13 @@ pub struct Output { impl Etch { pub(crate) fn run(self, wallet: Wallet) -> SubcommandResult { ensure!( - wallet.has_rune_index()?, + wallet.has_rune_index(), "`ord wallet etch` requires index created with `--index-runes` flag", ); let SpacedRune { rune, spacers } = self.rune; - let bitcoin_client = wallet.bitcoin_client()?; + let bitcoin_client = wallet.bitcoin_client(); let count = bitcoin_client.get_block_count()?; @@ -99,7 +99,7 @@ impl Etch { }; let inscriptions = wallet - .get_inscriptions()? + .inscriptions() .keys() .map(|satpoint| satpoint.outpoint) .collect::>(); @@ -109,7 +109,7 @@ impl Etch { } let unsigned_transaction = - fund_raw_transaction(&bitcoin_client, self.fee_rate, &unfunded_transaction)?; + fund_raw_transaction(bitcoin_client, self.fee_rate, &unfunded_transaction)?; let signed_transaction = bitcoin_client .sign_raw_transaction_with_wallet(&unsigned_transaction, None, None)? diff --git a/src/subcommand/wallet/inscribe.rs b/src/subcommand/wallet/inscribe.rs index baf3b1f728..b35e7ea39a 100644 --- a/src/subcommand/wallet/inscribe.rs +++ b/src/subcommand/wallet/inscribe.rs @@ -74,9 +74,9 @@ impl Inscribe { pub(crate) fn run(self, wallet: Wallet) -> SubcommandResult { let metadata = Inscribe::parse_metadata(self.cbor_metadata, self.json_metadata)?; - let utxos = wallet.get_unspent_outputs()?; + let utxos = wallet.utxos(); - let mut locked_utxos = wallet.get_locked_outputs()?; + let mut locked_utxos = wallet.locked_utxos().clone(); let runic_utxos = wallet.get_runic_outputs()?; @@ -91,7 +91,7 @@ impl Inscribe { let satpoint = match (self.file, self.batch) { (Some(file), None) => { - parent_info = wallet.get_parent_info(self.parent, &utxos)?; + parent_info = wallet.get_parent_info(self.parent)?; postages = vec![self.postage.unwrap_or(TARGET_POSTAGE)]; @@ -123,7 +123,7 @@ impl Inscribe { }]; if let Some(sat) = self.sat { - Some(wallet.find_sat_in_outputs(sat, &utxos)?) + Some(wallet.find_sat_in_outputs(sat)?) } else { self.satpoint } @@ -131,11 +131,11 @@ impl Inscribe { (None, Some(batch)) => { let batchfile = Batchfile::load(&batch)?; - parent_info = wallet.get_parent_info(batchfile.parent, &utxos)?; + parent_info = wallet.get_parent_info(batchfile.parent)?; (inscriptions, reveal_satpoints, postages, destinations) = batchfile.inscriptions( &wallet, - &utxos, + utxos, parent_info.as_ref().map(|info| info.tx_out.value), self.compress, )?; @@ -143,13 +143,13 @@ impl Inscribe { locked_utxos.extend( reveal_satpoints .iter() - .map(|(satpoint, _)| satpoint.outpoint), + .map(|(satpoint, txout)| (satpoint.outpoint, txout.clone())), ); mode = batchfile.mode; if let Some(sat) = batchfile.sat { - Some(wallet.find_sat_in_outputs(sat, &utxos)?) + Some(wallet.find_sat_in_outputs(sat)?) } else { batchfile.satpoint } @@ -172,7 +172,12 @@ impl Inscribe { reveal_satpoints, satpoint, } - .inscribe(&locked_utxos, runic_utxos, &utxos, &wallet) + .inscribe( + &locked_utxos.into_keys().collect(), + runic_utxos, + utxos, + &wallet, + ) } fn parse_metadata(cbor: Option, json: Option) -> Result>> { diff --git a/src/subcommand/wallet/inscriptions.rs b/src/subcommand/wallet/inscriptions.rs index 5a01cdea55..e726fefb7f 100644 --- a/src/subcommand/wallet/inscriptions.rs +++ b/src/subcommand/wallet/inscriptions.rs @@ -9,10 +9,6 @@ pub struct Output { } pub(crate) fn run(wallet: Wallet) -> SubcommandResult { - let unspent_outputs = wallet.get_unspent_outputs()?; - - let inscriptions = wallet.get_inscriptions()?; - let explorer = match wallet.chain() { Chain::Mainnet => "https://ordinals.com/inscription/", Chain::Regtest => "http://localhost/inscription/", @@ -22,12 +18,12 @@ pub(crate) fn run(wallet: Wallet) -> SubcommandResult { let mut output = Vec::new(); - for (location, inscriptions) in inscriptions { - if let Some(txout) = unspent_outputs.get(&location.outpoint) { + for (location, inscriptions) in wallet.inscriptions() { + if let Some(txout) = wallet.utxos().get(&location.outpoint) { for inscription in inscriptions { output.push(Output { - location, - inscription, + location: *location, + inscription: *inscription, explorer: format!("{explorer}{inscription}"), postage: txout.value, }) diff --git a/src/subcommand/wallet/outputs.rs b/src/subcommand/wallet/outputs.rs index 5adf445e4a..a5aa925854 100644 --- a/src/subcommand/wallet/outputs.rs +++ b/src/subcommand/wallet/outputs.rs @@ -8,9 +8,9 @@ pub struct Output { pub(crate) fn run(wallet: Wallet) -> SubcommandResult { let mut outputs = Vec::new(); - for (output, txout) in wallet.get_unspent_outputs()? { + for (output, txout) in wallet.utxos() { outputs.push(Output { - output, + output: *output, amount: txout.value, }); } diff --git a/src/subcommand/wallet/receive.rs b/src/subcommand/wallet/receive.rs index 669540f6db..70d650f277 100644 --- a/src/subcommand/wallet/receive.rs +++ b/src/subcommand/wallet/receive.rs @@ -7,7 +7,7 @@ pub struct Output { pub(crate) fn run(wallet: Wallet) -> SubcommandResult { let address = wallet - .bitcoin_client()? + .bitcoin_client() .get_new_address(None, Some(bitcoincore_rpc::json::AddressType::Bech32m))?; Ok(Some(Box::new(Output { address }))) diff --git a/src/subcommand/wallet/restore.rs b/src/subcommand/wallet/restore.rs index a67e3f1778..ed58ab8c7a 100644 --- a/src/subcommand/wallet/restore.rs +++ b/src/subcommand/wallet/restore.rs @@ -15,8 +15,16 @@ enum Source { } impl Restore { - pub(crate) fn run(self, wallet: Wallet) -> SubcommandResult { - ensure!(!wallet.exists()?, "wallet `{}` already exists", wallet.name); + pub(crate) fn run(self, name: String, options: &Options) -> SubcommandResult { + ensure!( + !options + .bitcoin_rpc_client(None)? + .list_wallet_dir()? + .iter() + .any(|wallet_name| wallet_name == &name), + "wallet `{}` already exists", + name + ); let mut buffer = String::new(); io::stdin().read_to_string(&mut buffer)?; @@ -28,11 +36,15 @@ impl Restore { "descriptor does not take a passphrase" ); let wallet_descriptors: ListDescriptorsResult = serde_json::from_str(&buffer)?; - wallet.initialize_from_descriptors(wallet_descriptors.descriptors)?; + Wallet::initialize_from_descriptors(name, options, wallet_descriptors.descriptors)?; } Source::Mnemonic => { let mnemonic = Mnemonic::from_str(&buffer)?; - wallet.initialize(mnemonic.to_seed(self.passphrase.unwrap_or_default()))?; + Wallet::initialize( + name, + options, + mnemonic.to_seed(self.passphrase.unwrap_or_default()), + )?; } } diff --git a/src/subcommand/wallet/sats.rs b/src/subcommand/wallet/sats.rs index 4e86533698..d7ef27e4a9 100644 --- a/src/subcommand/wallet/sats.rs +++ b/src/subcommand/wallet/sats.rs @@ -26,7 +26,7 @@ pub struct OutputRare { impl Sats { pub(crate) fn run(&self, wallet: Wallet) -> SubcommandResult { ensure!( - wallet.has_sat_index()?, + wallet.has_sat_index(), "sats requires index created with `--index-sats` flag" ); diff --git a/src/subcommand/wallet/send.rs b/src/subcommand/wallet/send.rs index a7da437b7a..5ffe971b3e 100644 --- a/src/subcommand/wallet/send.rs +++ b/src/subcommand/wallet/send.rs @@ -49,7 +49,11 @@ impl Send { Outgoing::InscriptionId(id) => Self::create_unsigned_send_satpoint_transaction( &wallet, address, - wallet.get_inscription_satpoint(id)?, + wallet + .inscription_info() + .get(&id) + .ok_or_else(|| anyhow!("inscription {id} not found"))? + .satpoint, self.postage, self.fee_rate, true, @@ -64,11 +68,11 @@ impl Send { )?, }; - let bitcoin_client = wallet.bitcoin_client()?; - let unspent_outputs = wallet.get_unspent_outputs()?; + let unspent_outputs = wallet.utxos(); let (txid, psbt) = if self.dry_run { - let psbt = bitcoin_client + let psbt = wallet + .bitcoin_client() .wallet_process_psbt( &base64::engine::general_purpose::STANDARD .encode(Psbt::from_unsigned_tx(unsigned_transaction.clone())?.serialize()), @@ -80,7 +84,8 @@ impl Send { (unsigned_transaction.txid(), psbt) } else { - let psbt = bitcoin_client + let psbt = wallet + .bitcoin_client() .wallet_process_psbt( &base64::engine::general_purpose::STANDARD .encode(Psbt::from_unsigned_tx(unsigned_transaction.clone())?.serialize()), @@ -90,12 +95,16 @@ impl Send { )? .psbt; - let signed_tx = bitcoin_client + let signed_tx = wallet + .bitcoin_client() .finalize_psbt(&psbt, None)? .hex .ok_or_else(|| anyhow!("unable to sign transaction"))?; - (bitcoin_client.send_raw_transaction(&signed_tx)?, psbt) + ( + wallet.bitcoin_client().send_raw_transaction(&signed_tx)?, + psbt, + ) }; Ok(Some(Box::new(Output { @@ -149,12 +158,12 @@ impl Send { amount: Amount, fee_rate: FeeRate, ) -> Result { - let client = wallet.bitcoin_client()?; - let unspent_outputs = wallet.get_unspent_outputs()?; - let inscriptions = wallet.get_inscriptions()?; - let runic_outputs = wallet.get_runic_outputs()?; - - Self::lock_non_cardinal_outputs(&client, &inscriptions, &runic_outputs, &unspent_outputs)?; + Self::lock_non_cardinal_outputs( + wallet.bitcoin_client(), + wallet.inscriptions(), + &wallet.get_runic_outputs()?, + wallet.utxos(), + )?; let unfunded_transaction = Transaction { version: 2, @@ -167,7 +176,7 @@ impl Send { }; let unsigned_transaction = consensus::encode::deserialize(&fund_raw_transaction( - &client, + wallet.bitcoin_client(), fee_rate, &unfunded_transaction, )?)?; @@ -183,19 +192,16 @@ impl Send { fee_rate: FeeRate, sending_inscription: bool, ) -> Result { - let unspent_outputs = wallet.get_unspent_outputs()?; - let locked_outputs = wallet.get_locked_outputs()?; - let inscriptions = wallet.get_inscriptions()?; - let runic_outputs = wallet.get_runic_outputs()?; - if !sending_inscription { - for inscription_satpoint in inscriptions.keys() { + for inscription_satpoint in wallet.inscriptions().keys() { if satpoint == *inscription_satpoint { bail!("inscriptions must be sent by inscription ID"); } } } + let runic_outputs = wallet.get_runic_outputs()?; + ensure!( !runic_outputs.contains(&satpoint.outpoint), "runic outpoints may not be sent by satpoint" @@ -212,9 +218,9 @@ impl Send { Ok( TransactionBuilder::new( satpoint, - inscriptions, - unspent_outputs.clone(), - locked_outputs, + wallet.inscriptions().clone(), + wallet.utxos().clone(), + wallet.locked_utxos().clone().into_keys().collect(), runic_outputs, destination.clone(), change, @@ -233,20 +239,20 @@ impl Send { fee_rate: FeeRate, ) -> Result { ensure!( - wallet.has_rune_index()?, + wallet.has_rune_index(), "sending runes with `ord send` requires index created with `--index-runes` flag", ); - let unspent_outputs = wallet.get_unspent_outputs()?; - let inscriptions = wallet.get_inscriptions()?; + let unspent_outputs = wallet.utxos(); + let inscriptions = wallet.inscriptions(); let runic_outputs = wallet.get_runic_outputs()?; - let bitcoin_client = wallet.bitcoin_client()?; + let bitcoin_client = wallet.bitcoin_client(); Self::lock_non_cardinal_outputs( - &bitcoin_client, - &inscriptions, + bitcoin_client, + inscriptions, &runic_outputs, - &unspent_outputs, + unspent_outputs, )?; let (id, entry, _parent) = wallet @@ -329,7 +335,7 @@ impl Send { }; let unsigned_transaction = - fund_raw_transaction(&bitcoin_client, fee_rate, &unfunded_transaction)?; + fund_raw_transaction(bitcoin_client, fee_rate, &unfunded_transaction)?; Ok(consensus::encode::deserialize(&unsigned_transaction)?) } diff --git a/src/subcommand/wallet/transactions.rs b/src/subcommand/wallet/transactions.rs index f0ad7d9057..cc0f20211a 100644 --- a/src/subcommand/wallet/transactions.rs +++ b/src/subcommand/wallet/transactions.rs @@ -14,7 +14,7 @@ pub struct Output { impl Transactions { pub(crate) fn run(self, wallet: Wallet) -> SubcommandResult { - let client = wallet.bitcoin_client()?; + let client = wallet.bitcoin_client(); let mut output = Vec::new(); for tx in client.list_transactions( diff --git a/src/templates.rs b/src/templates.rs index 39642b4210..2dedc4bb12 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -9,7 +9,7 @@ pub(crate) use { home::HomeHtml, iframe::Iframe, input::InputHtml, - inscription::{InscriptionHtml, InscriptionJson}, + inscription::{InscriptionHtml, InscriptionJson, InscriptionRecursiveJson}, inscriptions::{InscriptionsHtml, InscriptionsJson}, inscriptions_block::InscriptionsBlockHtml, metadata::MetadataHtml, diff --git a/src/templates/inscription.rs b/src/templates/inscription.rs index 2470424a35..b23d5dd890 100644 --- a/src/templates/inscription.rs +++ b/src/templates/inscription.rs @@ -21,6 +21,21 @@ pub(crate) struct InscriptionHtml { } #[derive(Debug, PartialEq, Serialize, Deserialize)] +pub struct InscriptionRecursiveJson { + pub charms: Vec, + pub content_type: Option, + pub content_length: Option, + pub fee: u64, + pub height: u32, + pub number: i32, + pub output: OutPoint, + pub sat: Option, + pub satpoint: SatPoint, + pub timestamp: i64, + pub value: Option, +} + +#[derive(Debug, PartialEq, Serialize, Deserialize, Clone)] pub struct InscriptionJson { pub address: Option, pub charms: Vec, diff --git a/src/templates/preview.rs b/src/templates/preview.rs index d6931415c0..4dd9e5f21c 100644 --- a/src/templates/preview.rs +++ b/src/templates/preview.rs @@ -18,6 +18,7 @@ pub(crate) struct PreviewFontHtml { #[derive(Boilerplate)] pub(crate) struct PreviewImageHtml { + pub(crate) image_rendering: ImageRendering, pub(crate) inscription_id: InscriptionId, } diff --git a/src/templates/status.rs b/src/templates/status.rs index 78a2046c25..b36fda057e 100644 --- a/src/templates/status.rs +++ b/src/templates/status.rs @@ -5,8 +5,9 @@ pub type StatusJson = StatusHtml; #[derive(Boilerplate, Debug, PartialEq, Serialize, Deserialize)] pub struct StatusHtml { pub blessed_inscriptions: u64, - pub cursed_inscriptions: u64, pub chain: Chain, + pub content_type_counts: Vec<(Option>, u64)>, + pub cursed_inscriptions: u64, pub height: Option, pub inscriptions: u64, pub lost_sats: u64, diff --git a/src/wallet.rs b/src/wallet.rs index bccbc8b74b..2b5b78c698 100644 --- a/src/wallet.rs +++ b/src/wallet.rs @@ -7,7 +7,10 @@ use { }, bitcoincore_rpc::bitcoincore_rpc_json::{Descriptor, ImportDescriptors, Timestamp}, fee_rate::FeeRate, - http::StatusCode, + futures::{ + future::{self, FutureExt}, + try_join, TryFutureExt, + }, inscribe::ParentInfo, miniscript::descriptor::{DescriptorSecretKey, DescriptorXKey, Wildcard}, reqwest::{header, Url}, @@ -17,72 +20,175 @@ use { pub mod inscribe; pub mod transaction_builder; -pub(crate) struct Wallet { - pub(crate) name: String, - pub(crate) no_sync: bool, - pub(crate) options: Options, - pub(crate) ord_url: Url, +#[derive(Clone)] +struct OrdClient { + url: Url, + client: reqwest::Client, } -impl Wallet { - pub(crate) fn bitcoin_client(&self) -> Result { - let client = check_version(self.options.bitcoin_rpc_client(Some(self.name.clone()))?)?; - - if !client.list_wallets()?.contains(&self.name) { - client.load_wallet(&self.name)?; - } - - self.check_descriptors(client.list_descriptors(None)?.descriptors)?; - - Ok(client) +impl OrdClient { + pub async fn get(&self, path: &str) -> Result { + let url = self.url.join(path)?; + self + .client + .get(url) + .send() + .map_err(|err| anyhow!(err)) + .await } +} + +pub(crate) struct Wallet { + rpc_url: Url, + options: Options, + bitcoin_client: bitcoincore_rpc::Client, + ord_client: reqwest::blocking::Client, + has_sat_index: bool, + has_rune_index: bool, + utxos: BTreeMap, + locked_utxos: BTreeMap, + output_info: BTreeMap, + inscriptions: BTreeMap>, + inscription_info: BTreeMap, +} - pub(crate) fn ord_client(&self) -> Result { +impl Wallet { + pub(crate) fn build(name: String, no_sync: bool, options: Options, rpc_url: Url) -> Result { let mut headers = header::HeaderMap::new(); + headers.insert( header::ACCEPT, header::HeaderValue::from_static("application/json"), ); - let client = reqwest::blocking::ClientBuilder::new() - .default_headers(headers) - .build() - .map_err(|err| anyhow!(err))?; + if let Some((username, password)) = options.credentials() { + use base64::Engine; + let credentials = + base64::engine::general_purpose::STANDARD.encode(format!("{username}:{password}")); + headers.insert( + header::AUTHORIZATION, + header::HeaderValue::from_str(&format!("Basic {credentials}")).unwrap(), + ); + } - let chain_block_count = self.bitcoin_client()?.get_block_count().unwrap() + 1; + let ord_client = reqwest::blocking::ClientBuilder::new() + .default_headers(headers.clone()) + .build()?; - if !self.no_sync { - for i in 0.. { - let response = client - .get(self.ord_url.join("/blockcount").unwrap()) - .send()?; + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()? + .block_on(async move { + let bitcoin_client = { + let client = Self::check_version(options.bitcoin_rpc_client(Some(name.clone()))?)?; - assert_eq!(response.status(), StatusCode::OK); + if !client.list_wallets()?.contains(&name) { + client.load_wallet(&name)?; + } - if response.text()?.parse::().unwrap() >= chain_block_count { - break; - } else if i == 20 { - bail!("wallet failed to synchronize with ord server"); + Self::check_descriptors(&name, client.list_descriptors(None)?.descriptors)?; + + client + }; + + let async_ord_client = OrdClient { + url: rpc_url.clone(), + client: reqwest::ClientBuilder::new() + .default_headers(headers.clone()) + .build()?, + }; + + let chain_block_count = bitcoin_client.get_block_count().unwrap() + 1; + + if !no_sync { + for i in 0.. { + let response = async_ord_client.get("/blockcount").await?; + if response.text().await?.parse::().unwrap() >= chain_block_count { + break; + } else if i == 20 { + bail!("wallet failed to synchronize with ord server"); + } + tokio::time::sleep(Duration::from_millis(50)).await; + } } - thread::sleep(Duration::from_millis(50)); - } - } + let mut utxos = Self::get_utxos(&bitcoin_client)?; + let locked_utxos = Self::get_locked_utxos(&bitcoin_client)?; + utxos.extend(locked_utxos.clone()); + + let requests = utxos + .clone() + .into_keys() + .map(|output| (output, Self::get_output(&async_ord_client, output))) + .collect::>(); - Ok(client) + let futures = requests.into_iter().map(|(output, req)| async move { + let result = req.await; + (output, result) + }); + + let results = future::join_all(futures).await; + + let mut output_info = BTreeMap::new(); + for (output, result) in results { + let info = result?; + output_info.insert(output, info); + } + + let requests = output_info + .iter() + .flat_map(|(_output, info)| info.inscriptions.clone()) + .collect::>() + .into_iter() + .map(|id| (id, Self::get_inscription_info(&async_ord_client, id))) + .collect::>(); + + let futures = requests.into_iter().map(|(output, req)| async move { + let result = req.await; + (output, result) + }); + + let (results, status) = try_join!( + future::join_all(futures).map(Ok), + Self::get_server_status(&async_ord_client) + )?; + + let mut inscriptions = BTreeMap::new(); + let mut inscription_info = BTreeMap::new(); + for (id, result) in results { + let info = result?; + inscriptions + .entry(info.satpoint) + .or_insert_with(Vec::new) + .push(id); + + inscription_info.insert(id, info); + } + + Ok(Wallet { + options, + rpc_url, + bitcoin_client, + ord_client, + has_sat_index: status.sat_index, + has_rune_index: status.rune_index, + utxos, + locked_utxos, + output_info, + inscriptions, + inscription_info, + }) + }) } - fn get_output(&self, output: &OutPoint) -> Result { - let response = self - .ord_client()? - .get(self.ord_url.join(&format!("/output/{output}")).unwrap()) - .send()?; + async fn get_output(ord_client: &OrdClient, output: OutPoint) -> Result { + let response = ord_client.get(&format!("/output/{output}")).await?; if !response.status().is_success() { - bail!("wallet failed get output: {}", response.text()?); + bail!("wallet failed get output: {}", response.text().await?); } - let output_json: OutputJson = serde_json::from_str(&response.text()?)?; + let output_json: OutputJson = serde_json::from_str(&response.text().await?)?; if !output_json.indexed { bail!("output in wallet but not in ord server: {output}"); @@ -91,11 +197,9 @@ impl Wallet { Ok(output_json) } - pub(crate) fn get_unspent_outputs(&self) -> Result> { - let mut utxos = BTreeMap::new(); - utxos.extend( - self - .bitcoin_client()? + fn get_utxos(bitcoin_client: &bitcoincore_rpc::Client) -> Result> { + Ok( + bitcoin_client .list_unspent(None, None, None, None, None)? .into_iter() .map(|utxo| { @@ -106,39 +210,73 @@ impl Wallet { }; (outpoint, txout) - }), - ); - - let locked_utxos: BTreeSet = self.get_locked_outputs()?; + }) + .collect(), + ) + } - for outpoint in locked_utxos { - utxos.insert( - outpoint, - self - .bitcoin_client()? - .get_raw_transaction(&outpoint.txid, None)? - .output[TryInto::::try_into(outpoint.vout).unwrap()] - .clone(), - ); + fn get_locked_utxos( + bitcoin_client: &bitcoincore_rpc::Client, + ) -> Result> { + #[derive(Deserialize)] + pub(crate) struct JsonOutPoint { + txid: bitcoin::Txid, + vout: u32, } - for output in utxos.keys() { - self.get_output(output)?; + let outpoints = bitcoin_client.call::>("listlockunspent", &[])?; + + let mut utxos = BTreeMap::new(); + + for outpoint in outpoints { + let txout = bitcoin_client + .get_raw_transaction(&outpoint.txid, None)? + .output + .get(TryInto::::try_into(outpoint.vout).unwrap()) + .cloned() + .ok_or_else(|| anyhow!("Invalid output index"))?; + + utxos.insert(OutPoint::new(outpoint.txid, outpoint.vout), txout); } Ok(utxos) } + async fn get_inscription_info( + ord_client: &OrdClient, + inscription_id: InscriptionId, + ) -> Result { + let response = ord_client + .get(&format!("/inscription/{inscription_id}")) + .await?; + + if !response.status().is_success() { + bail!("inscription {inscription_id} not found"); + } + + Ok(serde_json::from_str(&response.text().await?)?) + } + + async fn get_server_status(ord_client: &OrdClient) -> Result { + let response = ord_client.get("/status").await?; + + if !response.status().is_success() { + bail!("could not get status: {}", response.text().await?) + } + + Ok(serde_json::from_str(&response.text().await?)?) + } + pub(crate) fn get_output_sat_ranges(&self) -> Result)>> { ensure!( - self.has_sat_index()?, + self.has_sat_index, "ord index must be built with `--index-sats` to use `--sat`" ); let mut output_sat_ranges = Vec::new(); - for output in self.get_unspent_outputs()?.keys() { - if let Some(sat_ranges) = self.get_output(output)?.sat_ranges { - output_sat_ranges.push((*output, sat_ranges)); + for (output, info) in self.output_info.iter() { + if let Some(sat_ranges) = &info.sat_ranges { + output_sat_ranges.push((*output, sat_ranges.clone())); } else { bail!("output {output} in wallet but is spent according to ord server"); } @@ -147,23 +285,19 @@ impl Wallet { Ok(output_sat_ranges) } - pub(crate) fn find_sat_in_outputs( - &self, - sat: Sat, - utxos: &BTreeMap, - ) -> Result { + pub(crate) fn find_sat_in_outputs(&self, sat: Sat) -> Result { ensure!( - self.has_sat_index()?, + self.has_sat_index, "ord index must be built with `--index-sats` to use `--sat`" ); - for output in utxos.keys() { - if let Some(sat_ranges) = self.get_output(output)?.sat_ranges { + for (outpoint, info) in self.output_info.iter() { + if let Some(sat_ranges) = &info.sat_ranges { let mut offset = 0; for (start, end) in sat_ranges { - if start <= sat.n() && sat.n() < end { + if start <= &sat.n() && &sat.n() < end { return Ok(SatPoint { - outpoint: *output, + outpoint: *outpoint, offset: offset + sat.n() - start, }); } @@ -179,13 +313,33 @@ impl Wallet { ))) } + pub(crate) fn bitcoin_client(&self) -> &bitcoincore_rpc::Client { + &self.bitcoin_client + } + + pub(crate) fn utxos(&self) -> &BTreeMap { + &self.utxos + } + + pub(crate) fn locked_utxos(&self) -> &BTreeMap { + &self.locked_utxos + } + + pub(crate) fn inscriptions(&self) -> &BTreeMap> { + &self.inscriptions + } + + pub(crate) fn inscription_info(&self) -> BTreeMap { + self.inscription_info.clone() + } + pub(crate) fn inscription_exists(&self, inscription_id: InscriptionId) -> Result { Ok( !self - .ord_client()? + .ord_client .get( self - .ord_url + .rpc_url .join(&format!("/inscription/{inscription_id}")) .unwrap(), ) @@ -195,69 +349,42 @@ impl Wallet { ) } - fn get_inscription(&self, inscription_id: InscriptionId) -> Result { - let response = self - .ord_client()? - .get( - self - .ord_url - .join(&format!("/inscription/{inscription_id}")) - .unwrap(), - ) - .send()?; - - if !response.status().is_success() { - bail!("inscription {inscription_id} not found"); - } - - Ok(serde_json::from_str(&response.text()?)?) - } - - pub(crate) fn get_inscriptions(&self) -> Result>> { - let mut inscriptions = BTreeMap::new(); - for output in self.get_unspent_outputs()?.keys() { - for inscription in self.get_output(output)?.inscriptions { - inscriptions - .entry(self.get_inscription_satpoint(inscription)?) - .or_insert_with(Vec::new) - .push(inscription); + pub(crate) fn get_parent_info( + &self, + parent: Option, + ) -> Result> { + if let Some(parent_id) = parent { + if !self.inscription_exists(parent_id)? { + return Err(anyhow!("parent {parent_id} does not exist")); } - } - Ok(inscriptions) - } + let satpoint = self + .inscription_info + .get(&parent_id) + .ok_or_else(|| anyhow!("parent {parent_id} not in wallet"))? + .satpoint; - pub(crate) fn get_inscription_satpoint(&self, inscription_id: InscriptionId) -> Result { - Ok(self.get_inscription(inscription_id)?.satpoint) - } + let tx_out = self + .utxos + .get(&satpoint.outpoint) + .ok_or_else(|| anyhow!("parent {parent_id} not in wallet"))? + .clone(); - pub(crate) fn get_rune( - &self, - rune: Rune, - ) -> Result)>> { - let response = self - .ord_client()? - .get( - self - .ord_url - .join(&format!("/rune/{}", SpacedRune { rune, spacers: 0 })) - .unwrap(), - ) - .send()?; - - if !response.status().is_success() { - return Ok(None); + Ok(Some(ParentInfo { + destination: self.get_change_address()?, + id: parent_id, + location: satpoint, + tx_out, + })) + } else { + Ok(None) } - - let rune_json: RuneJson = serde_json::from_str(&response.text()?)?; - - Ok(Some((rune_json.id, rune_json.entry, rune_json.parent))) } pub(crate) fn get_runic_outputs(&self) -> Result> { let mut runic_outputs = BTreeSet::new(); - for output in self.get_unspent_outputs()?.keys() { - if !self.get_output(output)?.runes.is_empty() { + for (output, info) in self.output_info.iter() { + if !info.runes.is_empty() { runic_outputs.insert(*output); } } @@ -269,7 +396,14 @@ impl Wallet { &self, output: &OutPoint, ) -> Result> { - Ok(self.get_output(output)?.runes) + Ok( + self + .output_info + .get(output) + .ok_or(anyhow!("output not found in wallet"))? + .runes + .clone(), + ) } pub(crate) fn get_rune_balance_in_output(&self, output: &OutPoint, rune: Rune) -> Result { @@ -305,85 +439,52 @@ impl Wallet { ) } - pub(crate) fn get_parent_info( + pub(crate) fn get_rune( &self, - parent: Option, - utxos: &BTreeMap, - ) -> Result> { - if let Some(parent_id) = parent { - let satpoint = self - .get_inscription_satpoint(parent_id) - .map_err(|_| anyhow!(format!("parent {parent_id} does not exist")))?; - - if !utxos.contains_key(&satpoint.outpoint) { - return Err(anyhow!(format!("parent {parent_id} not in wallet"))); - } + rune: Rune, + ) -> Result)>> { + let response = self + .ord_client + .get( + self + .rpc_url + .join(&format!("/rune/{}", SpacedRune { rune, spacers: 0 })) + .unwrap(), + ) + .send()?; - Ok(Some(ParentInfo { - destination: self.get_change_address()?, - id: parent_id, - location: satpoint, - tx_out: self - .bitcoin_client()? - .get_raw_transaction(&satpoint.outpoint.txid, None) - .expect("parent transaction not found in ord server") - .output - .into_iter() - .nth(satpoint.outpoint.vout.try_into().unwrap()) - .expect("current transaction output"), - })) - } else { - Ok(None) + if !response.status().is_success() { + return Ok(None); } + + let rune_json: RuneJson = serde_json::from_str(&response.text()?)?; + + Ok(Some((rune_json.id, rune_json.entry, rune_json.parent))) } pub(crate) fn get_change_address(&self) -> Result
{ Ok( self - .bitcoin_client()? + .bitcoin_client .call::>("getrawchangeaddress", &["bech32m".into()]) .context("could not get change addresses from wallet")? .require_network(self.chain().network())?, ) } - pub(crate) fn get_server_status(&self) -> Result { - let response = self - .ord_client()? - .get(self.ord_url.join("/status").unwrap()) - .send()?; - - if !response.status().is_success() { - bail!("could not get status: {}", response.text()?) - } - - Ok(serde_json::from_str(&response.text()?)?) - } - - pub(crate) fn has_rune_index(&self) -> Result { - Ok(self.get_server_status()?.rune_index) + pub(crate) fn has_sat_index(&self) -> bool { + self.has_sat_index } - pub(crate) fn has_sat_index(&self) -> Result { - Ok(self.get_server_status()?.sat_index) + pub(crate) fn has_rune_index(&self) -> bool { + self.has_rune_index } pub(crate) fn chain(&self) -> Chain { self.options.chain() } - pub(crate) fn exists(&self) -> Result { - Ok( - self - .options - .bitcoin_rpc_client(None)? - .list_wallet_dir()? - .iter() - .any(|name| name == &self.name), - ) - } - - pub(crate) fn check_descriptors(&self, descriptors: Vec) -> Result> { + fn check_descriptors(wallet_name: &str, descriptors: Vec) -> Result> { let tr = descriptors .iter() .filter(|descriptor| descriptor.desc.starts_with("tr(")) @@ -395,18 +496,22 @@ impl Wallet { .count(); if tr != 2 || descriptors.len() != 2 + rawtr { - bail!("wallet \"{}\" contains unexpected output descriptors, and does not appear to be an `ord` wallet, create a new wallet with `ord wallet create`", self.name); + bail!("wallet \"{}\" contains unexpected output descriptors, and does not appear to be an `ord` wallet, create a new wallet with `ord wallet create`", wallet_name); } Ok(descriptors) } - pub(crate) fn initialize_from_descriptors(&self, descriptors: Vec) -> Result { - let client = check_version(self.options.bitcoin_rpc_client(Some(self.name.clone()))?)?; + pub(crate) fn initialize_from_descriptors( + name: String, + options: &Options, + descriptors: Vec, + ) -> Result { + let client = Self::check_version(options.bitcoin_rpc_client(Some(name.clone()))?)?; - let descriptors = self.check_descriptors(descriptors)?; + let descriptors = Self::check_descriptors(&name, descriptors)?; - client.create_wallet(&self.name, None, Some(true), None, None)?; + client.create_wallet(&name, None, Some(true), None, None)?; let descriptors = descriptors .into_iter() @@ -433,16 +538,16 @@ impl Wallet { Ok(()) } - pub(crate) fn initialize(&self, seed: [u8; 64]) -> Result { - check_version(self.options.bitcoin_rpc_client(None)?)?.create_wallet( - &self.name, + pub(crate) fn initialize(name: String, options: &Options, seed: [u8; 64]) -> Result { + Self::check_version(options.bitcoin_rpc_client(None)?)?.create_wallet( + &name, None, Some(true), None, None, )?; - let network = self.chain().network(); + let network = options.chain().network(); let secp = Secp256k1::new(); @@ -460,7 +565,9 @@ impl Wallet { let derived_private_key = master_private_key.derive_priv(&secp, &derivation_path)?; for change in [false, true] { - self.derive_and_import_descriptor( + Self::derive_and_import_descriptor( + name.clone(), + options, &secp, (fingerprint, derivation_path.clone()), derived_private_key, @@ -472,7 +579,8 @@ impl Wallet { } fn derive_and_import_descriptor( - &self, + name: String, + options: &Options, secp: &Secp256k1, origin: (Fingerprint, DerivationPath), derived_private_key: ExtendedPrivKey, @@ -494,9 +602,8 @@ impl Wallet { let descriptor = miniscript::descriptor::Descriptor::new_tr(public_key, None)?; - self - .options - .bitcoin_rpc_client(Some(self.name.clone()))? + options + .bitcoin_rpc_client(Some(name.clone()))? .import_descriptors(vec![ImportDescriptors { descriptor: descriptor.to_string_with_secret(&key_map), timestamp: Timestamp::Now, @@ -509,28 +616,28 @@ impl Wallet { Ok(()) } -} -pub(crate) fn check_version(client: Client) -> Result { - const MIN_VERSION: usize = 240000; + pub(crate) fn check_version(client: Client) -> Result { + const MIN_VERSION: usize = 240000; - let bitcoin_version = client.version()?; - if bitcoin_version < MIN_VERSION { - bail!( - "Bitcoin Core {} or newer required, current version is {}", - format_bitcoin_core_version(MIN_VERSION), - format_bitcoin_core_version(bitcoin_version), - ); - } else { - Ok(client) + let bitcoin_version = client.version()?; + if bitcoin_version < MIN_VERSION { + bail!( + "Bitcoin Core {} or newer required, current version is {}", + Self::format_bitcoin_core_version(MIN_VERSION), + Self::format_bitcoin_core_version(bitcoin_version), + ); + } else { + Ok(client) + } } -} -fn format_bitcoin_core_version(version: usize) -> String { - format!( - "{}.{}.{}", - version / 10000, - version % 10000 / 100, - version % 100 - ) + fn format_bitcoin_core_version(version: usize) -> String { + format!( + "{}.{}.{}", + version / 10000, + version % 10000 / 100, + version % 100 + ) + } } diff --git a/src/wallet/inscribe/batch.rs b/src/wallet/inscribe/batch.rs index f2f25baa3a..76b82b0337 100644 --- a/src/wallet/inscribe/batch.rs +++ b/src/wallet/inscribe/batch.rs @@ -44,13 +44,11 @@ impl Batch { utxos: &BTreeMap, wallet: &Wallet, ) -> SubcommandResult { - let wallet_inscriptions = wallet.get_inscriptions()?; - let commit_tx_change = [wallet.get_change_address()?, wallet.get_change_address()?]; let (commit_tx, reveal_tx, recovery_key_pair, total_fees) = self .create_batch_inscription_transactions( - wallet_inscriptions, + wallet.inscriptions().clone(), wallet.chain(), locked_utxos.clone(), runic_utxos, @@ -67,13 +65,12 @@ impl Batch { )))); } - let bitcoin_client = wallet.bitcoin_client()?; - - let signed_commit_tx = bitcoin_client + let signed_commit_tx = wallet + .bitcoin_client() .sign_raw_transaction_with_wallet(&commit_tx, None, None)? .hex; - let result = bitcoin_client.sign_raw_transaction_with_wallet( + let result = wallet.bitcoin_client().sign_raw_transaction_with_wallet( &reveal_tx, Some( &commit_tx @@ -103,9 +100,14 @@ impl Batch { Self::backup_recovery_key(wallet, recovery_key_pair)?; } - let commit = bitcoin_client.send_raw_transaction(&signed_commit_tx)?; + let commit = wallet + .bitcoin_client() + .send_raw_transaction(&signed_commit_tx)?; - let reveal = match bitcoin_client.send_raw_transaction(&signed_reveal_tx) { + let reveal = match wallet + .bitcoin_client() + .send_raw_transaction(&signed_reveal_tx) + { Ok(txid) => txid, Err(err) => { return Err(anyhow!( @@ -487,20 +489,21 @@ impl Batch { wallet.chain().network(), ); - let bitcoin_client = wallet.bitcoin_client()?; - - let info = - bitcoin_client.get_descriptor_info(&format!("rawtr({})", recovery_private_key.to_wif()))?; - - let response = bitcoin_client.import_descriptors(vec![ImportDescriptors { - descriptor: format!("rawtr({})#{}", recovery_private_key.to_wif(), info.checksum), - timestamp: Timestamp::Now, - active: Some(false), - range: None, - next_index: None, - internal: Some(false), - label: Some("commit tx recovery key".to_string()), - }])?; + let info = wallet + .bitcoin_client() + .get_descriptor_info(&format!("rawtr({})", recovery_private_key.to_wif()))?; + + let response = wallet + .bitcoin_client() + .import_descriptors(vec![ImportDescriptors { + descriptor: format!("rawtr({})#{}", recovery_private_key.to_wif(), info.checksum), + timestamp: Timestamp::Now, + active: Some(false), + range: None, + next_index: None, + internal: Some(false), + label: Some("commit tx recovery key".to_string()), + }])?; for result in response { if !result.success { diff --git a/templates/preview-image.html b/templates/preview-image.html index 2f2284daf4..2e8b112d12 100644 --- a/templates/preview-image.html +++ b/templates/preview-image.html @@ -15,7 +15,7 @@ background-repeat: no-repeat; background-size: contain; height: 100%; - image-rendering: pixelated; + image-rendering: {{ self.image_rendering }}; margin: 0; } diff --git a/templates/status.html b/templates/status.html index 944a2fc286..4b74246ac4 100644 --- a/templates/status.html +++ b/templates/status.html @@ -44,4 +44,17 @@

Status

%% } +
inscription content types
+
+
+%% for (content_type, count) in &self.content_type_counts { +%% if let Some(content_type) = content_type { +
{{String::from_utf8_lossy(&content_type)}}
+%% } else { +
none
+%% } +
{{count}} +%% } +
+
diff --git a/tests/json_api.rs b/tests/json_api.rs index cece0b6b66..d95f624932 100644 --- a/tests/json_api.rs +++ b/tests/json_api.rs @@ -491,8 +491,9 @@ fn get_status() { status_json, StatusJson { blessed_inscriptions: 1, - cursed_inscriptions: 0, chain: Chain::Regtest, + content_type_counts: vec![(Some("text/plain;charset=utf-8".into()), 1)], + cursed_inscriptions: 0, height: Some(3), inscriptions: 1, lost_sats: 0, diff --git a/tests/lib.rs b/tests/lib.rs index d449b3bba5..f1f3c924b3 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -16,8 +16,9 @@ use { subcommand::runes::RuneInfo, templates::{ block::BlockJson, blocks::BlocksJson, inscription::InscriptionJson, - inscriptions::InscriptionsJson, output::OutputJson, rune::RuneJson, runes::RunesJson, - sat::SatJson, status::StatusJson, transaction::TransactionJson, + inscription::InscriptionRecursiveJson, inscriptions::InscriptionsJson, output::OutputJson, + rune::RuneJson, runes::RunesJson, sat::SatJson, status::StatusJson, + transaction::TransactionJson, }, Edict, InscriptionId, Rune, RuneEntry, RuneId, Runestone, }, diff --git a/tests/server.rs b/tests/server.rs index 9bcaa3a786..d1b1034f05 100644 --- a/tests/server.rs +++ b/tests/server.rs @@ -281,6 +281,57 @@ fn inscription_metadata() { ); } +#[test] +fn recursive_inscription_endpoint() { + let bitcoin_rpc_server = test_bitcoincore_rpc::spawn(); + let ord_rpc_server = + TestServer::spawn_with_server_args(&bitcoin_rpc_server, &["--index-sats"], &[]); + + create_wallet(&bitcoin_rpc_server, &ord_rpc_server); + + bitcoin_rpc_server.mine_blocks(1); + + let output = CommandBuilder::new("wallet inscribe --fee-rate 1 --file foo.txt") + .write("foo.txt", "FOO") + .bitcoin_rpc_server(&bitcoin_rpc_server) + .ord_rpc_server(&ord_rpc_server) + .run_and_deserialize_output::(); + + bitcoin_rpc_server.mine_blocks(1); + + let inscription = output.inscriptions.first().unwrap(); + let response = ord_rpc_server.request(format!("/r/inscription/{}", inscription.id)); + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get("content-type").unwrap(), + "application/json" + ); + + let inscription_recursive_json: InscriptionRecursiveJson = + serde_json::from_str(&response.text().unwrap()).unwrap(); + + pretty_assert_eq!( + inscription_recursive_json, + InscriptionRecursiveJson { + charms: vec!["coin".into(), "uncommon".into()], + content_type: Some("text/plain;charset=utf-8".to_string()), + content_length: Some(3), + fee: 138, + height: 2, + number: 0, + output: inscription.location.outpoint, + sat: Some(Sat(50 * COIN_VALUE)), + satpoint: SatPoint { + outpoint: inscription.location.outpoint, + offset: 0, + }, + timestamp: 2, + value: Some(10000), + } + ) +} + #[test] fn inscriptions_page() { let bitcoin_rpc_server = test_bitcoincore_rpc::spawn(); @@ -584,3 +635,47 @@ fn run_no_sync() { child.kill().unwrap(); } + +#[test] +fn authentication() { + let rpc_server = test_bitcoincore_rpc::spawn(); + + let port = TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + + let builder = CommandBuilder::new(format!( + " --username foo --password bar server --address 127.0.0.1 --http-port {port}" + )) + .bitcoin_rpc_server(&rpc_server); + + let mut command = builder.command(); + + let mut child = command.spawn().unwrap(); + + for attempt in 0.. { + if let Ok(response) = reqwest::blocking::get(format!("http://localhost:{port}")) { + if response.status() == 401 { + break; + } + } + + if attempt == 100 { + panic!("Server did not respond"); + } + + thread::sleep(Duration::from_millis(50)); + } + + let response = reqwest::blocking::Client::new() + .get(format!("http://localhost:{port}")) + .basic_auth("foo", Some("bar")) + .send() + .unwrap(); + + assert_eq!(response.status(), 200); + + child.kill().unwrap(); +} diff --git a/tests/wallet.rs b/tests/wallet.rs index 2e56e7526a..91cdd81c66 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -1,5 +1,6 @@ use super::*; +mod authentication; mod balance; mod cardinals; mod create; diff --git a/tests/wallet/authentication.rs b/tests/wallet/authentication.rs new file mode 100644 index 0000000000..4e02ddf294 --- /dev/null +++ b/tests/wallet/authentication.rs @@ -0,0 +1,39 @@ +use {super::*, ord::subcommand::wallet::balance::Output}; + +#[test] +fn authentication() { + let bitcoin_rpc_server = test_bitcoincore_rpc::spawn(); + + let ord_rpc_server = TestServer::spawn_with_server_args( + &bitcoin_rpc_server, + &["--username", "foo", "--password", "bar"], + &[], + ); + + create_wallet(&bitcoin_rpc_server, &ord_rpc_server); + + assert_eq!( + CommandBuilder::new("--username foo --password bar wallet balance") + .bitcoin_rpc_server(&bitcoin_rpc_server) + .ord_rpc_server(&ord_rpc_server) + .run_and_deserialize_output::() + .cardinal, + 0 + ); + + bitcoin_rpc_server.mine_blocks(1); + + assert_eq!( + CommandBuilder::new("--username foo --password bar wallet balance") + .bitcoin_rpc_server(&bitcoin_rpc_server) + .ord_rpc_server(&ord_rpc_server) + .run_and_deserialize_output::(), + Output { + cardinal: 50 * COIN_VALUE, + ordinal: 0, + runic: None, + runes: None, + total: 50 * COIN_VALUE, + } + ); +} diff --git a/tests/wallet/dump.rs b/tests/wallet/dump.rs index 76ffbbaac6..29ce0a190d 100644 --- a/tests/wallet/dump.rs +++ b/tests/wallet/dump.rs @@ -9,6 +9,7 @@ fn dumped_descriptors_match_wallet_descriptors() { let output = CommandBuilder::new("wallet dump") .bitcoin_rpc_server(&bitcoin_rpc_server) + .ord_rpc_server(&ord_rpc_server) .stderr_regex(".*") .run_and_deserialize_output::(); @@ -28,6 +29,7 @@ fn dumped_descriptors_restore() { let output = CommandBuilder::new("wallet dump") .bitcoin_rpc_server(&bitcoin_rpc_server) + .ord_rpc_server(&ord_rpc_server) .stderr_regex(".*") .run_and_deserialize_output::(); @@ -36,6 +38,7 @@ fn dumped_descriptors_restore() { CommandBuilder::new("wallet restore --from descriptor") .stdin(serde_json::to_string(&output).unwrap().as_bytes().to_vec()) .bitcoin_rpc_server(&bitcoin_rpc_server) + .ord_rpc_server(&ord_rpc_server) .run_and_extract_stdout(); assert!(bitcoin_rpc_server @@ -54,6 +57,7 @@ fn dump_and_restore_descriptors_with_minify() { let output = CommandBuilder::new("--minify wallet dump") .bitcoin_rpc_server(&bitcoin_rpc_server) + .ord_rpc_server(&ord_rpc_server) .stderr_regex(".*") .run_and_deserialize_output::(); @@ -62,6 +66,7 @@ fn dump_and_restore_descriptors_with_minify() { CommandBuilder::new("wallet restore --from descriptor") .stdin(serde_json::to_string(&output).unwrap().as_bytes().to_vec()) .bitcoin_rpc_server(&bitcoin_rpc_server) + .ord_rpc_server(&ord_rpc_server) .run_and_extract_stdout(); assert!(bitcoin_rpc_server diff --git a/tests/wallet/inscribe.rs b/tests/wallet/inscribe.rs index 760da8aaed..e6e613e3d2 100644 --- a/tests/wallet/inscribe.rs +++ b/tests/wallet/inscribe.rs @@ -861,6 +861,9 @@ fn cbor_metadata_appears_on_inscription_page() { #[test] fn error_message_when_parsing_json_metadata_is_reasonable() { let bitcoin_rpc_server = test_bitcoincore_rpc::spawn(); + let ord_rpc_server = TestServer::spawn_with_server_args(&bitcoin_rpc_server, &[], &[]); + + create_wallet(&bitcoin_rpc_server, &ord_rpc_server); CommandBuilder::new( "wallet inscribe --fee-rate 1 --json-metadata metadata.json --file content.png", @@ -868,6 +871,7 @@ fn error_message_when_parsing_json_metadata_is_reasonable() { .write("content.png", [1; 520]) .write("metadata.json", "{") .bitcoin_rpc_server(&bitcoin_rpc_server) + .ord_rpc_server(&ord_rpc_server) .stderr_regex(".*failed to parse JSON metadata.*") .expected_exit_code(1) .run_and_extract_stdout(); @@ -876,6 +880,9 @@ fn error_message_when_parsing_json_metadata_is_reasonable() { #[test] fn error_message_when_parsing_cbor_metadata_is_reasonable() { let bitcoin_rpc_server = test_bitcoincore_rpc::spawn(); + let ord_rpc_server = TestServer::spawn_with_server_args(&bitcoin_rpc_server, &[], &[]); + + create_wallet(&bitcoin_rpc_server, &ord_rpc_server); CommandBuilder::new( "wallet inscribe --fee-rate 1 --cbor-metadata metadata.cbor --file content.png", @@ -883,6 +890,7 @@ fn error_message_when_parsing_cbor_metadata_is_reasonable() { .write("content.png", [1; 520]) .write("metadata.cbor", [0x61]) .bitcoin_rpc_server(&bitcoin_rpc_server) + .ord_rpc_server(&ord_rpc_server) .stderr_regex(".*failed to parse CBOR metadata.*") .expected_exit_code(1) .run_and_extract_stdout(); diff --git a/tests/wallet/receive.rs b/tests/wallet/receive.rs index 55f33c6910..448c19af52 100644 --- a/tests/wallet/receive.rs +++ b/tests/wallet/receive.rs @@ -9,6 +9,7 @@ fn receive() { let output = CommandBuilder::new("wallet receive") .bitcoin_rpc_server(&bitcoin_rpc_server) + .ord_rpc_server(&ord_rpc_server) .run_and_deserialize_output::(); assert!(output.address.is_valid_for_network(Network::Bitcoin)); diff --git a/tests/wallet/restore.rs b/tests/wallet/restore.rs index c53b1af394..ba917da30e 100644 --- a/tests/wallet/restore.rs +++ b/tests/wallet/restore.rs @@ -64,12 +64,14 @@ fn restore_to_existing_wallet_fails() { let output = CommandBuilder::new("wallet dump") .bitcoin_rpc_server(&bitcoin_rpc_server) + .ord_rpc_server(&ord_rpc_server) .stderr_regex(".*") .run_and_deserialize_output::(); CommandBuilder::new("wallet restore --from descriptor") .stdin(serde_json::to_string(&output).unwrap().as_bytes().to_vec()) .bitcoin_rpc_server(&bitcoin_rpc_server) + .ord_rpc_server(&ord_rpc_server) .expected_exit_code(1) .expected_stderr("error: wallet `ord` already exists\n") .run_and_extract_stdout(); diff --git a/tests/wallet/transactions.rs b/tests/wallet/transactions.rs index 259d3c7356..e728e58371 100644 --- a/tests/wallet/transactions.rs +++ b/tests/wallet/transactions.rs @@ -11,6 +11,7 @@ fn transactions() { CommandBuilder::new("wallet transactions") .bitcoin_rpc_server(&bitcoin_rpc_server) + .ord_rpc_server(&ord_rpc_server) .run_and_deserialize_output::>(); assert_eq!(bitcoin_rpc_server.loaded_wallets().len(), 1); @@ -20,6 +21,7 @@ fn transactions() { let output = CommandBuilder::new("wallet transactions") .bitcoin_rpc_server(&bitcoin_rpc_server) + .ord_rpc_server(&ord_rpc_server) .run_and_deserialize_output::>(); assert_regex_match!(output[0].transaction.to_string(), "[[:xdigit:]]{64}"); @@ -35,6 +37,7 @@ fn transactions_with_limit() { CommandBuilder::new("wallet transactions") .bitcoin_rpc_server(&bitcoin_rpc_server) + .ord_rpc_server(&ord_rpc_server) .stdout_regex(".*") .run_and_extract_stdout(); @@ -42,6 +45,7 @@ fn transactions_with_limit() { let output = CommandBuilder::new("wallet transactions") .bitcoin_rpc_server(&bitcoin_rpc_server) + .ord_rpc_server(&ord_rpc_server) .run_and_deserialize_output::>(); assert_regex_match!(output[0].transaction.to_string(), "[[:xdigit:]]{64}"); @@ -51,6 +55,7 @@ fn transactions_with_limit() { let output = CommandBuilder::new("wallet transactions") .bitcoin_rpc_server(&bitcoin_rpc_server) + .ord_rpc_server(&ord_rpc_server) .run_and_deserialize_output::>(); assert_regex_match!(output[1].transaction.to_string(), "[[:xdigit:]]{64}"); @@ -58,6 +63,7 @@ fn transactions_with_limit() { let output = CommandBuilder::new("wallet transactions --limit 1") .bitcoin_rpc_server(&bitcoin_rpc_server) + .ord_rpc_server(&ord_rpc_server) .run_and_deserialize_output::>(); assert_regex_match!(output[0].transaction.to_string(), "[[:xdigit:]]{64}");