From 560252f68c5891fb53052bc4fe384e71e12e166c Mon Sep 17 00:00:00 2001 From: andrewtoth Date: Tue, 3 Oct 2023 18:49:37 +0000 Subject: [PATCH 1/4] Add troubleshooting guide for syncing bitcoind (#1737) --- docs/src/guides/inscriptions.md | 55 +++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/docs/src/guides/inscriptions.md b/docs/src/guides/inscriptions.md index a027c3148d..e0280dd11f 100644 --- a/docs/src/guides/inscriptions.md +++ b/docs/src/guides/inscriptions.md @@ -46,12 +46,12 @@ Making inscriptions requires Bitcoin Core 24 or newer. This guide does not cover installing Bitcoin Core in detail. Once Bitcoin Core is installed, you should be able to run `bitcoind -version` successfully from -the command line. +the command line. Do *NOT* use `bitcoin-qt`. Configuring Bitcoin Core ------------------------ -`ord` requires Bitcoin Core's transaction index. +`ord` requires Bitcoin Core's transaction index and rest interface. To configure your Bitcoin Core node to maintain a transaction index, add the following to your `bitcoin.conf`: @@ -66,6 +66,9 @@ Or, run `bitcoind` with `-txindex`: bitcoind -txindex ``` +Details on creating or modifying your `bitcoin.conf` file can be found +[here](https://github.com/bitcoin/bitcoin/blob/master/doc/bitcoin-conf.md). + Syncing the Bitcoin Blockchain ------------------------------ @@ -85,6 +88,54 @@ agrees with the block count on a block explorer like [the mempool.space block explorer](https://mempool.space/). `ord` interacts with `bitcoind`, so you should leave `bitcoind` running in the background when you're using `ord`. +The blockchain takes about 600GB of disk space. If you have an external drive +you want to store blocks on, use the configuration option +`blocksdir=`. This is much simpler than using the +`datadir` option because the cookie file will still be in the default location +for `bitcoin-cli` and `ord` to find. + +Troubleshooting +--------------- + +Make sure you can access `bitcoind` with `bitcoin-cli -getinfo` and that it is +fully synced. + +If `bitcoin-cli -getinfo` returns `Could not connect to the server`, `bitcoind` +is not running. + +Make sure `rpcuser`, `rpcpassword`, or `rpcauth` are *NOT* set in your +`bitcoin.conf` file. `ord` requires using cookie authentication. Make sure there +is a file `.cookie` in your bitcoin data directory. + +If `bitcoin-cli -getinfo` returns `Could not locate RPC credentials`, then you +must specify the cookie file location. +If you are using a custom data directory (specifying the `datadir` option), +then you must specify the cookie location like +`bitcoin-cli -rpccookiefile=/.cookie -getinfo`. +When running `ord` you must specify the cookie file location with +`--cookie-file=/.cookie`. + +Make sure you do *NOT* have `disablewallet=1` in your `bitcoin.conf` file. If +`bitcoin-cli listwallets` returns `Method not found` then the wallet is disabled +and you won't be able to use `ord`. + +Make sure `txindex=1` is set. Run `bitcoin-cli getindexinfo` and it should +return something like +```json +{ + "txindex": { + "synced": true, + "best_block_height": 776546 + } +} +``` +If it only returns `{}`, `txindex` is not set. +If it returns `"synced": false`, `bitcoind` is still creating the `txindex`. +Wait until `"synced": true` before using `ord`. + +If you have `maxuploadtarget` set it can interfere with fetching blocks for +`ord` index. Either remove it or set `whitebind=127.0.0.1:8333`. + Installing `ord` ---------------- From be516f87211927bcfefbdd079faa342c7e85db30 Mon Sep 17 00:00:00 2001 From: bonedaddy <17089485+bonedaddy@users.noreply.github.com> Date: Tue, 3 Oct 2023 12:09:04 -0700 Subject: [PATCH 2/4] Pre-allocate vector size (#1960) --- src/index.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/index.rs b/src/index.rs index a03e31e376..f8cc7b6310 100644 --- a/src/index.rs +++ b/src/index.rs @@ -579,12 +579,12 @@ impl Index { pub(crate) fn rare_sat_satpoints(&self) -> Result>> { if self.has_sat_index()? { - let mut result = Vec::new(); - let rtx = self.database.begin_read()?; let sat_to_satpoint = rtx.open_table(SAT_TO_SATPOINT)?; + let mut result = Vec::with_capacity(sat_to_satpoint.len()?.try_into().unwrap()); + for range in sat_to_satpoint.range(0..)? { let (sat, satpoint) = range?; result.push((Sat(sat.value()), Entry::load(*satpoint.value()))); From 26a755fec9d3092368f110ae8ffef04449aeff39 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Tue, 3 Oct 2023 12:57:15 -0700 Subject: [PATCH 3/4] Allow inscriptions to include CBOR metadata (#2421) --- Cargo.lock | 35 ++++++++ Cargo.toml | 3 +- docs/src/SUMMARY.md | 1 + docs/src/inscriptions/metadata.md | 86 ++++++++++++++++++++ src/envelope.rs | 47 +++++++++++ src/inscription.rs | 124 ++++++++++++++++++++++++++-- src/lib.rs | 3 +- src/subcommand/preview.rs | 4 +- src/subcommand/wallet/inscribe.rs | 79 ++++++++++++++---- src/templates.rs | 2 + src/templates/metadata.rs | 130 ++++++++++++++++++++++++++++++ templates/inscription.html | 6 ++ tests/wallet/inscribe.rs | 94 +++++++++++++++++++++ 13 files changed, 590 insertions(+), 24 deletions(-) create mode 100644 docs/src/inscriptions/metadata.md create mode 100644 src/templates/metadata.rs diff --git a/Cargo.lock b/Cargo.lock index fad35c04ec..741613aa70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -661,6 +661,33 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "ciborium" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "effd91f6c78e5a4ace8a5d3c0b6bfaec9e2baaef55f3efc00e45fb2e477ee926" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdf919175532b369853f5d5e20b26b43112613fd6fe7aee757e35f7a44642656" + +[[package]] +name = "ciborium-ll" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "defaa24ecc093c77630e6c15e17c51f5e187bf35ee514f4e2d67baaa96dae22b" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "clap" version = "4.4.4" @@ -1352,6 +1379,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabb4a44450da02c90444cf74558da904edde8fb4e9035a9a6a4e15445af0bd7" + [[package]] name = "hashbrown" version = "0.12.3" @@ -2084,6 +2117,7 @@ dependencies = [ "bitcoin", "boilerplate", "chrono", + "ciborium", "clap", "ctrlc", "derive_more", @@ -2846,6 +2880,7 @@ version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b420ce6e3d8bd882e9b243c6eed35dbc9a6110c9769e74b584e0d68d1f20c65" dependencies = [ + "indexmap 2.0.0", "itoa", "ryu", "serde", diff --git a/Cargo.toml b/Cargo.toml index 41806b48c5..aad9e93602 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ bip39 = "2.0.0" bitcoin = { version = "0.30.0", features = ["rand"] } boilerplate = { version = "1.0.0", features = ["axum"] } chrono = "0.4.19" +ciborium = "0.2.1" clap = { version = "4.4.2", features = ["derive"] } ctrlc = { version = "3.2.1", features = ["termination"] } derive_more = "0.99.17" @@ -49,7 +50,7 @@ rust-embed = "8.0.0" rustls = "0.21.1" rustls-acme = { version = "0.7.1", features = ["axum"] } serde = { version = "1.0.137", features = ["derive"] } -serde_json = { version = "1.0.81" } +serde_json = { version = "1.0.81", features = ["preserve_order"] } serde_yaml = "0.9.17" sysinfo = "0.29.2" tempfile = "3.2.0" diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 2ed5bace41..acc5b95551 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -3,6 +3,7 @@ - [Overview](overview.md) - [Digital Artifacts](digital-artifacts.md) - [Inscriptions](inscriptions.md) + - [Metadata](inscriptions/metadata.md) - [Provenance](inscriptions/provenance.md) - [Recursion](inscriptions/recursion.md) - [FAQ](faq.md) diff --git a/docs/src/inscriptions/metadata.md b/docs/src/inscriptions/metadata.md new file mode 100644 index 0000000000..758e3558a7 --- /dev/null +++ b/docs/src/inscriptions/metadata.md @@ -0,0 +1,86 @@ +Metadata +======== + +Inscriptions may include [CBOR](https://cbor.io/) metadata, stored as data +pushes in fields with tag `5`. Since data pushes are limited to 520 bytes, +metadata longer than 520 bytes must be split into multiple tag `5` fields, +which will then be concatenated before decoding. + +Metadata is human readable, and all metadata will be displayed to the user with +its inscription. Inscribers are encouraged to consider how metadata will be +displayed, and make metadata concise and attractive. + +Metadata is rendered to HTML for display as follows: + +- `null`, `true`, `false`, numbers, floats, and strings are rendered as plain + text. +- Byte strings are rendered as uppercase hexadecimal. +- Arrays are rendered as `
    ` tags, with every element wrapped in `
  • ` + tags. +- Maps are rendered as `
    ` tags, with every key wrapped in `
    ` tags, and + every value wrapped in `
    ` tags. +- Tags are rendered as the tag , enclosed in a `` tag, followed by the + value. + +CBOR is a complex spec with many different data types, and multiple ways of +representing the same data. Exotic data types, such as tags, floats, and +bignums, and encoding such as indefinite values, may fail to display correctly +or at all. Contributions to `ord` to remedy this are welcome. + +Example +------- + +Since CBOR is not human readable, in these examples it is represented as JSON. +Keep in mind that this is *only* for these examples, and JSON metadata will +*not* be displayed correctly. + +The metadata `{"foo":"bar","baz":[null,true,false,0]}` would be included in an inscription as: + +``` +OP_FALSE +OP_IF + ... + OP_PUSH 0x05 OP_PUSH '{"foo":"bar","baz":[null,true,false,0]}' + ... +OP_ENDIF +``` + +And rendered as: + +``` +
    + ... +
    metadata
    +
    +
    +
    foo
    +
    bar
    +
    baz
    +
    +
      +
    • null
    • +
    • true
    • +
    • false
    • +
    • 0
    • +
    +
    +
    +
    + ... +
    +``` + +Metadata longer than 520 bytes must be split into multiple fields: + +``` +OP_FALSE +OP_IF + ... + OP_PUSH 0x05 OP_PUSH '{"very":"long","metadata":' + OP_PUSH 0x05 OP_PUSH '"is","finally":"done"}' + ... +OP_ENDIF +``` + +Which would then be concatinated into +`{"very":"long","metadata":"is","finally":"done"}`. diff --git a/src/envelope.rs b/src/envelope.rs index b93d615228..3221648c2d 100644 --- a/src/envelope.rs +++ b/src/envelope.rs @@ -11,6 +11,7 @@ pub(crate) const PROTOCOL_ID: [u8; 3] = *b"ord"; pub(crate) const BODY_TAG: [u8; 0] = []; pub(crate) const CONTENT_TYPE_TAG: [u8; 1] = [1]; pub(crate) const PARENT_TAG: [u8; 1] = [3]; +pub(crate) const METADATA_TAG: [u8; 1] = [5]; pub(crate) const METAPROTOCOL_TAG: [u8; 1] = [7]; type Result = std::result::Result; @@ -34,6 +35,19 @@ fn remove_field(fields: &mut BTreeMap<&[u8], Vec<&[u8]>>, field: &[u8]) -> Optio } } +fn remove_and_concatenate_field( + fields: &mut BTreeMap<&[u8], Vec<&[u8]>>, + field: &[u8], +) -> Option> { + let value = fields.remove(field)?; + + if value.is_empty() { + None + } else { + Some(value.into_iter().flatten().cloned().collect()) + } +} + impl From for ParsedEnvelope { fn from(envelope: RawEnvelope) -> Self { let body = envelope @@ -58,6 +72,7 @@ impl From for ParsedEnvelope { let content_type = remove_field(&mut fields, &CONTENT_TYPE_TAG); let parent = remove_field(&mut fields, &PARENT_TAG); let metaprotocol = remove_field(&mut fields, &METAPROTOCOL_TAG); + let metadata = remove_and_concatenate_field(&mut fields, &METADATA_TAG); let unrecognized_even_field = fields .keys() @@ -78,6 +93,7 @@ impl From for ParsedEnvelope { duplicate_field, incomplete_field, metaprotocol, + metadata, }, input: envelope.input, offset: envelope.offset, @@ -689,4 +705,35 @@ mod tests { }], ); } + + #[test] + fn metadata_is_parsed_correctly() { + assert_eq!( + parse(&[envelope(&[b"ord", &[5], &[]])]), + vec![ParsedEnvelope { + payload: Inscription { + metadata: Some(vec![]), + ..Default::default() + }, + input: 0, + offset: 0, + }] + ); + } + + #[test] + fn metadata_is_parsed_correctly_from_chunks() { + assert_eq!( + parse(&[envelope(&[b"ord", &[5], &[0], &[5], &[1]])]), + vec![ParsedEnvelope { + payload: Inscription { + metadata: Some(vec![0, 1]), + duplicate_field: true, + ..Default::default() + }, + input: 0, + offset: 0, + }] + ); + } } diff --git a/src/inscription.rs b/src/inscription.rs index 4c6fb00627..b89820ea25 100644 --- a/src/inscription.rs +++ b/src/inscription.rs @@ -7,6 +7,7 @@ use { }, ScriptBuf, }, + io::Cursor, std::str, }; @@ -24,11 +25,12 @@ pub(crate) enum Curse { pub struct Inscription { pub body: Option>, pub content_type: Option>, - pub parent: Option>, - pub metaprotocol: Option>, - pub unrecognized_even_field: bool, pub duplicate_field: bool, pub incomplete_field: bool, + pub metadata: Option>, + pub metaprotocol: Option>, + pub parent: Option>, + pub unrecognized_even_field: bool, } impl Inscription { @@ -46,6 +48,7 @@ impl Inscription { path: impl AsRef, parent: Option, metaprotocol: Option, + metadata: Option>, ) -> Result { let path = path.as_ref(); @@ -63,11 +66,12 @@ impl Inscription { Ok(Self { body: Some(body), content_type: Some(content_type.into()), - parent: parent.map(|id| id.parent_value()), - metaprotocol: metaprotocol.map(|metaprotocol| metaprotocol.into_bytes()), duplicate_field: false, - unrecognized_even_field: false, incomplete_field: false, + metadata, + metaprotocol: metaprotocol.map(|metaprotocol| metaprotocol.into_bytes()), + parent: parent.map(|id| id.parent_value()), + unrecognized_even_field: false, }) } @@ -98,6 +102,13 @@ impl Inscription { .push_slice(PushBytesBuf::try_from(parent).unwrap()); } + if let Some(metadata) = &self.metadata { + for chunk in metadata.chunks(520) { + builder = builder.push_slice(envelope::METADATA_TAG); + builder = builder.push_slice(PushBytesBuf::try_from(chunk.to_vec()).unwrap()); + } + } + if let Some(body) = &self.body { builder = builder.push_slice(envelope::BODY_TAG); for chunk in body.chunks(520) { @@ -140,6 +151,10 @@ impl Inscription { str::from_utf8(self.content_type.as_ref()?).ok() } + pub(crate) fn metadata(&self) -> Option { + ciborium::from_reader(Cursor::new(self.metadata.as_ref()?)).ok() + } + pub(crate) fn metaprotocol(&self) -> Option<&str> { str::from_utf8(self.metaprotocol.as_ref()?).ok() } @@ -197,7 +212,7 @@ mod tests { use super::*; #[test] - fn reveal_script_chunks_data() { + fn reveal_script_chunks_body() { assert_eq!( inscription("foo", []) .append_reveal_script(script::Builder::new()) @@ -247,6 +262,64 @@ mod tests { ); } + #[test] + fn reveal_script_chunks_metadata() { + assert_eq!( + Inscription { + metadata: None, + ..Default::default() + } + .append_reveal_script(script::Builder::new()) + .instructions() + .count(), + 4 + ); + + assert_eq!( + Inscription { + metadata: Some(Vec::new()), + ..Default::default() + } + .append_reveal_script(script::Builder::new()) + .instructions() + .count(), + 4 + ); + + assert_eq!( + Inscription { + metadata: Some(vec![0; 1]), + ..Default::default() + } + .append_reveal_script(script::Builder::new()) + .instructions() + .count(), + 6 + ); + + assert_eq!( + Inscription { + metadata: Some(vec![0; 520]), + ..Default::default() + } + .append_reveal_script(script::Builder::new()) + .instructions() + .count(), + 6 + ); + + assert_eq!( + Inscription { + metadata: Some(vec![0; 521]), + ..Default::default() + } + .append_reveal_script(script::Builder::new()) + .instructions() + .count(), + 8 + ); + } + #[test] fn inscription_with_no_parent_field_has_no_parent() { assert!(Inscription { @@ -396,4 +469,41 @@ mod tests { 0x04030201, ); } + + #[test] + fn metadata_function_decodes_metadata() { + assert_eq!( + Inscription { + metadata: Some(vec![0x44, 0, 1, 2, 3]), + ..Default::default() + } + .metadata() + .unwrap(), + Value::Bytes(vec![0, 1, 2, 3]), + ); + } + + #[test] + fn metadata_function_returns_none_if_no_metadata() { + assert_eq!( + Inscription { + metadata: None, + ..Default::default() + } + .metadata(), + None, + ); + } + + #[test] + fn metadata_function_returns_none_if_metadata_fails_to_parse() { + assert_eq!( + Inscription { + metadata: Some(vec![0x44]), + ..Default::default() + } + .metadata(), + None, + ); + } } diff --git a/src/lib.rs b/src/lib.rs index cfeeff6164..8b8df73161 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,6 +43,7 @@ use { bitcoincore_rpc::{Client, RpcApi}, chain::Chain, chrono::{DateTime, TimeZone, Utc}, + ciborium::Value, clap::{ArgGroup, Parser}, derive_more::{Display, FromStr}, html_escaper::{Escape, Trusted}, @@ -56,7 +57,7 @@ use { ffi::OsString, fmt::{self, Display, Formatter}, fs::{self, File}, - io, + io::{self, Cursor}, net::{TcpListener, ToSocketAddrs}, ops::{Add, AddAssign, Sub}, path::{Path, PathBuf}, diff --git a/src/subcommand/preview.rs b/src/subcommand/preview.rs index 3248761c5d..a493298481 100644 --- a/src/subcommand/preview.rs +++ b/src/subcommand/preview.rs @@ -79,16 +79,18 @@ impl Preview { options: options.clone(), subcommand: Subcommand::Wallet(super::wallet::Wallet::Inscribe( super::wallet::inscribe::Inscribe { + cbor_metadata: None, commit_fee_rate: None, destination: None, dry_run: false, fee_rate: FeeRate::try_from(1.0).unwrap(), file, + json_metadata: None, + metaprotocol: None, no_backup: true, no_limit: false, parent: None, postage: Some(TransactionBuilder::TARGET_POSTAGE), - metaprotocol: None, reinscribe: false, satpoint: None, }, diff --git a/src/subcommand/wallet/inscribe.rs b/src/subcommand/wallet/inscribe.rs index 537d6a615f..85b59c2bfe 100644 --- a/src/subcommand/wallet/inscribe.rs +++ b/src/subcommand/wallet/inscribe.rs @@ -36,17 +36,33 @@ struct ParentInfo { #[derive(Debug, Parser)] pub(crate) struct Inscribe { - #[arg(long, help = "Inscribe .")] - pub(crate) satpoint: Option, - #[arg(long, help = "Use fee rate of sats/vB.")] - pub(crate) fee_rate: FeeRate, + #[arg( + long, + help = "Include CBOR in file at as inscription metadata", + conflicts_with = "json_metadata" + )] + pub(crate) cbor_metadata: Option, #[arg( long, help = "Use sats/vbyte for commit transaction.\nDefaults to if unset." )] pub(crate) commit_fee_rate: Option, + #[arg(long, help = "Send inscription to .")] + pub(crate) destination: Option>, + #[arg(long, help = "Don't sign or broadcast transactions.")] + pub(crate) dry_run: bool, + #[arg(long, help = "Use fee rate of sats/vB.")] + pub(crate) fee_rate: FeeRate, #[arg(help = "Inscribe sat with contents of .")] pub(crate) file: PathBuf, + #[arg( + long, + help = "Include JSON in file at convered to CBOR as inscription metadata", + conflicts_with = "cbor_metadata" + )] + pub(crate) json_metadata: Option, + #[clap(long, help = "Set inscription metaprotocol to .")] + pub(crate) metaprotocol: Option, #[arg(long, help = "Do not back up recovery key.")] pub(crate) no_backup: bool, #[arg( @@ -54,27 +70,43 @@ pub(crate) struct Inscribe { help = "Do not check that transactions are equal to or below the MAX_STANDARD_TX_WEIGHT of 400,000 weight units. Transactions over this limit are currently nonstandard and will not be relayed by bitcoind in its default configuration. Do not use this flag unless you understand the implications." )] pub(crate) no_limit: bool, - #[arg(long, help = "Don't sign or broadcast transactions.")] - pub(crate) dry_run: bool, - #[arg(long, help = "Send inscription to .")] - pub(crate) destination: Option>, + #[clap(long, help = "Make inscription a child of .")] + pub(crate) parent: Option, #[arg( long, help = "Amount of postage to include in the inscription. Default `10000sat`." )] pub(crate) postage: Option, - #[clap(long, help = "Make inscription a child of .")] - pub(crate) parent: Option, #[clap(long, help = "Allow reinscription.")] pub(crate) reinscribe: bool, - #[clap(long, help = "Set inscription metaprotocol to .")] - pub(crate) metaprotocol: Option, + #[arg(long, help = "Inscribe .")] + pub(crate) satpoint: Option, } impl Inscribe { pub(crate) fn run(self, options: Options) -> SubcommandResult { - let inscription = - Inscription::from_file(options.chain(), &self.file, self.parent, self.metaprotocol)?; + let metadata = if let Some(path) = self.cbor_metadata { + let cbor = fs::read(path)?; + let _value: Value = ciborium::from_reader(Cursor::new(cbor.clone())) + .context("failed to parse CBOR metadata")?; + Some(cbor) + } else if let Some(path) = self.json_metadata { + let value: serde_json::Value = + serde_json::from_reader(File::open(path)?).context("failed to parse JSON metadata")?; + let mut cbor = Vec::new(); + ciborium::into_writer(&value, &mut cbor)?; + Some(cbor) + } else { + None + }; + + let inscription = Inscription::from_file( + options.chain(), + &self.file, + self.parent, + self.metaprotocol, + metadata, + )?; let index = Index::open(&options)?; index.update()?; @@ -949,4 +981,23 @@ mod tests { assert!(reveal_tx.size() >= MAX_STANDARD_TX_WEIGHT as usize); } + + #[test] + fn cbor_and_json_metadata_flags_conflict() { + assert_regex_match!( + Arguments::try_parse_from([ + "ord", + "wallet", + "inscribe", + "--cbor-metadata", + "foo", + "--json-metadata", + "bar", + "baz", + ]) + .unwrap_err() + .to_string(), + ".*--cbor-metadata.*cannot be used with.*--json-metadata.*" + ); + } } diff --git a/src/templates.rs b/src/templates.rs index 3d73d82276..bd6735b919 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -9,6 +9,7 @@ pub(crate) use { inscription::{InscriptionHtml, InscriptionJson}, inscriptions::{InscriptionsHtml, InscriptionsJson}, inscriptions_block::InscriptionsBlockHtml, + metadata::MetadataHtml, output::{OutputHtml, OutputJson}, page_config::PageConfig, preview::{ @@ -29,6 +30,7 @@ mod input; pub mod inscription; pub mod inscriptions; mod inscriptions_block; +mod metadata; pub mod output; mod preview; mod range; diff --git a/src/templates/metadata.rs b/src/templates/metadata.rs new file mode 100644 index 0000000000..29245ad44a --- /dev/null +++ b/src/templates/metadata.rs @@ -0,0 +1,130 @@ +use super::*; + +pub(crate) struct MetadataHtml<'a>(pub &'a Value); + +impl<'a> Display for MetadataHtml<'a> { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + match self.0 { + Value::Array(x) => { + write!(f, "
      ")?; + for element in x { + write!(f, "
    • {}
    • ", MetadataHtml(element))?; + } + write!(f, "
    ") + } + Value::Bool(x) => write!(f, "{x}"), + Value::Bytes(x) => { + for byte in x { + write!(f, "{byte:02X}")?; + } + Ok(()) + } + Value::Float(x) => write!(f, "{x}"), + Value::Integer(x) => write!(f, "{}", i128::from(*x)), + Value::Map(x) => { + write!(f, "
    ")?; + for (key, value) in x { + write!(f, "
    {}
    ", MetadataHtml(key))?; + write!(f, "
    {}
    ", MetadataHtml(value))?; + } + write!(f, "
    ") + } + Value::Null => write!(f, "null"), + Value::Tag(tag, value) => write!(f, "{tag}{}", MetadataHtml(value)), + Value::Text(x) => x.escape(f, false), + _ => write!(f, "unknown"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn null() { + assert_eq!(MetadataHtml(&Value::Null).to_string(), "null"); + } + + #[test] + fn integer() { + assert_eq!(MetadataHtml(&Value::Integer(100.into())).to_string(), "100"); + } + + #[test] + fn bool() { + assert_eq!(MetadataHtml(&Value::Bool(false)).to_string(), "false"); + assert_eq!(MetadataHtml(&Value::Bool(true)).to_string(), "true"); + } + + #[test] + fn tag() { + assert_eq!( + MetadataHtml(&Value::Tag(0, Box::new(Value::Bool(false)))).to_string(), + "0false" + ); + } + + #[test] + fn string() { + assert_eq!( + MetadataHtml(&Value::Text("hello".into())).to_string(), + "hello" + ); + assert_eq!(MetadataHtml(&Value::Text("<".into())).to_string(), "<"); + } + + #[test] + fn bytes() { + assert_eq!( + MetadataHtml(&Value::Bytes(vec![0, 1, 2, 0xFF])).to_string(), + "000102FF" + ); + } + + #[test] + fn float() { + assert_eq!(MetadataHtml(&Value::Float(0.5)).to_string(), "0.5"); + } + + #[test] + fn array() { + assert_eq!( + MetadataHtml(&Value::Array(vec![ + Value::Null, + Value::Null, + Value::Text("hello".to_string()) + ])) + .to_string(), + "
    • null
    • null
    • hello
    " + ) + } + + #[test] + fn map() { + assert_eq!( + MetadataHtml(&Value::Map( + vec![ + (Value::Text("b".to_string()), Value::Null), + ( + Value::Text("a".to_string()), + Value::Text("hello".to_string()) + ) + ] + .into_iter() + .collect() + )) + .to_string(), + "
    b
    null
    a
    hello
    " + ); + assert_eq!( + MetadataHtml(&Value::Map( + vec![(Value::Text("<".to_string()), Value::Null),] + .into_iter() + .collect() + )) + .to_string(), + "
    <
    null
    " + ); + } +} diff --git a/templates/inscription.html b/templates/inscription.html index 56b4839da1..fa8835c055 100644 --- a/templates/inscription.html +++ b/templates/inscription.html @@ -19,6 +19,12 @@

    Inscription {{ self.inscription_number }} (unstable)

    id
    {{ self.inscription_id }}
    +%% if let Some(metadata) = self.inscription.metadata() { +
    metadata
    +
    + {{ Trusted(MetadataHtml(&metadata)) }} +
    +%% } %% if let Some(parent) = &self.parent {
    parent
    {{ parent }}
    diff --git a/tests/wallet/inscribe.rs b/tests/wallet/inscribe.rs index 4f283df1cb..e37edc5ea3 100644 --- a/tests/wallet/inscribe.rs +++ b/tests/wallet/inscribe.rs @@ -657,3 +657,97 @@ fn try_reinscribe_without_flag() { )) .run_and_extract_stdout(); } + +#[test] +fn no_metadata_appears_on_inscription_page_if_no_metadata_is_passed() { + let rpc_server = test_bitcoincore_rpc::spawn(); + create_wallet(&rpc_server); + rpc_server.mine_blocks(1); + + let Inscribe { inscription, .. } = + CommandBuilder::new("wallet inscribe --fee-rate 1 content.png") + .write("content.png", [1; 520]) + .rpc_server(&rpc_server) + .run_and_deserialize_output(); + + rpc_server.mine_blocks(1); + + let ord_server = TestServer::spawn_with_args(&rpc_server, &[]); + + assert!(!ord_server + .request(format!("/inscription/{inscription}"),) + .text() + .unwrap() + .contains("metadata")); +} + +#[test] +fn json_metadata_appears_on_inscription_page() { + let rpc_server = test_bitcoincore_rpc::spawn(); + create_wallet(&rpc_server); + rpc_server.mine_blocks(1); + + let Inscribe { inscription, .. } = + CommandBuilder::new("wallet inscribe --fee-rate 1 --json-metadata metadata.json content.png") + .write("content.png", [1; 520]) + .write("metadata.json", r#"{"foo": "bar", "baz": 1}"#) + .rpc_server(&rpc_server) + .run_and_deserialize_output(); + + rpc_server.mine_blocks(1); + + let ord_server = TestServer::spawn_with_args(&rpc_server, &[]); + + ord_server.assert_response_regex( + format!("/inscription/{inscription}"), + ".*
    metadata
    .*
    foo
    bar
    baz
    1
    .*", + ); +} + +#[test] +fn cbor_metadata_appears_on_inscription_page() { + let rpc_server = test_bitcoincore_rpc::spawn(); + create_wallet(&rpc_server); + rpc_server.mine_blocks(1); + + let Inscribe { inscription, .. } = + CommandBuilder::new("wallet inscribe --fee-rate 1 --cbor-metadata metadata.cbor content.png") + .write("content.png", [1; 520]) + .write( + "metadata.cbor", + [ + 0xA2, 0x63, b'f', b'o', b'o', 0x63, b'b', b'a', b'r', 0x63, b'b', b'a', b'z', 0x01, + ], + ) + .rpc_server(&rpc_server) + .run_and_deserialize_output(); + + rpc_server.mine_blocks(1); + + let ord_server = TestServer::spawn_with_args(&rpc_server, &[]); + + ord_server.assert_response_regex( + format!("/inscription/{inscription}"), + ".*
    metadata
    .*
    foo
    bar
    baz
    1
    .*", + ); +} + +#[test] +fn error_message_when_parsing_json_metadata_is_reasonable() { + CommandBuilder::new("wallet inscribe --fee-rate 1 --json-metadata metadata.json content.png") + .write("content.png", [1; 520]) + .write("metadata.json", "{") + .stderr_regex(".*failed to parse JSON metadata.*") + .expected_exit_code(1) + .run_and_extract_stdout(); +} + +#[test] +fn error_message_when_parsing_cbor_metadata_is_reasonable() { + CommandBuilder::new("wallet inscribe --fee-rate 1 --cbor-metadata metadata.cbor content.png") + .write("content.png", [1; 520]) + .write("metadata.cbor", [0x61]) + .stderr_regex(".*failed to parse CBOR metadata.*") + .expected_exit_code(1) + .run_and_extract_stdout(); +} From cd15017423639526fdeb2de2892890ea7a47a657 Mon Sep 17 00:00:00 2001 From: raph Date: Tue, 3 Oct 2023 22:40:14 +0200 Subject: [PATCH 4/4] Add inscription number endpoint (#2485) --- src/index.rs | 25 ++++++++ src/index/updater.rs | 3 + src/index/updater/inscription_updater.rs | 7 +++ src/subcommand/server.rs | 80 +++++++++++++++++++++++- 4 files changed, 113 insertions(+), 2 deletions(-) diff --git a/src/index.rs b/src/index.rs index f8cc7b6310..817003986b 100644 --- a/src/index.rs +++ b/src/index.rs @@ -51,6 +51,7 @@ define_table! { HEIGHT_TO_BLOCK_HASH, u64, &BlockHashValue } define_table! { HEIGHT_TO_LAST_SEQUENCE_NUMBER, u64, u64 } define_table! { INSCRIPTION_ID_TO_INSCRIPTION_ENTRY, &InscriptionIdValue, InscriptionEntryValue } define_table! { INSCRIPTION_ID_TO_SATPOINT, &InscriptionIdValue, &SatPointValue } +define_table! { INSCRIPTION_NUMBER_TO_INSCRIPTION_ID, i64, &InscriptionIdValue } define_table! { OUTPOINT_TO_SAT_RANGES, &OutPointValue, &[u8] } define_table! { OUTPOINT_TO_VALUE, &OutPointValue, u64} define_table! { SAT_TO_SATPOINT, u64, &SatPointValue } @@ -242,6 +243,7 @@ impl Index { tx.open_table(HEIGHT_TO_LAST_SEQUENCE_NUMBER)?; tx.open_table(INSCRIPTION_ID_TO_INSCRIPTION_ENTRY)?; tx.open_table(INSCRIPTION_ID_TO_SATPOINT)?; + tx.open_table(INSCRIPTION_NUMBER_TO_INSCRIPTION_ID)?; tx.open_table(OUTPOINT_TO_VALUE)?; tx.open_table(SAT_TO_SATPOINT)?; tx.open_table(SEQUENCE_NUMBER_TO_INSCRIPTION_ID)?; @@ -702,6 +704,20 @@ impl Index { ) } + pub(crate) fn get_inscription_id_by_inscription_number( + &self, + n: i64, + ) -> Result> { + Ok( + self + .database + .begin_read()? + .open_table(INSCRIPTION_NUMBER_TO_INSCRIPTION_ID)? + .get(&n)? + .map(|id| Entry::load(*id.value())), + ) + } + pub(crate) fn get_inscription_satpoint_by_id( &self, inscription_id: InscriptionId, @@ -3161,6 +3177,15 @@ mod tests { 0 ); + assert_eq!( + context + .index + .get_inscription_id_by_inscription_number(-3) + .unwrap() + .unwrap(), + fourth + ); + assert_eq!( context .index diff --git a/src/index/updater.rs b/src/index/updater.rs index 0a4a918995..2dd342a077 100644 --- a/src/index/updater.rs +++ b/src/index/updater.rs @@ -382,6 +382,8 @@ impl<'index> Updater<'_> { let mut inscription_id_to_inscription_entry = wtx.open_table(INSCRIPTION_ID_TO_INSCRIPTION_ENTRY)?; let mut inscription_id_to_satpoint = wtx.open_table(INSCRIPTION_ID_TO_SATPOINT)?; + let mut inscription_number_to_inscription_id = + wtx.open_table(INSCRIPTION_NUMBER_TO_INSCRIPTION_ID)?; let mut sat_to_inscription_id = wtx.open_multimap_table(SAT_TO_INSCRIPTION_ID)?; let mut inscription_id_to_children = wtx.open_multimap_table(INSCRIPTION_ID_TO_CHILDREN)?; let mut satpoint_to_inscription_id = wtx.open_multimap_table(SATPOINT_TO_INSCRIPTION_ID)?; @@ -416,6 +418,7 @@ impl<'index> Updater<'_> { value_receiver, &mut inscription_id_to_inscription_entry, lost_sats, + &mut inscription_number_to_inscription_id, cursed_inscription_count, blessed_inscription_count, &mut sequence_number_to_inscription_id, diff --git a/src/index/updater/inscription_updater.rs b/src/index/updater/inscription_updater.rs index 2bd02387a8..6c9f4f7f93 100644 --- a/src/index/updater/inscription_updater.rs +++ b/src/index/updater/inscription_updater.rs @@ -32,6 +32,7 @@ pub(super) struct InscriptionUpdater<'a, 'db, 'tx> { pub(super) cursed_inscription_count: u64, pub(super) blessed_inscription_count: u64, pub(super) next_sequence_number: u64, + inscription_number_to_id: &'a mut Table<'db, 'tx, i64, &'static InscriptionIdValue>, sequence_number_to_id: &'a mut Table<'db, 'tx, u64, &'static InscriptionIdValue>, outpoint_to_value: &'a mut Table<'db, 'tx, &'static OutPointValue, u64>, reward: u64, @@ -56,6 +57,7 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { value_receiver: &'a mut Receiver, id_to_entry: &'a mut Table<'db, 'tx, &'static InscriptionIdValue, InscriptionEntryValue>, lost_sats: u64, + inscription_number_to_id: &'a mut Table<'db, 'tx, i64, &'static InscriptionIdValue>, cursed_inscription_count: u64, blessed_inscription_count: u64, sequence_number_to_id: &'a mut Table<'db, 'tx, u64, &'static InscriptionIdValue>, @@ -90,6 +92,7 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { blessed_inscription_count, next_sequence_number, sequence_number_to_id, + inscription_number_to_id, outpoint_to_value, reward: Height(height).subsidy(), sat_to_inscription_id, @@ -422,6 +425,10 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { number }; + self + .inscription_number_to_id + .insert(inscription_number, &inscription_id)?; + let sequence_number = self.next_sequence_number; self.next_sequence_number += 1; diff --git a/src/subcommand/server.rs b/src/subcommand/server.rs index df85e07657..7b837624bc 100644 --- a/src/subcommand/server.rs +++ b/src/subcommand/server.rs @@ -47,6 +47,23 @@ pub struct ServerConfig { pub is_json_api_enabled: bool, } +enum InscriptionQuery { + Id(InscriptionId), + Number(i64), +} + +impl FromStr for InscriptionQuery { + type Err = Error; + + fn from_str(s: &str) -> Result { + Ok(if s.contains('i') { + InscriptionQuery::Id(s.parse()?) + } else { + InscriptionQuery::Number(s.parse()?) + }) + } +} + enum BlockQuery { Height(u64), Hash(BlockHash), @@ -175,7 +192,7 @@ impl Server { .route("/favicon.ico", get(Self::favicon)) .route("/feed.xml", get(Self::feed)) .route("/input/:block/:transaction/:input", get(Self::input)) - .route("/inscription/:inscription_id", get(Self::inscription)) + .route("/inscription/:inscription_query", get(Self::inscription)) .route("/inscriptions", get(Self::inscriptions)) .route( "/inscriptions/block/:height", @@ -975,9 +992,16 @@ impl Server { async fn inscription( Extension(page_config): Extension>, Extension(index): Extension>, - Path(inscription_id): Path, + Path(DeserializeFromStr(query)): Path>, accept_json: AcceptJson, ) -> ServerResult { + let inscription_id = match query { + InscriptionQuery::Id(id) => id, + InscriptionQuery::Number(inscription_number) => index + .get_inscription_id_by_inscription_number(inscription_number)? + .ok_or_not_found(|| format!("{inscription_number}"))?, + }; + let entry = index .get_inscription_entry(inscription_id)? .ok_or_not_found(|| format!("inscription {inscription_id}"))?; @@ -3025,4 +3049,56 @@ mod tests { [inscription_id], ); } + + #[test] + fn inscription_number_endpoint() { + let server = TestServer::new_with_regtest(); + server.mine_blocks(2); + + let txid = server.bitcoin_rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[ + (1, 0, 0, inscription("text/plain", "hello").to_witness()), + (2, 0, 0, inscription("text/plain", "cursed").to_witness()), + ], + outputs: 2, + ..Default::default() + }); + + let inscription_id = InscriptionId { txid, index: 0 }; + let cursed_inscription_id = InscriptionId { txid, index: 1 }; + + server.mine_blocks(1); + + server.assert_response_regex( + format!("/inscription/{inscription_id}"), + StatusCode::OK, + format!( + ".*

    Inscription 0

    .* +
    +
    id
    +
    {inscription_id}
    .*" + ), + ); + server.assert_response_regex( + "/inscription/0", + StatusCode::OK, + format!( + ".*

    Inscription 0

    .* +
    +
    id
    +
    {inscription_id}
    .*" + ), + ); + + server.assert_response_regex( + "/inscription/-1", + StatusCode::OK, + format!( + ".*

    Inscription -1 \\(unstable\\)

    .* +
    +
    id
    +
    {cursed_inscription_id}
    .*" + ), + ) + } }