diff --git a/Cargo.lock b/Cargo.lock index 50cf4603db..e4fbd7eee9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1093,6 +1093,12 @@ dependencies = [ "syn", ] +[[package]] +name = "either" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcaabb2fef8c910e7f4c7ce9f67a1283a1715879a7c230ca9d6d1ae31f16d91" + [[package]] name = "encode_unicode" version = "0.3.6" @@ -1723,6 +1729,15 @@ dependencies = [ "windows-sys 0.45.0", ] +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.5" @@ -2182,6 +2197,7 @@ dependencies = [ "http", "hyper", "indicatif", + "itertools", "lazy_static", "log", "mime", diff --git a/Cargo.toml b/Cargo.toml index 3064f5ed53..b78e6661d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ html-escaper = "0.2.0" http = "0.2.6" hyper = { version = "0.14.24", features = ["http1", "client"] } indicatif = "0.17.1" +itertools = "0.10.0" lazy_static = "1.4.0" log = "0.4.14" mime = "0.3.16" diff --git a/docs/src/guides/inscriptions.md b/docs/src/guides/inscriptions.md index c8824b5077..e9fe3e2993 100644 --- a/docs/src/guides/inscriptions.md +++ b/docs/src/guides/inscriptions.md @@ -166,7 +166,7 @@ Creating Inscriptions To create an inscription with the contents of `FILE`, run: ``` -ord wallet inscribe FILE +ord wallet inscribe --fee-rate FEE_RATE FILE ``` Ord will output two transactions IDs, one for the commit transaction, and one diff --git a/fuzz/Cargo.lock b/fuzz/Cargo.lock index f30058f2a7..87e3510802 100644 --- a/fuzz/Cargo.lock +++ b/fuzz/Cargo.lock @@ -80,6 +80,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -163,6 +178,20 @@ dependencies = [ "futures-core", ] +[[package]] +name = "async-compression" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "942c7cd7ae39e91bde4820d74132e9862e62c2f386c3aa90ccf55949f5bad63a" +dependencies = [ + "brotli", + "flate2", + "futures-core", + "memchr", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-dup" version = "1.2.2" @@ -380,6 +409,7 @@ dependencies = [ "bitflags", "bytes", "futures-util", + "headers", "http", "http-body", "hyper", @@ -569,12 +599,39 @@ dependencies = [ "syn", ] +[[package]] +name = "brotli" +version = "3.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a0b1dbcc8ae29329621f8d4f0d835787c1c38bb1401979b49d13b0b305ff68" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "2.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b6561fd3f895a11e8f72af2cb7d22e08366bebc2b6b57f7744c4bda27034744" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bumpalo" version = "3.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d261e256854913907f67ed06efbc3338dfe6179796deefc1ff763fc1aee5535" +[[package]] +name = "byteorder" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + [[package]] name = "bytes" version = "1.3.0" @@ -741,6 +798,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcb25d077389e53838a8158c8e99174c5a9d902dee4904320db714f3c653ffba" +[[package]] +name = "crc32fast" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b540bd8bc810d3885c6ea91e2018302f68baba2129ab3e88f32389ee9370880d" +dependencies = [ + "cfg-if", +] + [[package]] name = "crossbeam-utils" version = "0.8.14" @@ -1082,6 +1148,16 @@ dependencies = [ "instant", ] +[[package]] +name = "flate2" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8a2db397cb1c8772f31494cb8917e48cd1e64f0fa7efac59fbd741a0a8ce841" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1316,6 +1392,31 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "headers" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3e372db8e5c0d213e0cd0b9be18be2aca3d44cf2fe30a9d46a65581cd454584" +dependencies = [ + "base64 0.13.1", + "bitflags", + "bytes", + "headers-core", + "http", + "httpdate", + "mime", + "sha1 0.10.5", +] + +[[package]] +name = "headers-core" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7f66481bfee273957b1f20485a4ff3362987f85b2c236580d81b4eb7a326429" +dependencies = [ + "http", +] + [[package]] name = "heck" version = "0.4.0" @@ -1442,9 +1543,9 @@ checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" [[package]] name = "hyper" -version = "0.14.23" +version = "0.14.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "034711faac9d2166cb1baf1a2fb0b60b1f277f8492fd72176c17f3515e1abd3c" +checksum = "cc5e554ff619822309ffd57d8734d77cd5ce6238bc956f037ea06c58238c9899" dependencies = [ "bytes", "futures-channel", @@ -1731,6 +1832,20 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "mp4" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "509348cba250e7b852a875100a2ddce7a36ee3abf881a681c756670c1774264d" +dependencies = [ + "byteorder", + "bytes", + "num-rational", + "serde", + "serde_json", + "thiserror", +] + [[package]] name = "never" version = "0.1.0" @@ -1790,6 +1905,19 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0638a1c9d0a3c0914158145bc76cff373a75a627e6ecbfb71cbe6f453a5a19b0" +dependencies = [ + "autocfg", + "num-bigint", + "num-integer", + "num-traits", + "serde", +] + [[package]] name = "num-traits" version = "0.2.15" @@ -1847,11 +1975,12 @@ checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" [[package]] name = "ord" -version = "0.4.0" +version = "0.5.1" dependencies = [ "anyhow", "axum", "axum-server", + "base64 0.13.1", "bech32", "bip39", "bitcoin", @@ -1866,12 +1995,14 @@ dependencies = [ "hex", "html-escaper", "http", + "hyper", "indicatif", "lazy_static", "log", "mime", "mime_guess", "miniscript", + "mp4", "ord-bitcoincore-rpc", "pulldown-cmark", "redb", @@ -1882,6 +2013,7 @@ dependencies = [ "rustls-acme", "serde", "serde_json", + "serde_yaml", "sys-info", "tempfile", "tokio", @@ -1892,8 +2024,9 @@ dependencies = [ [[package]] name = "ord-bitcoincore-rpc" -version = "0.16.4" -source = "git+https://github.com/casey/rust-bitcoincore-rpc?branch=ord#1bc2638f5a3049495a7aa953c4c6dac44fd1524b" +version = "0.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca77a54d5a14a2731dd85150e24be89c849bdcb5704d23097188d763381d98b8" dependencies = [ "jsonrpc", "log", @@ -1904,8 +2037,9 @@ dependencies = [ [[package]] name = "ord-bitcoincore-rpc-json" -version = "0.16.4" -source = "git+https://github.com/casey/rust-bitcoincore-rpc?branch=ord#1bc2638f5a3049495a7aa953c4c6dac44fd1524b" +version = "0.16.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18b81ced8ed99f50d3f221a7de8297cbdf9bdf20ca2908a12f6128d38b2284fb" dependencies = [ "bitcoin", "serde", @@ -2061,9 +2195,9 @@ dependencies = [ [[package]] name = "pyo3-build-config" -version = "0.17.3" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28fcd1e73f06ec85bf3280c48c67e731d8290ad3d730f8be9dc07946923005c8" +checksum = "75439f995d07ddfad42b192dfcf3bc66a7ecfd8b4a1f5f6f046aa5c2c5d7677d" dependencies = [ "once_cell", "target-lexicon", @@ -2179,8 +2313,9 @@ dependencies = [ [[package]] name = "redb" -version = "0.11.0" -source = "git+https://github.com/casey/redb.git?branch=ord#826fd633b2e26bb649cf7004406cb469fbb28837" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78f210bb101d3a0ddba42f67b12a1d7186e584733ad028f119c8d217d867f03d" dependencies = [ "libc", "pyo3-build-config", @@ -2535,6 +2670,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f82e6c8c047aa50a7328632d067bcae6ef38772a79e28daf32f735e0e4f3dd10" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "sha1" version = "0.6.1" @@ -2544,6 +2692,17 @@ dependencies = [ "sha1_smol", ] +[[package]] +name = "sha1" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f04293dc80c3993519f2d7f6f511707ee7094fe0c6d3406feb330cdb3540eba3" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest 0.10.6", +] + [[package]] name = "sha1_smol" version = "1.0.0" @@ -2707,7 +2866,7 @@ dependencies = [ "serde", "serde_derive", "serde_json", - "sha1", + "sha1 0.6.1", "syn", ] @@ -2987,6 +3146,7 @@ version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f873044bf02dd1e8239e9c1293ea39dad76dc594ec16185d0a1bf31d8dc8d858" dependencies = [ + "async-compression", "bitflags", "bytes", "futures-core", @@ -2995,6 +3155,8 @@ dependencies = [ "http-body", "http-range-header", "pin-project-lite", + "tokio", + "tokio-util", "tower", "tower-layer", "tower-service", @@ -3097,6 +3259,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "unsafe-libyaml" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad2024452afd3874bf539695e04af6732ba06517424dbf958fdb16a01f3bef6c" + [[package]] name = "untrusted" version = "0.7.1" @@ -3404,3 +3572,8 @@ checksum = "aed2e7a52e3744ab4d0c05c20aa065258e84c49fd4226f5191b2ed29712710b4" dependencies = [ "time 0.3.17", ] + +[[patch.unused]] +name = "redb" +version = "0.11.0" +source = "git+https://github.com/casey/redb.git?branch=ord#826fd633b2e26bb649cf7004406cb469fbb28837" diff --git a/src/fee_rate.rs b/src/fee_rate.rs index 72d30834f5..e8f6c60494 100644 --- a/src/fee_rate.rs +++ b/src/fee_rate.rs @@ -23,7 +23,7 @@ impl TryFrom for FeeRate { } impl FeeRate { - pub(crate) fn fee(&self, vsize: usize) -> Amount { + pub fn fee(&self, vsize: usize) -> Amount { #[allow(clippy::cast_possible_truncation)] #[allow(clippy::cast_sign_loss)] Amount::from_sat((self.0 * vsize as f64).round() as u64) diff --git a/src/index.rs b/src/index.rs index e4c02d853c..32062bd38a 100644 --- a/src/index.rs +++ b/src/index.rs @@ -12,18 +12,20 @@ use { bitcoincore_rpc::{json::GetBlockHeaderResult, Auth, Client}, chrono::SubsecRound, indicatif::{ProgressBar, ProgressStyle}, + itertools::Itertools, log::log_enabled, redb::{Database, ReadableTable, Table, TableDefinition, WriteStrategy, WriteTransaction}, std::collections::HashMap, std::sync::atomic::{self, AtomicBool}, + std::sync::Mutex, }; -mod entry; +pub(crate) mod entry; mod fetcher; mod rtx; mod updater; -const SCHEMA_VERSION: u64 = 3; +const SCHEMA_VERSION: u64 = 4; macro_rules! define_table { ($name:ident, $key:ty, $value:ty) => { @@ -54,6 +56,7 @@ pub(crate) struct Index { height_limit: Option, reorged: AtomicBool, rpc_url: String, + cached_children_by_id: Mutex>>, } #[derive(Debug, PartialEq)] @@ -241,6 +244,7 @@ impl Index { height_limit: options.height_limit, reorged: AtomicBool::new(false), rpc_url, + cached_children_by_id: Mutex::new(HashMap::new()), }) } @@ -556,6 +560,45 @@ impl Index { ) } + pub(crate) fn get_children_by_id( + &self, + inscription_id: InscriptionId, + ) -> Result> { + { + let cache = self.cached_children_by_id.lock().unwrap(); + if let Some(cached_result) = cache.get(&inscription_id) { + return Ok(cached_result.clone()); + } + } + + let sorted_children = self + .database + .begin_read()? + .open_table(INSCRIPTION_ID_TO_INSCRIPTION_ENTRY)? + .iter()? + .filter_map(|(key, entry_value)| { + let entry = InscriptionEntry::load(entry_value.value()); + if entry.parent == Some(inscription_id) { + Some((InscriptionId::load(*key.value()), entry.number)) + } else { + None + } + }) + .collect::>() + .into_iter() + .sorted_by_key(|&(_id, number)| number) + .map(|(id, _)| id) + .collect::>(); + + self + .cached_children_by_id + .lock() + .unwrap() + .insert(inscription_id, sorted_children.clone()); + + Ok(sorted_children) + } + pub(crate) fn get_inscriptions_on_output( &self, outpoint: OutPoint, @@ -568,7 +611,6 @@ impl Index { .open_table(SATPOINT_TO_INSCRIPTION_ID)?, outpoint, )? - .into_iter() .map(|(_satpoint, inscription_id)| inscription_id) .collect(), ) @@ -1032,7 +1074,7 @@ mod tests { let inscription = inscription("text/plain;charset=utf-8", "hello"); let template = TransactionTemplate { inputs: &[(1, 0, 0)], - witness: inscription.to_witness(), + witnesses: vec![inscription.to_witness()], ..Default::default() }; @@ -1378,7 +1420,7 @@ mod tests { let txid = context.rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], - witness: inscription("text/plain", "hello").to_witness(), + witnesses: vec![inscription("text/plain", "hello").to_witness()], ..Default::default() }); let inscription_id = InscriptionId::from(txid); @@ -1403,7 +1445,7 @@ mod tests { let txid = context.rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], - witness: inscription("text/plain", "hello").to_witness(), + witnesses: vec![inscription("text/plain", "hello").to_witness()], ..Default::default() }); let inscription_id = InscriptionId::from(txid); @@ -1447,7 +1489,7 @@ mod tests { let first_txid = context.rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], - witness: inscription("text/plain", "hello").to_witness(), + witnesses: vec![inscription("text/plain", "hello").to_witness()], ..Default::default() }); @@ -1455,7 +1497,7 @@ mod tests { let second_txid = context.rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(2, 0, 0)], - witness: inscription("text/png", [1; 100]).to_witness(), + witnesses: vec![inscription("text/png", [1; 100]).to_witness()], ..Default::default() }); let second_inscription_id = InscriptionId::from(second_txid); @@ -1502,7 +1544,7 @@ mod tests { let txid = context.rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], - witness: inscription("text/plain", "hello").to_witness(), + witnesses: vec![inscription("text/plain", "hello").to_witness()], ..Default::default() }); let inscription_id = InscriptionId::from(txid); @@ -1551,7 +1593,7 @@ mod tests { let txid = context.rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], - witness: inscription("text/plain", "hello").to_witness(), + witnesses: vec![inscription("text/plain", "hello").to_witness()], ..Default::default() }); let inscription_id = InscriptionId::from(txid); @@ -1595,7 +1637,7 @@ mod tests { let txid = context.rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], - witness: inscription("text/plain", "hello").to_witness(), + witnesses: vec![inscription("text/plain", "hello").to_witness()], ..Default::default() }); let inscription_id = InscriptionId::from(txid); @@ -1632,7 +1674,7 @@ mod tests { let txid = context.rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], fee: 50 * COIN_VALUE, - witness: inscription("text/plain", "hello").to_witness(), + witnesses: vec![inscription("text/plain", "hello").to_witness()], ..Default::default() }); let inscription_id = InscriptionId::from(txid); @@ -1661,7 +1703,7 @@ mod tests { let txid = context.rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], fee: 50 * COIN_VALUE, - witness: inscription("text/plain", "hello").to_witness(), + witnesses: vec![inscription("text/plain", "hello").to_witness()], ..Default::default() }); let inscription_id = InscriptionId::from(txid); @@ -1687,7 +1729,7 @@ mod tests { let first_txid = context.rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], fee: 50 * COIN_VALUE, - witness: inscription("text/plain", "hello").to_witness(), + witnesses: vec![inscription("text/plain", "hello").to_witness()], ..Default::default() }); let first_inscription_id = InscriptionId::from(first_txid); @@ -1698,7 +1740,7 @@ mod tests { let second_txid = context.rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(3, 0, 0)], fee: 50 * COIN_VALUE, - witness: inscription("text/plain", "hello").to_witness(), + witnesses: vec![inscription("text/plain", "hello").to_witness()], ..Default::default() }); let second_inscription_id = InscriptionId::from(second_txid); @@ -1812,7 +1854,7 @@ mod tests { let txid = context.rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(2, 0, 0)], outputs: 2, - witness: inscription("text/plain", "hello").to_witness(), + witnesses: vec![inscription("text/plain", "hello").to_witness()], ..Default::default() }); let inscription_id = InscriptionId::from(txid); @@ -1844,7 +1886,7 @@ mod tests { let txid = context.rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], outputs: 2, - witness: inscription("text/plain", "hello").to_witness(), + witnesses: vec![inscription("text/plain", "hello").to_witness()], output_values: &[0, 50 * COIN_VALUE], ..Default::default() }); @@ -1870,7 +1912,7 @@ mod tests { let txid = context.rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], fee: 50 * COIN_VALUE, - witness: inscription("text/plain", "hello").to_witness(), + witnesses: vec![inscription("text/plain", "hello").to_witness()], ..Default::default() }); let inscription_id = InscriptionId::from(txid); @@ -1979,7 +2021,7 @@ mod tests { let txid = context.rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], - witness: inscription("text/plain", "hello").to_witness(), + witnesses: vec![inscription("text/plain", "hello").to_witness()], ..Default::default() }); @@ -2038,7 +2080,7 @@ mod tests { let first = context.rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], - witness: inscription("text/plain", "hello").to_witness(), + witnesses: vec![inscription("text/plain", "hello").to_witness()], ..Default::default() }); @@ -2071,7 +2113,7 @@ mod tests { let second = context.rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(2, 1, 0)], - witness: inscription("text/plain", "hello").to_witness(), + witnesses: vec![inscription("text/plain", "hello").to_witness()], ..Default::default() }); @@ -2110,7 +2152,7 @@ mod tests { let txid = context.rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], - witness: inscription("text/plain", "hello").to_witness(), + witnesses: vec![inscription("text/plain", "hello").to_witness()], ..Default::default() }); let inscription_id = InscriptionId::from(txid); @@ -2137,7 +2179,7 @@ mod tests { for i in 0..103 { let txid = context.rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(i + 1, 0, 0)], - witness: inscription("text/plain", "hello").to_witness(), + witnesses: vec![inscription("text/plain", "hello").to_witness()], ..Default::default() }); ids.push(InscriptionId::from(txid)); @@ -2190,4 +2232,80 @@ mod tests { ); } } + + #[test] + fn test_inscription_with_parent() { + // for context in Context::configurations() { + let context = Context::builder().build(); + + context.mine_blocks(1); + + let parent_txid = context.rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[(1, 0, 0)], + witnesses: vec![inscription("text/plain", "parent").to_witness()], + ..Default::default() + }); + + let parent_id = InscriptionId::from(parent_txid); + + context.mine_blocks(1); + + assert_eq!( + context.index.get_inscription_entry(parent_id).unwrap(), + Some(InscriptionEntry { + fee: 0, + height: 2, + number: 0, + parent: None, + sat: None, + timestamp: 2 + }) + ); + + let child_txid = context.rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[(2, 1, 0), (2, 0, 0)], + witnesses: vec![ + Witness::new(), + inscription_with_parent("text/plain", "child", parent_id).to_witness(), + ], + ..Default::default() + }); + + let child_id = InscriptionId { + txid: child_txid, + index: 0, + }; + + context.mine_blocks(1); + + // parent is transferred successfully + context.index.assert_inscription_location( + parent_id, + SatPoint { + outpoint: OutPoint { + txid: child_txid, + vout: 0, + }, + offset: 0, + }, + 50 * COIN_VALUE, + ); + + // child inscription successfully added to database + assert_eq!( + context.index.get_inscription_entry(child_id).unwrap(), + Some(InscriptionEntry { + fee: 0, + height: 3, + number: 1, + parent: Some(parent_id), + sat: None, + timestamp: 3 + }) + ); + + // child successfully retrieved from parent + let children = context.index.get_children_by_id(parent_id).unwrap(); + assert_eq!(children, vec![child_id]); + } } diff --git a/src/index/entry.rs b/src/index/entry.rs index 15ff3d8ecb..8a00ae6e2a 100644 --- a/src/index/entry.rs +++ b/src/index/entry.rs @@ -1,6 +1,6 @@ use super::*; -pub(super) trait Entry: Sized { +pub(crate) trait Entry: Sized { type Value; fn load(value: Self::Value) -> Self; @@ -22,24 +22,27 @@ impl Entry for BlockHash { } } +#[derive(Debug, PartialEq)] pub(crate) struct InscriptionEntry { pub(crate) fee: u64, pub(crate) height: u64, pub(crate) number: u64, + pub(crate) parent: Option, pub(crate) sat: Option, pub(crate) timestamp: u32, } -pub(crate) type InscriptionEntryValue = (u64, u64, u64, u64, u32); +pub(crate) type InscriptionEntryValue = (u64, u64, u64, (u128, u128, u32), u64, u32); impl Entry for InscriptionEntry { type Value = InscriptionEntryValue; - fn load((fee, height, number, sat, timestamp): InscriptionEntryValue) -> Self { + fn load((fee, height, number, parent, sat, timestamp): InscriptionEntryValue) -> Self { Self { fee, height, number, + parent: Entry::load(parent), sat: if sat == u64::MAX { None } else { @@ -54,6 +57,7 @@ impl Entry for InscriptionEntry { self.fee, self.height, self.number, + self.parent.store(), match self.sat { Some(sat) => sat.n(), None => u64::MAX, @@ -85,6 +89,74 @@ impl Entry for InscriptionId { } } +type ParentValue = (u128, u128, u32); + +impl Entry for Option { + type Value = ParentValue; + + fn load(value: Self::Value) -> Self { + if (0, 0, u32::MAX) == value { + None + } else { + let (head, tail, index) = value; + let head_array = head.to_le_bytes(); + let tail_array = tail.to_le_bytes(); + let index_array = index.to_be_bytes(); + let array = [ + head_array[0], + head_array[1], + head_array[2], + head_array[3], + head_array[4], + head_array[5], + head_array[6], + head_array[7], + head_array[8], + head_array[9], + head_array[10], + head_array[11], + head_array[12], + head_array[13], + head_array[14], + head_array[15], + tail_array[0], + tail_array[1], + tail_array[2], + tail_array[3], + tail_array[4], + tail_array[5], + tail_array[6], + tail_array[7], + tail_array[8], + tail_array[9], + tail_array[10], + tail_array[11], + tail_array[12], + tail_array[13], + tail_array[14], + tail_array[15], + index_array[0], + index_array[1], + index_array[2], + index_array[3], + ]; + + Some(InscriptionId::load(array)) + } + } + + fn store(self) -> Self::Value { + if let Some(inscription_id) = self { + let txid_entry = inscription_id.txid.store(); + let little_end = u128::from_le_bytes(txid_entry[..16].try_into().unwrap()); + let big_end = u128::from_le_bytes(txid_entry[16..].try_into().unwrap()); + (little_end, big_end, inscription_id.index) + } else { + (0, 0, u32::MAX) + } + } +} + pub(super) type OutPointValue = [u8; 36]; impl Entry for OutPoint { @@ -143,3 +215,100 @@ impl Entry for SatRange { n.to_le_bytes()[0..11].try_into().unwrap() } } + +pub(super) type TxidValue = [u8; 32]; + +impl Entry for Txid { + type Value = TxidValue; + + fn load(value: Self::Value) -> Self { + Txid::from_inner(value) + } + + fn store(self) -> Self::Value { + Txid::into_inner(self) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parent_entry() { + let inscription_id: Option = None; + + assert_eq!(inscription_id.store(), (0, 0, u32::MAX)); + assert_eq!( + as Entry>::load((0, 0, u32::MAX)), + inscription_id + ); + + let inscription_id = Some( + "0000000000000000000000000000000000000000000000000000000000000000i0" + .parse::() + .unwrap(), + ); + + assert_eq!(inscription_id.store(), (0, 0, 0)); + assert_eq!( + as Entry>::load((0, 0, 0)), + inscription_id + ); + + let inscription_id = Some( + "ffffffffffffffffffffffffffffffff00000000000000000000000000000000i0" + .parse::() + .unwrap(), + ); + + assert_eq!(inscription_id.store(), (0, u128::MAX, 0)); + assert_eq!( + as Entry>::load((0, u128::MAX, 0)), + inscription_id + ); + } + + #[test] + fn parent_entry_individual_byte_order() { + let inscription_id = Some( + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdefi0" + .parse::() + .unwrap(), + ); + + assert_eq!( + inscription_id.store(), + ( + 0x0123456789abcdef0123456789abcdef, + 0x0123456789abcdef0123456789abcdef, + 0 + ) + ); + + assert_eq!( + as Entry>::load(( + 0x0123456789abcdef0123456789abcdef, + 0x0123456789abcdef0123456789abcdef, + 0 + )), + inscription_id + ); + } + + #[test] + fn parent_entry_index() { + let inscription_id = Some( + "0000000000000000000000000000000000000000000000000000000000000000i1" + .parse::() + .unwrap(), + ); + + assert_eq!(inscription_id.store(), (0, 0, 1)); + + assert_eq!( + as Entry>::load((0, 0, 1)), + inscription_id + ); + } +} diff --git a/src/index/updater.rs b/src/index/updater.rs index 5d56e4e9ab..89906bf049 100644 --- a/src/index/updater.rs +++ b/src/index/updater.rs @@ -29,7 +29,7 @@ impl From for BlockData { } } -pub(crate) struct Updater { +pub(crate) struct Updater<'a> { range_cache: HashMap>, height: u64, index_sats: bool, @@ -37,10 +37,11 @@ pub(crate) struct Updater { outputs_cached: u64, outputs_inserted_since_flush: u64, outputs_traversed: u64, + cached_children_by_id: &'a Mutex>>, } -impl Updater { - pub(crate) fn update(index: &Index) -> Result { +impl<'a> Updater<'a> { + pub(crate) fn update(index: &'a Index) -> Result { let wtx = index.begin_write()?; let height = wtx @@ -65,10 +66,12 @@ impl Updater { range_cache: HashMap::new(), height, index_sats: index.has_sat_index()?, + sat_ranges_since_flush: 0, outputs_cached: 0, outputs_inserted_since_flush: 0, outputs_traversed: 0, + cached_children_by_id: &index.cached_children_by_id, }; updater.update_index(index, wtx) @@ -430,6 +433,7 @@ impl Updater { &mut satpoint_to_inscription_id, block.header.time, value_cache, + self.cached_children_by_id, )?; if self.index_sats { @@ -523,6 +527,7 @@ impl Updater { outpoint_to_sat_ranges.insert(&OutPoint::null().store(), lost_sat_ranges.as_slice())?; } } else { + // move coinbase to end for (tx, txid) in block.txdata.iter().skip(1).chain(block.txdata.first()) { lost_sats += inscription_updater.index_transaction_inscriptions(tx, *txid, None)?; } diff --git a/src/index/updater/inscription_updater.rs b/src/index/updater/inscription_updater.rs index 575ccf7ca7..d33535f707 100644 --- a/src/index/updater/inscription_updater.rs +++ b/src/index/updater/inscription_updater.rs @@ -1,13 +1,16 @@ -use super::*; +use {super::*, std::collections::BTreeSet}; +#[derive(Clone, Copy, Debug)] pub(super) struct Flotsam { inscription_id: InscriptionId, offset: u64, origin: Origin, } +// change name to Jetsam or more poetic german word +#[derive(Clone, Copy, Debug)] enum Origin { - New(u64), + New((u64, Option)), Old(SatPoint), } @@ -26,6 +29,7 @@ pub(super) struct InscriptionUpdater<'a, 'db, 'tx> { satpoint_to_id: &'a mut Table<'db, 'tx, &'static SatPointValue, &'static InscriptionIdValue>, timestamp: u32, value_cache: &'a mut HashMap, + cached_children_by_id: &'a Mutex>>, } impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { @@ -41,6 +45,7 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { satpoint_to_id: &'a mut Table<'db, 'tx, &'static SatPointValue, &'static InscriptionIdValue>, timestamp: u32, value_cache: &'a mut HashMap, + cached_children_by_id: &'a Mutex>>, ) -> Result { let next_number = number_to_id .iter()? @@ -64,6 +69,7 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { satpoint_to_id, timestamp, value_cache, + cached_children_by_id, }) } @@ -73,50 +79,109 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { txid: Txid, input_sat_ranges: Option<&VecDeque<(u64, u64)>>, ) -> Result { - let mut inscriptions = Vec::new(); - + let mut floating_inscriptions = Vec::new(); + let mut inscribed_offsets = BTreeSet::new(); let mut input_value = 0; for tx_in in &tx.input { + // skip subsidy since no inscriptions possible if tx_in.previous_output.is_null() { input_value += Height(self.height).subsidy(); - } else { - for (old_satpoint, inscription_id) in - Index::inscriptions_on_output(self.satpoint_to_id, tx_in.previous_output)? - { - inscriptions.push(Flotsam { - offset: input_value + old_satpoint.offset, + continue; + } + + // find existing inscriptions on input aka transfers + for (old_satpoint, inscription_id) in + Index::inscriptions_on_output(self.satpoint_to_id, tx_in.previous_output)? + { + floating_inscriptions.push(Flotsam { + offset: input_value + old_satpoint.offset, + inscription_id, + origin: Origin::Old(old_satpoint), + }); + + inscribed_offsets.insert(input_value + old_satpoint.offset); + } + + // find new inscriptions + if let Some(inscription) = Inscription::from_tx_input(tx_in) { + // ignore new inscriptions on already inscribed offset (sats) + if !inscribed_offsets.contains(&input_value) { + let inscription_id = InscriptionId { + txid, + index: 0, // will have to be updated for multi inscriptions + }; + + // parent has to be in an input before child + // think about specifying a more general approach in a protocol doc/BIP + if let Some(parent_candidate) = inscription.get_parent_id() { + log::debug!( + "INDEX: inscription {} has inscribed parent {}", + inscription_id, + parent_candidate + ); + } + + let parent = inscription.get_parent_id().filter(|&parent_id| { + floating_inscriptions + .iter() + .any(|flotsam| flotsam.inscription_id == parent_id) + }); + + if let Some(parent) = parent { + log::debug!( + "INDEX: inscription {} has confirmed parent {}", + inscription_id, + parent + ); + } + + floating_inscriptions.push(Flotsam { inscription_id, - origin: Origin::Old(old_satpoint), + offset: input_value, + origin: Origin::New((0, parent)), }); } + } - input_value += if let Some(value) = self.value_cache.remove(&tx_in.previous_output) { - value - } else if let Some(value) = self - .outpoint_to_value - .remove(&tx_in.previous_output.store())? - { - value.value() - } else { - self.value_receiver.blocking_recv().ok_or_else(|| { - anyhow!( - "failed to get transaction for {}", - tx_in.previous_output.txid - ) - })? - } + // different ways to get the utxo set (input amount) + input_value += if let Some(value) = self.value_cache.remove(&tx_in.previous_output) { + value + } else if let Some(value) = self + .outpoint_to_value + .remove(&tx_in.previous_output.store())? + { + value.value() + } else { + self.value_receiver.blocking_recv().ok_or_else(|| { + anyhow!( + "failed to get transaction for {}", + tx_in.previous_output.txid + ) + })? } } - if inscriptions.iter().all(|flotsam| flotsam.offset != 0) - && Inscription::from_transaction(tx).is_some() - { - inscriptions.push(Flotsam { - inscription_id: txid.into(), - offset: 0, - origin: Origin::New(input_value - tx.output.iter().map(|txout| txout.value).sum::()), - }); - }; + // calulate genesis fee for new inscriptions + let total_output_value = tx.output.iter().map(|txout| txout.value).sum::(); + let mut floating_inscriptions = floating_inscriptions + .into_iter() + .map(|flotsam| { + if let Flotsam { + inscription_id, + offset, + origin: Origin::New((_, parent)), + } = flotsam + { + Flotsam { + inscription_id, + offset, + origin: Origin::New((input_value - total_output_value, parent)), + } + } else { + flotsam + } + }) + .collect::>(); let is_coinbase = tx .input @@ -125,11 +190,11 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { .unwrap_or_default(); if is_coinbase { - inscriptions.append(&mut self.flotsam); + floating_inscriptions.append(&mut self.flotsam); } - inscriptions.sort_by_key(|flotsam| flotsam.offset); - let mut inscriptions = inscriptions.into_iter().peekable(); + floating_inscriptions.sort_by_key(|flotsam| flotsam.offset); + let mut inscriptions = floating_inscriptions.into_iter().peekable(); let mut output_value = 0; for (vout, tx_out) in tx.output.iter().enumerate() { @@ -150,7 +215,7 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { self.update_inscription_location( input_sat_ranges, - inscriptions.next().unwrap(), + inscriptions.next().unwrap(), // This will need to change when we implement multiple inscriptions per TX (#1298). new_satpoint, )?; } @@ -198,7 +263,7 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { Origin::Old(old_satpoint) => { self.satpoint_to_id.remove(&old_satpoint.store())?; } - Origin::New(fee) => { + Origin::New((fee, parent)) => { self .number_to_id .insert(&self.next_number, &inscription_id)?; @@ -218,18 +283,30 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { } } + log::debug!( + "INDEX: assigned {} for inscription {} at height {}", + &self.next_number, + flotsam.inscription_id, + self.height + ); + self.id_to_entry.insert( &inscription_id, &InscriptionEntry { fee, height: self.height, number: self.next_number, + parent, sat, timestamp: self.timestamp, } .store(), )?; + if let Some(parent) = parent { + self.update_cached_children(parent, flotsam.inscription_id); + } + self.next_number += 1; } } @@ -241,4 +318,13 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { Ok(()) } + + fn update_cached_children(&self, parent: InscriptionId, inscription_id: InscriptionId) { + let mut cache = self.cached_children_by_id.lock().unwrap(); + + // only update the cache if it is already populated, so we retrieve the full list of children when required + if let Some(children) = cache.get_mut(&parent) { + children.push(inscription_id); + } + } } diff --git a/src/inscription.rs b/src/inscription.rs index d0fba77016..e4b8ad648f 100644 --- a/src/inscription.rs +++ b/src/inscription.rs @@ -1,5 +1,6 @@ use { super::*, + crate::index::entry::Entry, bitcoin::{ blockdata::{ opcodes, @@ -15,24 +16,48 @@ const PROTOCOL_ID: &[u8] = b"ord"; const BODY_TAG: &[u8] = &[]; const CONTENT_TYPE_TAG: &[u8] = &[1]; +const PARENT_TAG: &[u8] = &[3]; #[derive(Debug, PartialEq, Clone)] pub(crate) struct Inscription { - body: Option>, + parent: Option, content_type: Option>, + body: Option>, } impl Inscription { #[cfg(test)] - pub(crate) fn new(content_type: Option>, body: Option>) -> Self { - Self { content_type, body } + pub(crate) fn new( + parent: Option, + content_type: Option>, + body: Option>, + ) -> Self { + Self { + parent, + content_type, + body, + } } pub(crate) fn from_transaction(tx: &Transaction) -> Option { - InscriptionParser::parse(&tx.input.get(0)?.witness).ok() + for input in &tx.input { + if let Some(inscription) = Inscription::from_tx_input(input) { + return Some(inscription); + } + } + + None } - pub(crate) fn from_file(chain: Chain, path: impl AsRef) -> Result { + pub(crate) fn from_tx_input(tx_in: &TxIn) -> Option { + InscriptionParser::parse(&tx_in.witness).ok() + } + + pub(crate) fn from_file( + chain: Chain, + path: impl AsRef, + parent: Option, + ) -> Result { let path = path.as_ref(); let body = fs::read(path).with_context(|| format!("io error reading {}", path.display()))?; @@ -47,6 +72,7 @@ impl Inscription { let content_type = Media::content_type_for_path(path)?; Ok(Self { + parent, body: Some(body), content_type: Some(content_type.into()), }) @@ -58,6 +84,10 @@ impl Inscription { .push_opcode(opcodes::all::OP_IF) .push_slice(PROTOCOL_ID); + if let Some(parent) = &self.parent { + builder = builder.push_slice(PARENT_TAG).push_slice(&parent.store()); + } + if let Some(content_type) = &self.content_type { builder = builder .push_slice(CONTENT_TYPE_TAG) @@ -106,6 +136,10 @@ impl Inscription { str::from_utf8(self.content_type.as_ref()?).ok() } + pub(crate) fn get_parent_id(&self) -> Option { + self.parent + } + #[cfg(test)] pub(crate) fn to_witness(&self) -> Witness { let builder = script::Builder::new(); @@ -222,6 +256,7 @@ impl<'a> InscriptionParser<'a> { let body = fields.remove(BODY_TAG); let content_type = fields.remove(CONTENT_TYPE_TAG); + let parent = fields.remove(PARENT_TAG); for tag in fields.keys() { if let Some(lsb) = tag.first() { @@ -231,7 +266,12 @@ impl<'a> InscriptionParser<'a> { } } - return Ok(Some(Inscription { body, content_type })); + return Ok(Some(Inscription { + body, + content_type, + parent: parent + .and_then(|parent| Some(InscriptionId::load(parent.as_slice().try_into().ok()?))), + })); } Ok(None) @@ -358,7 +398,7 @@ mod tests { b"ord", &[1], b"text/plain;charset=utf-8", - &[3], + &[5], b"bar", &[], b"ord", @@ -372,6 +412,7 @@ mod tests { assert_eq!( InscriptionParser::parse(&envelope(&[b"ord", &[1], b"text/plain;charset=utf-8"])), Ok(Inscription { + parent: None, content_type: Some(b"text/plain;charset=utf-8".to_vec()), body: None, }), @@ -383,6 +424,7 @@ mod tests { assert_eq!( InscriptionParser::parse(&envelope(&[b"ord", &[], b"foo"])), Ok(Inscription { + parent: None, content_type: None, body: Some(b"foo".to_vec()), }), @@ -576,7 +618,8 @@ mod tests { } #[test] - fn do_not_extract_from_second_input() { + fn do_extract_from_second_input() { + let inscription = inscription("foo", [1; 1040]); let tx = Transaction { version: 0, lock_time: bitcoin::PackedLockTime(0), @@ -591,13 +634,13 @@ mod tests { previous_output: OutPoint::null(), script_sig: Script::new(), sequence: Sequence(0), - witness: inscription("foo", [1; 1040]).to_witness(), + witness: inscription.to_witness(), }, ], output: Vec::new(), }; - assert_eq!(Inscription::from_transaction(&tx), None); + assert_eq!(Inscription::from_transaction(&tx), Some(inscription)); } #[test] @@ -705,6 +748,7 @@ mod tests { witness.push( &Inscription { + parent: None, content_type: None, body: None, } @@ -716,6 +760,7 @@ mod tests { assert_eq!( InscriptionParser::parse(&witness).unwrap(), Inscription { + parent: None, content_type: None, body: None, } @@ -725,8 +770,9 @@ mod tests { #[test] fn unknown_odd_fields_are_ignored() { assert_eq!( - InscriptionParser::parse(&envelope(&[b"ord", &[3], &[0]])), + InscriptionParser::parse(&envelope(&[b"ord", &[5], &[0]])), Ok(Inscription { + parent: None, content_type: None, body: None, }), diff --git a/src/subcommand/preview.rs b/src/subcommand/preview.rs index d9f402e47e..13ba8d1730 100644 --- a/src/subcommand/preview.rs +++ b/src/subcommand/preview.rs @@ -86,6 +86,7 @@ impl Preview { dry_run: false, no_limit: false, destination: None, + parent: None, }, )), } diff --git a/src/subcommand/server.rs b/src/subcommand/server.rs index 63adafb300..e0b3ab9382 100644 --- a/src/subcommand/server.rs +++ b/src/subcommand/server.rs @@ -1,3 +1,5 @@ +use serde_json::Value; + use { self::{ deserialize_from_str::DeserializeFromStr, @@ -6,9 +8,10 @@ use { super::*, crate::page_config::PageConfig, crate::templates::{ - BlockHtml, ClockSvg, HomeHtml, InputHtml, InscriptionHtml, InscriptionsHtml, OutputHtml, - PageContent, PageHtml, PreviewAudioHtml, PreviewImageHtml, PreviewPdfHtml, PreviewTextHtml, - PreviewUnknownHtml, PreviewVideoHtml, RangeHtml, RareTxt, SatHtml, TransactionHtml, + BlockHtml, ClockSvg, HomeHtml, InputHtml, InscriptionChildrenHtml, InscriptionHtml, + InscriptionsHtml, OutputHtml, PageContent, PageHtml, PreviewAudioHtml, PreviewImageHtml, + PreviewPdfHtml, PreviewTextHtml, PreviewUnknownHtml, PreviewVideoHtml, RangeHtml, RareTxt, + SatHtml, TransactionHtml, }, axum::{ body, @@ -91,7 +94,7 @@ impl Display for StaticHtml { pub(crate) struct Server { #[clap( long, - default_value = "0.0.0.0", + default_value = "127.0.0.1", help = "Listen on
for incoming requests." )] address: String, @@ -154,6 +157,14 @@ impl Server { .route("/feed.xml", get(Self::feed)) .route("/input/:block/:transaction/:input", get(Self::input)) .route("/inscription/:inscription_id", get(Self::inscription)) + .route( + "/inscription/:inscription_id/children", + get(Self::inscription_children), + ) + .route( + "/inscription/:inscription_id/child/:index", + get(Self::inscription_child), + ) .route("/inscriptions", get(Self::inscriptions)) .route("/inscriptions/:from", get(Self::inscriptions_from)) .route("/install.sh", get(Self::install_script)) @@ -712,6 +723,69 @@ impl Server { Redirect::to("https://docs.ordinals.com/bounty/") } + fn get_parent_url_params_if_child_pointer(inscription: &Inscription) -> Option { + if !inscription.get_parent_id().is_some() { + return None; + } + + if !Self::valid_json(inscription.body()) { + return None; + } + + let json_result: Result = serde_json::from_slice(&inscription.body().unwrap()); + let json: Value = json_result.unwrap(); + + if !json.as_object().unwrap().contains_key("use_p") { + return None; + } + + let parent_url_params = Self::get_url_params_from_json_value(json); + + Some(parent_url_params) + } + + fn valid_json(data: Option<&[u8]>) -> bool { + match data { + Some(bytes) => serde_json::from_slice::(bytes).is_ok(), + None => false, + } + } + + fn get_url_params_from_json_value(json: Value) -> String { + let mut params_str = String::new(); + + if let Some(url_params_field) = json.get("params") { + if let Some(url_params) = url_params_field.as_array() { + let param_strs: Vec<&str> = url_params.into_iter().map(|v| v.as_str().unwrap()).collect(); + params_str = param_strs.join("&"); + } + } + + if params_str.is_empty() { + return params_str; + } + + format!("?{params_str}") + } + + fn get_content_response_if_child_pointer(inscription: &Inscription) -> Option> { + let parent_url_params = Self::get_parent_url_params_if_child_pointer(inscription); + if let Some(url_params) = parent_url_params { + let redirect_uri = format!("/content/{}{}", inscription.get_parent_id().unwrap(), url_params); + return Some(Ok(Redirect::permanent(&redirect_uri).into_response())); + } + None + } + + fn get_preview_response_if_child_pointer(inscription: &Inscription) -> Option> { + let parent_url_params = Self::get_parent_url_params_if_child_pointer(inscription); + if let Some(url_params) = parent_url_params { + let redirect_uri = format!("/preview/{}{}", inscription.get_parent_id().unwrap(), url_params); + return Some(Ok(Redirect::permanent(&redirect_uri).into_response())); + } + None + } + async fn content( Extension(index): Extension>, Extension(config): Extension>, @@ -725,6 +799,10 @@ impl Server { .get_inscription_by_id(inscription_id)? .ok_or_not_found(|| format!("inscription {inscription_id}"))?; + if let Some(response) = Self::get_content_response_if_child_pointer(&inscription) { + return response; + } + Ok( Self::content_response(inscription) .ok_or_not_found(|| format!("inscription {inscription_id} content"))? @@ -768,7 +846,11 @@ impl Server { .get_inscription_by_id(inscription_id)? .ok_or_not_found(|| format!("inscription {inscription_id}"))?; - return match inscription.media() { + if let Some(response) = Self::get_preview_response_if_child_pointer(&inscription) { + return response; + } + + match inscription.media() { Media::Audio => Ok(PreviewAudioHtml { inscription_id }.into_response()), Media::Iframe => Ok( Self::content_response(inscription) @@ -809,7 +891,7 @@ impl Server { } Media::Unknown => Ok(PreviewUnknownHtml.into_response()), Media::Video => Ok(PreviewVideoHtml { inscription_id }.into_response()), - }; + } } async fn inscription( @@ -825,6 +907,8 @@ impl Server { .get_inscription_by_id(inscription_id)? .ok_or_not_found(|| format!("inscription {inscription_id}"))?; + let children = index.get_children_by_id(inscription_id)?; + let satpoint = index .get_inscription_satpoint_by_id(inscription_id)? .ok_or_not_found(|| format!("inscription {inscription_id}"))?; @@ -852,6 +936,7 @@ impl Server { Ok( InscriptionHtml { chain: page_config.chain, + children, genesis_fee: entry.fee, genesis_height: entry.height, inscription, @@ -859,6 +944,7 @@ impl Server { next, number: entry.number, output, + parent: entry.parent, previous, sat: entry.sat, satpoint, @@ -868,6 +954,39 @@ impl Server { ) } + async fn inscription_children( + Extension(page_config): Extension>, + Extension(index): Extension>, + Path(inscription_id): Path, + ) -> ServerResult> { + let entry = index + .get_inscription_entry(inscription_id)? + .ok_or_not_found(|| format!("inscription {inscription_id}"))?; + + Ok( + InscriptionChildrenHtml { + parent_id: inscription_id, + parent_number: entry.number, + children: index.get_children_by_id(inscription_id)?, + } + .page(page_config, index.has_sat_index()?), + ) + } + + async fn inscription_child( + Extension(page_config): Extension>, + Extension(index): Extension>, + Path((parent_id, child_num)): Path<(InscriptionId, usize)>, + ) -> ServerResult> { + let children = index.get_children_by_id(parent_id)?; + + let child_id = children + .get(child_num) + .ok_or_not_found(|| format!("child #{child_num} for parent {parent_id}"))?; + + Self::inscription(Extension(page_config), Extension(index), Path(*child_id)).await + } + async fn inscriptions( Extension(page_config): Extension>, Extension(index): Extension>, @@ -937,6 +1056,12 @@ mod tests { Self::new_server(test_bitcoincore_rpc::spawn(), None, ord_args, server_args) } + fn new_with_bitcoin_rpc_server( + bitcoin_rpc_server: test_bitcoincore_rpc::Handle, + ) -> Self { + Self::new_server(bitcoin_rpc_server, None, &[], &[]) + } + fn new_with_bitcoin_rpc_server_and_config( bitcoin_rpc_server: test_bitcoincore_rpc::Handle, config: String, @@ -1083,6 +1208,19 @@ mod tests { assert_eq!(response.headers().get(header::LOCATION).unwrap(), location); } + fn assert_redirect_permanent(&self, path: &str, location: &str) { + let response = reqwest::blocking::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap() + .get(self.join_url(path)) + .send() + .unwrap(); + + assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT); + assert_eq!(response.headers().get(header::LOCATION).unwrap(), location); + } + fn mine_blocks(&self, n: u64) -> Vec { let blocks = self.bitcoin_rpc_server.mine_blocks(n); self.index.update().unwrap(); @@ -1971,6 +2109,7 @@ mod tests { fn content_response_no_content() { assert_eq!( Server::content_response(Inscription::new( + None, Some("text/plain".as_bytes().to_vec()), None )), @@ -1981,6 +2120,7 @@ mod tests { #[test] fn content_response_with_content() { let (headers, body) = Server::content_response(Inscription::new( + None, Some("text/plain".as_bytes().to_vec()), Some(vec![1, 2, 3]), )) @@ -1993,7 +2133,7 @@ mod tests { #[test] fn content_response_no_content_type() { let (headers, body) = - Server::content_response(Inscription::new(None, Some(Vec::new()))).unwrap(); + Server::content_response(Inscription::new(None, None, Some(Vec::new()))).unwrap(); assert_eq!(headers["content-type"], "application/octet-stream"); assert!(body.is_empty()); @@ -2006,7 +2146,7 @@ mod tests { let txid = server.bitcoin_rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], - witness: inscription("text/plain;charset=utf-8", "hello").to_witness(), + witnesses: vec![inscription("text/plain;charset=utf-8", "hello").to_witness()], ..Default::default() }); @@ -2027,7 +2167,7 @@ mod tests { let txid = server.bitcoin_rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], - witness: inscription("text/plain;charset=utf-8", b"\xc3\x28").to_witness(), + witnesses: vec![inscription("text/plain;charset=utf-8", b"\xc3\x28").to_witness()], ..Default::default() }); @@ -2047,11 +2187,11 @@ mod tests { let txid = server.bitcoin_rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], - witness: inscription( + witnesses: vec![inscription( "text/plain;charset=utf-8", "", ) - .to_witness(), + .to_witness()], ..Default::default() }); @@ -2072,7 +2212,7 @@ mod tests { let txid = server.bitcoin_rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], - witness: inscription("audio/flac", "hello").to_witness(), + witnesses: vec![inscription("audio/flac", "hello").to_witness()], ..Default::default() }); let inscription_id = InscriptionId::from(txid); @@ -2093,7 +2233,7 @@ mod tests { let txid = server.bitcoin_rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], - witness: inscription("application/pdf", "hello").to_witness(), + witnesses: vec![inscription("application/pdf", "hello").to_witness()], ..Default::default() }); let inscription_id = InscriptionId::from(txid); @@ -2114,7 +2254,7 @@ mod tests { let txid = server.bitcoin_rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], - witness: inscription("image/png", "hello").to_witness(), + witnesses: vec![inscription("image/png", "hello").to_witness()], ..Default::default() }); let inscription_id = InscriptionId::from(txid); @@ -2136,7 +2276,7 @@ mod tests { let txid = server.bitcoin_rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], - witness: inscription("text/html;charset=utf-8", "hello").to_witness(), + witnesses: vec![inscription("text/html;charset=utf-8", "hello").to_witness()], ..Default::default() }); @@ -2157,7 +2297,7 @@ mod tests { let txid = server.bitcoin_rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], - witness: inscription("text/foo", "hello").to_witness(), + witnesses: vec![inscription("text/foo", "hello").to_witness()], ..Default::default() }); @@ -2178,7 +2318,7 @@ mod tests { let txid = server.bitcoin_rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], - witness: inscription("video/webm", "hello").to_witness(), + witnesses: vec![inscription("video/webm", "hello").to_witness()], ..Default::default() }); let inscription_id = InscriptionId::from(txid); @@ -2199,7 +2339,7 @@ mod tests { let txid = server.bitcoin_rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], - witness: inscription("text/foo", "hello").to_witness(), + witnesses: vec![inscription("text/foo", "hello").to_witness()], ..Default::default() }); @@ -2219,7 +2359,7 @@ mod tests { let txid = server.bitcoin_rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], - witness: inscription("text/foo", "hello").to_witness(), + witnesses: vec![inscription("text/foo", "hello").to_witness()], ..Default::default() }); @@ -2239,7 +2379,7 @@ mod tests { let txid = server.bitcoin_rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], - witness: inscription("text/foo", "hello").to_witness(), + witnesses: vec![inscription("text/foo", "hello").to_witness()], ..Default::default() }); @@ -2271,7 +2411,7 @@ mod tests { server.bitcoin_rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], - witness: inscription("text/foo", "hello").to_witness(), + witnesses: vec![inscription("text/foo", "hello").to_witness()], ..Default::default() }); @@ -2291,7 +2431,9 @@ mod tests { let txid = server.bitcoin_rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], - witness: Inscription::new(Some("foo/bar".as_bytes().to_vec()), None).to_witness(), + witnesses: vec![ + Inscription::new(None, Some("foo/bar".as_bytes().to_vec()), None).to_witness(), + ], ..Default::default() }); @@ -2313,7 +2455,9 @@ mod tests { let txid = server.bitcoin_rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], - witness: Inscription::new(Some("image/png".as_bytes().to_vec()), None).to_witness(), + witnesses: vec![ + Inscription::new(None, Some("image/png".as_bytes().to_vec()), None).to_witness(), + ], ..Default::default() }); @@ -2335,7 +2479,7 @@ mod tests { let txid = server.bitcoin_rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], - witness: inscription("text/foo", "hello").to_witness(), + witnesses: vec![inscription("text/foo", "hello").to_witness()], ..Default::default() }); @@ -2367,7 +2511,7 @@ mod tests { server.mine_blocks(1); server.bitcoin_rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(i + 1, 0, 0)], - witness: inscription("text/foo", "hello").to_witness(), + witnesses: vec![inscription("text/foo", "hello").to_witness()], ..Default::default() }); } @@ -2389,7 +2533,7 @@ mod tests { server.mine_blocks(1); server.bitcoin_rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(i + 1, 0, 0)], - witness: inscription("text/foo", "hello").to_witness(), + witnesses: vec![inscription("text/foo", "hello").to_witness()], ..Default::default() }); } @@ -2453,7 +2597,7 @@ mod tests { bitcoin_rpc_server.mine_blocks(1); let txid = bitcoin_rpc_server.broadcast_tx(TransactionTemplate { inputs: &[(1, 0, 0)], - witness: inscription("text/plain;charset=utf-8", "hello").to_witness(), + witnesses: vec![inscription("text/plain;charset=utf-8", "hello").to_witness()], ..Default::default() }); let inscription = InscriptionId::from(txid); @@ -2476,4 +2620,172 @@ mod tests { &fs::read_to_string("templates/preview-unknown.html").unwrap(), ); } + + #[test] + fn ord_pointer_child_inscription_redirect_to_parent_with_url_params() { + let bitcoin_rpc_server = test_bitcoincore_rpc::spawn(); + bitcoin_rpc_server.mine_blocks(1); + let parent_txid = bitcoin_rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[(1, 0, 0)], + witnesses: vec![inscription("text/plain;charset=utf-8", "hello").to_witness()], + ..Default::default() + }); + let parent_inscription = InscriptionId::from(parent_txid); + + bitcoin_rpc_server.mine_blocks(1); + + let child_txid = bitcoin_rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[(2, 1, 0), (2, 0, 0)], + witnesses: vec![ + Witness::new(), + inscription_with_parent( + "application/json", + "{\"use_p\":1,\"params\":[\"tokenID=69\"]}", + parent_inscription + ).to_witness() + ], + ..Default::default() + }); + let child_inscription = InscriptionId { + txid: child_txid, + index: 0, + }; + + bitcoin_rpc_server.mine_blocks(1); + + let server = TestServer::new_with_bitcoin_rpc_server(bitcoin_rpc_server); + + server.assert_redirect_permanent( + &format!("/preview/{child_inscription}"), + &format!("/preview/{parent_inscription}?tokenID=69") + ); + server.assert_redirect_permanent( + &format!("/content/{child_inscription}"), + &format!("/content/{parent_inscription}?tokenID=69") + ); + } + + #[test] + fn ord_pointer_child_inscription_redirect_to_parent_without_url_params() { + let bitcoin_rpc_server = test_bitcoincore_rpc::spawn(); + bitcoin_rpc_server.mine_blocks(1); + let parent_txid = bitcoin_rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[(1, 0, 0)], + witnesses: vec![inscription("text/plain;charset=utf-8", "hello").to_witness()], + ..Default::default() + }); + let parent_inscription = InscriptionId::from(parent_txid); + + bitcoin_rpc_server.mine_blocks(1); + + let child_txid = bitcoin_rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[(2, 1, 0), (2, 0, 0)], + witnesses: vec![ + Witness::new(), + inscription_with_parent( + "application/json", + "{\"use_p\":1}", + parent_inscription + ).to_witness() + ], + ..Default::default() + }); + let child_inscription = InscriptionId { + txid: child_txid, + index: 0, + }; + + bitcoin_rpc_server.mine_blocks(1); + + let server = TestServer::new_with_bitcoin_rpc_server(bitcoin_rpc_server); + + server.assert_redirect_permanent( + &format!("/preview/{child_inscription}"), + &format!("/preview/{parent_inscription}") + ); + server.assert_redirect_permanent( + &format!("/content/{child_inscription}"), + &format!("/content/{parent_inscription}") + ); + } + + #[test] + fn non_pointer_json_child_inscription_does_not_redirect_to_parent() { + let bitcoin_rpc_server = test_bitcoincore_rpc::spawn(); + bitcoin_rpc_server.mine_blocks(1); + let parent_txid = bitcoin_rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[(1, 0, 0)], + witnesses: vec![inscription("text/plain;charset=utf-8", "hello").to_witness()], + ..Default::default() + }); + let parent_inscription = InscriptionId::from(parent_txid); + + bitcoin_rpc_server.mine_blocks(1); + + let child_txid = bitcoin_rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[(2, 1, 0), (2, 0, 0)], + witnesses: vec![ + Witness::new(), + inscription_with_parent( + "application/json", + "{\"not_ord_pointer\":1}", + parent_inscription + ).to_witness() + ], + ..Default::default() + }); + let child_inscription = InscriptionId { + txid: child_txid, + index: 0, + }; + + bitcoin_rpc_server.mine_blocks(1); + + let server = TestServer::new_with_bitcoin_rpc_server(bitcoin_rpc_server); + + server.assert_response_csp( + format!("/preview/{}", child_inscription), + StatusCode::OK, + "default-src 'self'", + ".*
\\{"not_ord_pointer":1\\}
.*", + ); + } + + #[test] + fn non_pointer_child_inscription_does_not_redirect_to_parent() { + let bitcoin_rpc_server = test_bitcoincore_rpc::spawn(); + bitcoin_rpc_server.mine_blocks(1); + let parent_txid = bitcoin_rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[(1, 0, 0)], + witnesses: vec![inscription("text/plain;charset=utf-8", "hello").to_witness()], + ..Default::default() + }); + let parent_inscription = InscriptionId::from(parent_txid); + + bitcoin_rpc_server.mine_blocks(1); + + let child_txid = bitcoin_rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[(2, 1, 0), (2, 0, 0)], + witnesses: vec![ + Witness::new(), + inscription_with_parent("text/plain;charset=utf-8", "child", parent_inscription).to_witness(), + ], + ..Default::default() + }); + let child_inscription = InscriptionId { + txid: child_txid, + index: 0, + }; + + bitcoin_rpc_server.mine_blocks(1); + + let server = TestServer::new_with_bitcoin_rpc_server(bitcoin_rpc_server); + + server.assert_response_csp( + format!("/preview/{}", child_inscription), + StatusCode::OK, + "default-src 'self'", + ".*
child
.*", + ); + } } diff --git a/src/subcommand/wallet.rs b/src/subcommand/wallet.rs index 0404f2ad24..914805d5af 100644 --- a/src/subcommand/wallet.rs +++ b/src/subcommand/wallet.rs @@ -15,6 +15,7 @@ use { }; pub mod balance; +pub mod cardinals; pub mod create; pub(crate) mod inscribe; pub mod inscriptions; @@ -46,8 +47,10 @@ pub(crate) enum Wallet { Send(send::Send), #[clap(about = "See wallet transactions")] Transactions(transactions::Transactions), - #[clap(about = "List wallet outputs")] + #[clap(about = "List all unspent outputs in wallet")] Outputs, + #[clap(about = "List unspent cardinal outputs in wallet")] + Cardinals, } impl Wallet { @@ -63,6 +66,7 @@ impl Wallet { Self::Send(send) => send.run(options), Self::Transactions(transactions) => transactions.run(options), Self::Outputs => outputs::run(options), + Self::Cardinals => cardinals::run(options), } } } @@ -136,7 +140,7 @@ fn derive_and_import_descriptor( active: Some(true), range: None, next_index: None, - internal: Some(!change), + internal: Some(change), label: None, })?; diff --git a/src/subcommand/wallet/cardinals.rs b/src/subcommand/wallet/cardinals.rs new file mode 100644 index 0000000000..32076229a1 --- /dev/null +++ b/src/subcommand/wallet/cardinals.rs @@ -0,0 +1,37 @@ +use {super::*, crate::wallet::Wallet, std::collections::BTreeSet}; + +#[derive(Serialize, Deserialize)] +pub struct Cardinal { + pub output: OutPoint, + pub amount: u64, +} + +pub(crate) fn run(options: Options) -> Result { + let index = Index::open(&options)?; + index.update()?; + + let inscribed_utxos = index + .get_inscriptions(None)? + .keys() + .map(|satpoint| satpoint.outpoint) + .collect::>(); + + let cardinal_utxos = index + .get_unspent_outputs(Wallet::load(&options)?)? + .iter() + .filter_map(|(output, amount)| { + if inscribed_utxos.contains(output) { + None + } else { + Some(Cardinal { + output: *output, + amount: amount.to_sat(), + }) + } + }) + .collect::>(); + + print_json(cardinal_utxos)?; + + Ok(()) +} diff --git a/src/subcommand/wallet/inscribe.rs b/src/subcommand/wallet/inscribe.rs index d9b537f82d..d0f38dcd4a 100644 --- a/src/subcommand/wallet/inscribe.rs +++ b/src/subcommand/wallet/inscribe.rs @@ -1,3 +1,5 @@ +use bitcoin::SchnorrSig; + use { super::*, crate::wallet::Wallet, @@ -22,6 +24,7 @@ use { struct Output { commit: Txid, inscription: InscriptionId, + parent: Option, reveal: Txid, fees: u64, } @@ -30,11 +33,7 @@ struct Output { pub(crate) struct Inscribe { #[clap(long, help = "Inscribe ")] pub(crate) satpoint: Option, - #[clap( - long, - default_value = "1.0", - help = "Use fee rate of sats/vB" - )] + #[clap(long, help = "Use fee rate of sats/vB")] pub(crate) fee_rate: FeeRate, #[clap( long, @@ -54,12 +53,12 @@ pub(crate) struct Inscribe { pub(crate) dry_run: bool, #[clap(long, help = "Send inscription to .")] pub(crate) destination: Option
, + #[clap(long, help = "Establish parent relationship with .")] + pub(crate) parent: Option, } impl Inscribe { pub(crate) fn run(self, options: Options) -> Result { - let inscription = Inscription::from_file(options.chain(), &self.file)?; - let index = Index::open(&options)?; index.update()?; @@ -69,6 +68,34 @@ impl Inscribe { let inscriptions = index.get_inscriptions(None)?; + let (parent, commit_input_offset) = if let Some(parent_id) = self.parent { + if let Some(satpoint) = index.get_inscription_satpoint_by_id(parent_id)? { + if !utxos.contains_key(&satpoint.outpoint) { + return Err(anyhow!(format!( + "unrelated parent {parent_id} not accepting mailman's child" // for the germans: "Kuckuckskind" + ))); + } + + let output = index + .get_transaction(satpoint.outpoint.txid)? + .expect("not found") + .output + .into_iter() + .nth(satpoint.outpoint.vout.try_into().unwrap()) + .expect("current transaction output"); + + (Some((satpoint, output)), 1) + } else { + return Err(anyhow!(format!( + "specified parent {parent_id} does not exist" + ))); + } + } else { + (None, 0) + }; + + let inscription = Inscription::from_file(options.chain(), &self.file, self.parent)?; + let commit_tx_change = [get_change_address(&client)?, get_change_address(&client)?]; let reveal_tx_destination = self @@ -76,9 +103,10 @@ impl Inscribe { .map(Ok) .unwrap_or_else(|| get_change_address(&client))?; - let (unsigned_commit_tx, reveal_tx, recovery_key_pair) = + let (unsigned_commit_tx, partially_signed_reveal_tx, _recovery_key_pair) = Inscribe::create_inscription_transactions( self.satpoint, + parent, inscription, inscriptions, options.chain().network(), @@ -91,47 +119,69 @@ impl Inscribe { )?; utxos.insert( - reveal_tx.input[0].previous_output, + partially_signed_reveal_tx.input[commit_input_offset].previous_output, Amount::from_sat( - unsigned_commit_tx.output[reveal_tx.input[0].previous_output.vout as usize].value, + unsigned_commit_tx.output[partially_signed_reveal_tx.input[commit_input_offset] + .previous_output + .vout as usize] + .value, ), ); - let fees = - Self::calculate_fee(&unsigned_commit_tx, &utxos) + Self::calculate_fee(&reveal_tx, &utxos); + let fees = Self::calculate_fee(&unsigned_commit_tx, &utxos) + + Self::calculate_fee(&partially_signed_reveal_tx, &utxos); if self.dry_run { print_json(Output { commit: unsigned_commit_tx.txid(), - reveal: reveal_tx.txid(), - inscription: reveal_tx.txid().into(), + reveal: partially_signed_reveal_tx.txid(), + inscription: partially_signed_reveal_tx.txid().into(), + parent: self.parent, fees, })?; - } else { - if !self.no_backup { - Inscribe::backup_recovery_key(&client, recovery_key_pair, options.chain().network())?; - } - let signed_raw_commit_tx = client - .sign_raw_transaction_with_wallet(&unsigned_commit_tx, None, None)? - .hex; + return Ok(()); + } - let commit = client - .send_raw_transaction(&signed_raw_commit_tx) - .context("Failed to send commit transaction")?; + // if !self.no_backup { + // Inscribe::backup_recovery_key(&client, recovery_key_pair, options.chain().network())?; + // } - let reveal = client - .send_raw_transaction(&reveal_tx) - .context("Failed to send reveal transaction")?; + let signed_raw_commit_tx = client + .sign_raw_transaction_with_wallet(&unsigned_commit_tx, None, None)? + .hex; - print_json(Output { - commit, - reveal, - inscription: reveal.into(), - fees, - })?; + let commit = client + .send_raw_transaction(&signed_raw_commit_tx) + .context("Failed to send commit transaction")?; + + let reveal = if self.parent.is_some() { + let fully_signed_raw_reveal_tx = client + .sign_raw_transaction_with_wallet(&partially_signed_reveal_tx, None, None)? + .hex; + + client + .send_raw_transaction(&fully_signed_raw_reveal_tx) + .context("Failed to send reveal transaction")? + } else { + client + .send_raw_transaction(&partially_signed_reveal_tx) + .context("Failed to send reveal transaction")? + }; + + let inscription = InscriptionId { + txid: reveal, + index: 0, }; + print_json(Output { + commit, + reveal, + inscription, + parent: self.parent, + fees, + })?; + Ok(()) } @@ -146,6 +196,7 @@ impl Inscribe { fn create_inscription_transactions( satpoint: Option, + parent: Option<(SatPoint, TxOut)>, inscription: Inscription, inscriptions: BTreeMap, network: Network, @@ -209,17 +260,43 @@ impl Inscribe { let commit_tx_address = Address::p2tr_tweaked(taproot_spend_info.output_key(), network); + let (mut inputs, mut outputs, commit_input_offset) = + if let Some((parent_satpoint, output)) = parent.clone() { + ( + vec![parent_satpoint.outpoint, OutPoint::null()], + vec![ + TxOut { + script_pubkey: output.script_pubkey, + value: output.value, + }, + TxOut { + script_pubkey: destination.script_pubkey(), + value: 0, + }, + ], + 1, + ) + } else { + ( + vec![OutPoint::null()], + vec![TxOut { + script_pubkey: destination.script_pubkey(), + value: 0, + }], + 0, + ) + }; + let (_, reveal_fee) = Self::build_reveal_transaction( &control_block, reveal_fee_rate, - OutPoint::null(), - TxOut { - script_pubkey: destination.script_pubkey(), - value: 0, - }, + inputs.clone(), + commit_input_offset, + outputs.clone(), &reveal_script, ); + // watch out that parent and inscription preserved let unsigned_commit_tx = TransactionBuilder::build_transaction_with_value( satpoint, inscriptions, @@ -237,50 +314,77 @@ impl Inscribe { .find(|(_vout, output)| output.script_pubkey == commit_tx_address.script_pubkey()) .expect("should find sat commit/inscription output"); + inputs[commit_input_offset] = OutPoint { + txid: unsigned_commit_tx.txid(), + vout: vout.try_into().unwrap(), + }; + + outputs[commit_input_offset] = TxOut { + script_pubkey: destination.script_pubkey(), + value: output.value, + }; + let (mut reveal_tx, fee) = Self::build_reveal_transaction( &control_block, reveal_fee_rate, - OutPoint { - txid: unsigned_commit_tx.txid(), - vout: vout.try_into().unwrap(), - }, - TxOut { - script_pubkey: destination.script_pubkey(), - value: output.value, - }, + inputs, + commit_input_offset, + outputs, &reveal_script, ); - reveal_tx.output[0].value = reveal_tx.output[0] + reveal_tx.output[commit_input_offset].value = reveal_tx.output[commit_input_offset] .value .checked_sub(fee.to_sat()) .context("commit transaction output value insufficient to pay transaction fee")?; - if reveal_tx.output[0].value < reveal_tx.output[0].script_pubkey.dust_value().to_sat() { + if reveal_tx.output[commit_input_offset].value + < reveal_tx.output[commit_input_offset] + .script_pubkey + .dust_value() + .to_sat() + { bail!("commit transaction output would be dust"); } + // NB. This binding is to avoid borrow-checker problems + let prevouts_all_inputs = &[output]; + + let (prevouts, hash_ty) = if parent.is_some() { + ( + Prevouts::One(commit_input_offset, output), + SchnorrSighashType::AllPlusAnyoneCanPay, + ) + } else { + ( + Prevouts::All(prevouts_all_inputs), + SchnorrSighashType::Default, + ) + }; + let mut sighash_cache = SighashCache::new(&mut reveal_tx); - let signature_hash = sighash_cache + let message = sighash_cache .taproot_script_spend_signature_hash( - 0, - &Prevouts::All(&[output]), + commit_input_offset, + &prevouts, TapLeafHash::from_script(&reveal_script, LeafVersion::TapScript), - SchnorrSighashType::Default, + hash_ty, ) .expect("signature hash should compute"); - let signature = secp256k1.sign_schnorr( - &secp256k1::Message::from_slice(signature_hash.as_inner()) + let sig = secp256k1.sign_schnorr( + &secp256k1::Message::from_slice(message.as_inner()) .expect("should be cryptographically secure hash"), &key_pair, ); let witness = sighash_cache - .witness_mut(0) + .witness_mut(commit_input_offset) .expect("getting mutable witness reference should work"); - witness.push(signature.as_ref()); + + witness.push(SchnorrSig { sig, hash_ty }.to_vec()); + witness.push(reveal_script); witness.push(&control_block.serialize()); @@ -306,7 +410,7 @@ impl Inscribe { Ok((unsigned_commit_tx, reveal_tx, recovery_key_pair)) } - fn backup_recovery_key( + fn _backup_recovery_key( client: &Client, recovery_key_pair: TweakedKeyPair, network: Network, @@ -337,18 +441,22 @@ impl Inscribe { fn build_reveal_transaction( control_block: &ControlBlock, fee_rate: FeeRate, - input: OutPoint, - output: TxOut, + inputs: Vec, + commit_input_index: usize, + outputs: Vec, script: &Script, ) -> (Transaction, Amount) { let reveal_tx = Transaction { - input: vec![TxIn { - previous_output: input, - script_sig: script::Builder::new().into_script(), - witness: Witness::new(), - sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, - }], - output: vec![output], + input: inputs + .iter() + .map(|outpoint| TxIn { + previous_output: *outpoint, + script_sig: script::Builder::new().into_script(), + witness: Witness::new(), + sequence: Sequence::ENABLE_RBF_NO_LOCKTIME, + }) + .collect(), + output: outputs, lock_time: PackedLockTime::ZERO, version: 1, }; @@ -356,13 +464,20 @@ impl Inscribe { let fee = { let mut reveal_tx = reveal_tx.clone(); - reveal_tx.input[0].witness.push( - Signature::from_slice(&[0; SCHNORR_SIGNATURE_SIZE]) - .unwrap() - .as_ref(), - ); - reveal_tx.input[0].witness.push(script); - reveal_tx.input[0].witness.push(&control_block.serialize()); + for (current_index, txin) in reveal_tx.input.iter_mut().enumerate() { + // add dummy inscription witness for reveal input/commit output + if current_index == commit_input_index { + txin.witness.push( + Signature::from_slice(&[0; SCHNORR_SIGNATURE_SIZE]) + .unwrap() + .as_ref(), + ); + txin.witness.push(script); + txin.witness.push(&control_block.serialize()); + } else { + txin.witness = Witness::from_vec(vec![vec![0; SCHNORR_SIGNATURE_SIZE]]); + } + } fee_rate.fee(reveal_tx.vsize()) }; @@ -384,6 +499,7 @@ mod tests { let (commit_tx, reveal_tx, _private_key) = Inscribe::create_inscription_transactions( Some(satpoint(1, 0)), + None, inscription, BTreeMap::new(), Network::Bitcoin, @@ -415,6 +531,7 @@ mod tests { let (commit_tx, reveal_tx, _) = Inscribe::create_inscription_transactions( Some(satpoint(1, 0)), + None, inscription, BTreeMap::new(), Network::Bitcoin, @@ -450,6 +567,7 @@ mod tests { let error = Inscribe::create_inscription_transactions( satpoint, + None, inscription, inscriptions, Network::Bitcoin, @@ -492,6 +610,7 @@ mod tests { assert!(Inscribe::create_inscription_transactions( satpoint, + None, inscription, inscriptions, Network::Bitcoin, @@ -528,6 +647,7 @@ mod tests { let (commit_tx, reveal_tx, _private_key) = Inscribe::create_inscription_transactions( satpoint, + None, inscription, inscriptions, bitcoin::Network::Signet, @@ -566,6 +686,76 @@ mod tests { ); } + #[test] + fn inscribe_with_custom_fee_rate_and_parent() { + let utxos = vec![ + (outpoint(1), Amount::from_sat(10_000)), + (outpoint(2), Amount::from_sat(20_000)), + ]; + let mut inscriptions = BTreeMap::new(); + inscriptions.insert( + SatPoint { + outpoint: outpoint(1), + offset: 0, + }, + inscription_id(1), + ); + + let inscription = inscription("text/plain", [b'O'; 100]); + + let satpoint = None; + let commit_address = change(1); + let reveal_address = recipient(); + let fee_rate = 4.0; + + let (commit_tx, reveal_tx, _private_key) = Inscribe::create_inscription_transactions( + satpoint, + Some(( + SatPoint { + outpoint: outpoint(1), + offset: 0, + }, + TxOut { + script_pubkey: change(0).script_pubkey(), + value: 10000, + }, + )), + inscription, + inscriptions, + bitcoin::Network::Signet, + utxos.into_iter().collect(), + [commit_address, change(2)], + reveal_address, + FeeRate::try_from(fee_rate).unwrap(), + FeeRate::try_from(fee_rate).unwrap(), + false, + ) + .unwrap(); + + let sig_vbytes = 17; + let fee = FeeRate::try_from(fee_rate) + .unwrap() + .fee(commit_tx.vsize() + sig_vbytes) + .to_sat(); + + let reveal_value = commit_tx + .output + .iter() + .map(|o| o.value) + .reduce(|acc, i| acc + i) + .unwrap(); + + assert_eq!(reveal_value, 20_000 - fee); + + let sig_vbytes = 16; + let fee = FeeRate::try_from(fee_rate) + .unwrap() + .fee(reveal_tx.vsize() + sig_vbytes) + .to_sat(); + + assert_eq!(fee, commit_tx.output[0].value - reveal_tx.output[1].value,); + } + #[test] fn inscribe_with_commit_fee_rate() { let utxos = vec![ @@ -590,6 +780,7 @@ mod tests { let (commit_tx, reveal_tx, _private_key) = Inscribe::create_inscription_transactions( satpoint, + None, inscription, inscriptions, bitcoin::Network::Signet, @@ -639,6 +830,7 @@ mod tests { let error = Inscribe::create_inscription_transactions( satpoint, + None, inscription, BTreeMap::new(), Network::Bitcoin, @@ -670,6 +862,7 @@ mod tests { let (_commit_tx, reveal_tx, _private_key) = Inscribe::create_inscription_transactions( satpoint, + None, inscription, BTreeMap::new(), Network::Bitcoin, diff --git a/src/subcommand/wallet/transaction_builder.rs b/src/subcommand/wallet/transaction_builder.rs index aed340be39..11f73d4f65 100644 --- a/src/subcommand/wallet/transaction_builder.rs +++ b/src/subcommand/wallet/transaction_builder.rs @@ -427,7 +427,6 @@ impl TransactionBuilder { version: 1, lock_time: PackedLockTime::ZERO, input: (0..inputs) - .into_iter() .map(|_| TxIn { previous_output: OutPoint::null(), script_sig: Script::new(), diff --git a/src/templates.rs b/src/templates.rs index f4344309e6..c2915745ce 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -7,6 +7,7 @@ pub(crate) use { iframe::Iframe, input::InputHtml, inscription::InscriptionHtml, + inscription_children::InscriptionChildrenHtml, inscriptions::InscriptionsHtml, output::OutputHtml, page_config::PageConfig, @@ -26,6 +27,7 @@ mod home; mod iframe; mod input; mod inscription; +mod inscription_children; mod inscriptions; mod output; mod preview; @@ -137,7 +139,7 @@ mod tests { rare.txt
- +
diff --git a/src/templates/inscription.rs b/src/templates/inscription.rs index 0f396903fe..e6de3cca1f 100644 --- a/src/templates/inscription.rs +++ b/src/templates/inscription.rs @@ -3,6 +3,7 @@ use super::*; #[derive(Boilerplate)] pub(crate) struct InscriptionHtml { pub(crate) chain: Chain, + pub(crate) children: Vec, pub(crate) genesis_fee: u64, pub(crate) genesis_height: u64, pub(crate) inscription: Inscription, @@ -10,6 +11,7 @@ pub(crate) struct InscriptionHtml { pub(crate) next: Option, pub(crate) number: u64, pub(crate) output: TxOut, + pub(crate) parent: Option, pub(crate) previous: Option, pub(crate) sat: Option, pub(crate) satpoint: SatPoint, @@ -35,6 +37,7 @@ mod tests { assert_regex_match!( InscriptionHtml { chain: Chain::Mainnet, + children: vec![], genesis_fee: 1, genesis_height: 0, inscription: inscription("text/plain;charset=utf-8", "HELLOWORLD"), @@ -42,6 +45,7 @@ mod tests { next: None, number: 1, output: tx_out(1, address()), + parent: None, previous: None, sat: None, satpoint: satpoint(1, 0), @@ -94,6 +98,7 @@ mod tests { assert_regex_match!( InscriptionHtml { chain: Chain::Mainnet, + children: vec![], genesis_fee: 1, genesis_height: 0, inscription: inscription("text/plain;charset=utf-8", "HELLOWORLD"), @@ -102,6 +107,7 @@ mod tests { number: 1, output: tx_out(1, address()), previous: None, + parent: None, sat: Some(Sat(1)), satpoint: satpoint(1, 0), timestamp: timestamp(0), @@ -126,6 +132,7 @@ mod tests { assert_regex_match!( InscriptionHtml { chain: Chain::Mainnet, + children: vec![], genesis_fee: 1, genesis_height: 0, inscription: inscription("text/plain;charset=utf-8", "HELLOWORLD"), @@ -133,6 +140,7 @@ mod tests { next: Some(inscription_id(3)), number: 1, output: tx_out(1, address()), + parent: None, previous: Some(inscription_id(1)), sat: None, satpoint: satpoint(1, 0), @@ -150,4 +158,38 @@ mod tests { .unindent() ); } + + #[test] + fn with_children() { + assert_regex_match!( + InscriptionHtml { + chain: Chain::Mainnet, + children: vec![inscription_id(5), inscription_id(6)], + genesis_fee: 1, + genesis_height: 0, + inscription: inscription("text/plain;charset=utf-8", "HELLOWORLD"), + inscription_id: inscription_id(2), + next: Some(inscription_id(3)), + number: 1, + output: tx_out(1, address()), + parent: None, + previous: Some(inscription_id(1)), + sat: None, + satpoint: satpoint(1, 0), + timestamp: timestamp(0), + }, + " +

Inscription 1

+ .* +
+ .* +
children
+
5{64}i5
+
6{64}i6
+ .* +
+ " + .unindent() + ); + } } diff --git a/src/templates/inscription_children.rs b/src/templates/inscription_children.rs new file mode 100644 index 0000000000..c4d14fda5a --- /dev/null +++ b/src/templates/inscription_children.rs @@ -0,0 +1,47 @@ +use super::*; + +#[derive(Boilerplate)] +pub(crate) struct InscriptionChildrenHtml { + pub(crate) parent_id: InscriptionId, + pub(crate) parent_number: u64, + pub(crate) children: Vec, +} + +impl PageContent for InscriptionChildrenHtml { + fn title(&self) -> String { + format!("Children of parent inscription {}", self.parent_number) + } + + fn preview_image_url(&self) -> Option> { + Some(Trusted(format!("/content/{}", self.parent_id))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn without_sat_or_nav_links() { + assert_regex_match!( + InscriptionChildrenHtml { + parent_id: inscription_id(0), + parent_number: 0, + children: vec![inscription_id(1), inscription_id(2), inscription_id(3)], + }, + " +

Parent inscription 0

+
+ +
+

Children

+
+ + + +
+ " + .unindent() + ); + } +} diff --git a/src/test.rs b/src/test.rs index 27a8d45f83..59df4a06fb 100644 --- a/src/test.rs +++ b/src/test.rs @@ -101,7 +101,19 @@ pub(crate) fn tx_out(value: u64, address: Address) -> TxOut { } pub(crate) fn inscription(content_type: &str, body: impl AsRef<[u8]>) -> Inscription { - Inscription::new(Some(content_type.into()), Some(body.as_ref().into())) + Inscription::new(None, Some(content_type.into()), Some(body.as_ref().into())) +} + +pub(crate) fn inscription_with_parent( + content_type: &str, + body: impl AsRef<[u8]>, + parent: InscriptionId, +) -> Inscription { + Inscription::new( + Some(parent), + Some(content_type.into()), + Some(body.as_ref().into()), + ) } pub(crate) fn inscription_id(n: u32) -> InscriptionId { diff --git a/static/index.css b/static/index.css index a8faeb6616..37e83b7875 100644 --- a/static/index.css +++ b/static/index.css @@ -4,8 +4,8 @@ --dark-fg: #98a3ad; --epic: darkorchid; --legendary: gold; - --light-bg: #292c2f; - --light-fg: #a1adb8; + --light-bg: #000000; + --light-fg: #ffffff; --link: #4169e1; --mythic: #f2a900; --rare: cornflowerblue; @@ -48,10 +48,6 @@ a:hover { text-decoration: underline; } -a:visited { - color: var(--link); -} - dt { font-weight: bold; margin-top: 0.5rem; @@ -70,6 +66,15 @@ nav > :first-child { font-weight: bold; } +nav a:hover { + color: var(--link); + text-decoration: none; +} + +nav a { + color: var(--light-fg); +} + form { display: flex; flex-grow: 1; @@ -87,6 +92,17 @@ input[type=text] { min-width: 0; } +input[type=submit] { + background: none; + border: none; + color: var(--light-fg); + cursor: pointer; + font-weight: bold; + font: inherit; + outline: inherit; + padding: 0; +} + dl { overflow-wrap: break-word; } @@ -188,6 +204,17 @@ a.mythic { width: 100%; } +.parent { + justify-content: center; +} + +.parent > a { + border-radius: 2%; + margin: 1%; + overflow: hidden; + width: 48%; +} + .inscription { display: flex; justify-content: center; diff --git a/templates/inscription-children.html b/templates/inscription-children.html new file mode 100644 index 0000000000..03b42ca82a --- /dev/null +++ b/templates/inscription-children.html @@ -0,0 +1,10 @@ +

Parent inscription {{ self.parent_number }}

+
+ {{Iframe::thumbnail(self.parent_id)}} +
+

Children

+
+%% for id in &self.children { + {{Iframe::thumbnail(*id)}} +%% } +
diff --git a/templates/inscription.html b/templates/inscription.html index 37573f3706..7a11f44cf7 100644 --- a/templates/inscription.html +++ b/templates/inscription.html @@ -15,6 +15,20 @@

Inscription {{ self.number }}

id
{{ self.inscription_id }}
+%% if let Some(parent) = self.parent { +
parent
+
{{parent}}
+%% } +%% if !self.children.is_empty() { +
children
+
+
+%% for (i, child) in self.children.iter().enumerate() { + {{ Iframe::thumbnail(*child) }} +%% } +
+
+%% } %% if let Ok(address) = self.chain.address_from_script(&self.output.script_pubkey ) {
address
{{ address }}
diff --git a/templates/page.html b/templates/page.html index 8e5a25ed1e..6d1faccff3 100644 --- a/templates/page.html +++ b/templates/page.html @@ -25,7 +25,7 @@ %% }
- +
diff --git a/test-bitcoincore-rpc/src/lib.rs b/test-bitcoincore-rpc/src/lib.rs index 7051d283d6..d7db02d5a0 100644 --- a/test-bitcoincore-rpc/src/lib.rs +++ b/test-bitcoincore-rpc/src/lib.rs @@ -118,7 +118,7 @@ pub struct TransactionTemplate<'a> { pub inputs: &'a [(usize, usize, usize)], pub output_values: &'a [u64], pub outputs: usize, - pub witness: Witness, + pub witnesses: Vec, } #[derive(Clone, Debug, PartialEq)] @@ -150,7 +150,7 @@ impl<'a> Default for TransactionTemplate<'a> { inputs: &[], output_values: &[], outputs: 1, - witness: Witness::default(), + witnesses: vec![], } } } diff --git a/test-bitcoincore-rpc/src/server.rs b/test-bitcoincore-rpc/src/server.rs index 0ea66122dd..26d842d460 100644 --- a/test-bitcoincore-rpc/src/server.rs +++ b/test-bitcoincore-rpc/src/server.rs @@ -249,7 +249,9 @@ impl Api for Server { let mut transaction = Transaction::deserialize(&hex::decode(tx).unwrap()).unwrap(); for input in &mut transaction.input { - input.witness = Witness::from_vec(vec![vec![0; 64]]); + if input.witness.is_empty() { + input.witness = Witness::from_vec(vec![vec![0; 64]]); + } } Ok( diff --git a/test-bitcoincore-rpc/src/state.rs b/test-bitcoincore-rpc/src/state.rs index 80f887c003..08ea2a09c4 100644 --- a/test-bitcoincore-rpc/src/state.rs +++ b/test-bitcoincore-rpc/src/state.rs @@ -138,11 +138,10 @@ impl State { previous_output: OutPoint::new(tx.txid(), *vout as u32), script_sig: Script::new(), sequence: Sequence::MAX, - witness: if i == 0 { - template.witness.clone() - } else { - Witness::new() - }, + witness: template + .witnesses + .get(i) + .map_or(Witness::new(), |i| i.clone()), }); } diff --git a/tests/core.rs b/tests/core.rs index 8e4c951d38..526def9941 100644 --- a/tests/core.rs +++ b/tests/core.rs @@ -1,4 +1,10 @@ -use super::*; +use bitcoin::Address; + +use { + super::*, + bitcoincore_rpc::{Client, RpcApi}, + std::ffi::OsString, +}; struct KillOnDrop(std::process::Child); @@ -70,3 +76,229 @@ fn preview() { format!(".*( u16 { + TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() +} + +fn ord( + cookiefile: &std::path::Path, + ord_data_dir: &std::path::Path, + rpc_port: u16, + args: &[&str], +) -> Result { + let mut ord = Command::new(executable_path("ord")); + + ord + .env("ORD_INTEGRATION_TEST", "1") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .current_dir(ord_data_dir) + .arg("--regtest") + .arg("--data-dir") + .arg(ord_data_dir) + .arg("--rpc-url") + .arg(&format!("127.0.0.1:{}", rpc_port)) + .arg("--cookie-file") + .arg(cookiefile.to_str().unwrap()) + .args(args); + + let output = ord.output().unwrap(); + + if output.status.success() { + Ok(String::from(str::from_utf8(&output.stdout).unwrap())) + } else { + Err(String::from(str::from_utf8(&output.stderr).unwrap())) + } +} + +#[test] +#[ignore] +fn inscribe_child() { + let rpc_port = get_free_port(); + + let tmp_dir_1 = TempDir::new().unwrap(); + let bitcoin_data_dir = tmp_dir_1.path().join("bitcoin"); + fs::create_dir(&bitcoin_data_dir).unwrap(); + + let tmp_dir_2 = TempDir::new().unwrap(); + let ord_data_dir = tmp_dir_2.path().join("ord"); + fs::create_dir(&ord_data_dir).unwrap(); + + let _bitcoind = KillOnDrop( + Command::new("bitcoind") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .arg({ + let mut arg = OsString::from("-datadir="); + arg.push(&bitcoin_data_dir); + arg + }) + .arg("-regtest") + .arg("-txindex") + .arg("-listen=0") + .arg("-minrelaytxfee=0") + .arg(format!("-rpcport={rpc_port}")) + .spawn() + .expect("failed to spawn `bitcoind`"), + ); + + let cookiefile = bitcoin_data_dir.as_path().join("regtest/.cookie"); + + for attempt in 0.. { + if Client::new( + &format!("127.0.0.1:{rpc_port}"), + bitcoincore_rpc::Auth::CookieFile(cookiefile.clone()), + ) + .is_ok() + { + break; + } + + if attempt == 500 { + panic!("Bitcoin Core RPC did not respond"); + } + + thread::sleep(Duration::from_millis(50)); + } + + let _ = ord(&cookiefile, &ord_data_dir, rpc_port, &["wallet", "create"]); + + let rpc_client = Client::new( + &format!("127.0.0.1:{rpc_port}/wallet/ord"), + bitcoincore_rpc::Auth::CookieFile(cookiefile.clone()), + ) + .unwrap(); + + let address = rpc_client + .get_new_address(None, Some(bitcoincore_rpc::json::AddressType::Bech32m)) + .unwrap(); + + let not_ours = Address::from_str("bcrt1qyr2zc4lhadk9k35hwfh2unn7hgvtpwpx8mjx4h").unwrap(); + + rpc_client.generate_to_address(1, &address).unwrap(); + rpc_client.generate_to_address(100, ¬_ours).unwrap(); // need to mine 100 blocks for coins to become spendable. use address outside our wallet to prevent slow rescan + + fs::write(ord_data_dir.as_path().join("parent.txt"), "Pater").unwrap(); + + #[derive(Deserialize, Debug)] + #[allow(dead_code)] // required because of the `serde` macro, can't use _ + struct Output { + commit: String, + inscription: String, + parent: Option, + reveal: String, + fees: u64, + } + + let output: Output = match ord( + &cookiefile, + &ord_data_dir, + rpc_port, + &["wallet", "inscribe", "--fee-rate", "1.0", "parent.txt"], + ) { + Ok(s) => serde_json::from_str(&s) + .unwrap_or_else(|err| panic!("Failed to deserialize JSON: {err}\n{s}")), + Err(e) => panic!("error inscribing parent: {}", e), + }; + let parent_id = output.inscription; + + rpc_client.generate_to_address(1, &address).unwrap(); + thread::sleep(Duration::from_secs(1)); + + fs::write(ord_data_dir.as_path().join("child.txt"), "Filius").unwrap(); + let output: Output = match ord( + &cookiefile, + &ord_data_dir, + rpc_port, + &[ + "wallet", + "inscribe", + "--fee-rate", + "2", + "--parent", + &parent_id, + "child.txt", + ], + ) { + Ok(s) => serde_json::from_str(&s) + .unwrap_or_else(|err| panic!("Failed to deserialize JSON: {err}\n{s}")), + Err(e) => panic!("error inscribing child with parent: {}", e), + }; + + let child_id = output.inscription; + + rpc_client.generate_to_address(1, &address).unwrap(); + + let ord_port = 8080; + let _ord_server = KillOnDrop( + Command::new(executable_path("ord")) + .env("ORD_INTEGRATION_TEST", "1") + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .current_dir(ord_data_dir.clone()) + .arg("--regtest") + .arg("--data-dir") + .arg(ord_data_dir.as_path()) + .arg("--rpc-url") + .arg(&format!("127.0.0.1:{}", rpc_port)) + .arg("--cookie-file") + .arg(cookiefile.to_str().unwrap()) + .arg("server") + .arg("--http-port") + .arg(&format!("{ord_port}")) + .spawn() + .expect("failed to spawn `ord server`"), + ); + + let client = reqwest::blocking::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap(); + + for i in 0.. { + match client + .get(format!("http://127.0.0.1:{ord_port}/status")) + .send() + { + Ok(_) => break, + Err(err) => { + if i == 400 { + panic!("server failed to start: {err}"); + } + } + } + + thread::sleep(Duration::from_millis(25)); + } + + let response = client + .get(format!( + "http://127.0.0.1:{ord_port}/inscription/{parent_id}" + )) + .send() + .unwrap(); + + assert_regex_match!(response.text().unwrap(), &format!(".*id.*{}.*", parent_id)); + + thread::sleep(Duration::from_secs(10)); + + let response = client + .get(format!( + "http://127.0.0.1:{ord_port}/inscription/{child_id}" + )) + .send() + .unwrap(); + + assert_regex_match!( + response.text().unwrap(), + &format!(".*parent.*{}.*", parent_id) + ); +} diff --git a/tests/lib.rs b/tests/lib.rs index 710e6b2a88..086567f821 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -43,12 +43,13 @@ struct Inscribe { inscription: String, reveal: Txid, fees: u64, + parent: Option, } fn inscribe(rpc_server: &test_bitcoincore_rpc::Handle) -> Inscribe { rpc_server.mine_blocks(1); - let output = CommandBuilder::new("wallet inscribe foo.txt") + let output = CommandBuilder::new("wallet inscribe --fee-rate 1 foo.txt") .write("foo.txt", "FOO") .rpc_server(rpc_server) .output(); diff --git a/tests/wallet.rs b/tests/wallet.rs index 31dc1d96a0..3e04064183 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -1,6 +1,7 @@ use super::*; mod balance; +mod cardinals; mod create; mod inscribe; mod inscriptions; diff --git a/tests/wallet/cardinals.rs b/tests/wallet/cardinals.rs new file mode 100644 index 0000000000..19053f4389 --- /dev/null +++ b/tests/wallet/cardinals.rs @@ -0,0 +1,23 @@ +use { + super::*, + ord::subcommand::wallet::{cardinals::Cardinal, outputs::Output}, +}; + +#[test] +fn cardinals() { + let rpc_server = test_bitcoincore_rpc::spawn(); + create_wallet(&rpc_server); + + // this creates 2 more cardinal outputs and one inscribed output + inscribe(&rpc_server); + + let all_outputs = CommandBuilder::new("wallet outputs") + .rpc_server(&rpc_server) + .output::>(); + + let cardinal_outputs = CommandBuilder::new("wallet cardinals") + .rpc_server(&rpc_server) + .output::>(); + + assert_eq!(all_outputs.len() - cardinal_outputs.len(), 1); +} diff --git a/tests/wallet/inscribe.rs b/tests/wallet/inscribe.rs index 16b486037e..505f6d83ff 100644 --- a/tests/wallet/inscribe.rs +++ b/tests/wallet/inscribe.rs @@ -11,7 +11,8 @@ fn inscribe_creates_inscriptions() { let Inscribe { inscription, .. } = inscribe(&rpc_server); - assert_eq!(rpc_server.descriptors().len(), 3); + // no backup + assert_eq!(rpc_server.descriptors().len(), 2); let request = TestServer::spawn_with_args(&rpc_server, &[]).request(format!("/content/{inscription}")); @@ -42,7 +43,7 @@ fn inscribe_works_with_huge_expensive_inscriptions() { fn inscribe_fails_if_bitcoin_core_is_too_old() { let rpc_server = test_bitcoincore_rpc::builder().version(230000).build(); - CommandBuilder::new("wallet inscribe hello.txt") + CommandBuilder::new("wallet inscribe hello.txt --fee-rate 1") .write("hello.txt", "HELLOWORLD") .expected_exit_code(1) .expected_stderr("error: Bitcoin Core 24.0.0 or newer required, current version is 23.0.0\n") @@ -58,7 +59,7 @@ fn inscribe_no_backup() { create_wallet(&rpc_server); assert_eq!(rpc_server.descriptors().len(), 2); - CommandBuilder::new("wallet inscribe hello.txt --no-backup") + CommandBuilder::new("wallet inscribe hello.txt --no-backup --fee-rate 1") .write("hello.txt", "HELLOWORLD") .rpc_server(&rpc_server) .output::(); @@ -72,7 +73,7 @@ fn inscribe_unknown_file_extension() { create_wallet(&rpc_server); rpc_server.mine_blocks(1); - CommandBuilder::new("wallet inscribe pepe.xyz") + CommandBuilder::new("wallet inscribe pepe.xyz --fee-rate 1") .write("pepe.xyz", [1; 520]) .rpc_server(&rpc_server) .expected_exit_code(1) @@ -88,7 +89,7 @@ fn inscribe_exceeds_chain_limit() { create_wallet(&rpc_server); rpc_server.mine_blocks(1); - CommandBuilder::new("--chain signet wallet inscribe degenerate.png") + CommandBuilder::new("--chain signet wallet inscribe degenerate.png --fee-rate 1") .write("degenerate.png", [1; 1025]) .rpc_server(&rpc_server) .expected_exit_code(1) @@ -106,7 +107,7 @@ fn regtest_has_no_content_size_limit() { create_wallet(&rpc_server); rpc_server.mine_blocks(1); - CommandBuilder::new("--chain regtest wallet inscribe degenerate.png") + CommandBuilder::new("--chain regtest wallet inscribe degenerate.png --fee-rate 1") .write("degenerate.png", [1; 1025]) .rpc_server(&rpc_server) .stdout_regex(".*") @@ -121,7 +122,7 @@ fn mainnet_has_no_content_size_limit() { create_wallet(&rpc_server); rpc_server.mine_blocks(1); - CommandBuilder::new("wallet inscribe degenerate.png") + CommandBuilder::new("wallet inscribe degenerate.png --fee-rate 1") .write("degenerate.png", [1; 1025]) .rpc_server(&rpc_server) .stdout_regex(".*") @@ -136,7 +137,7 @@ fn inscribe_does_not_use_inscribed_sats_as_cardinal_utxos() { rpc_server.mine_blocks_with_subsidy(1, 100); CommandBuilder::new( - "wallet inscribe degenerate.png" + "wallet inscribe degenerate.png --fee-rate 1" ) .rpc_server(&rpc_server) .write("degenerate.png", [1; 100]) @@ -156,12 +157,14 @@ fn refuse_to_reinscribe_sats() { rpc_server.mine_blocks_with_subsidy(1, 100); - CommandBuilder::new(format!("wallet inscribe --satpoint {reveal}:0:0 hello.txt")) - .write("hello.txt", "HELLOWORLD") - .rpc_server(&rpc_server) - .expected_exit_code(1) - .expected_stderr(format!("error: sat at {reveal}:0:0 already inscribed\n")) - .run(); + CommandBuilder::new(format!( + "wallet inscribe --satpoint {reveal}:0:0 hello.txt --fee-rate 1" + )) + .write("hello.txt", "HELLOWORLD") + .rpc_server(&rpc_server) + .expected_exit_code(1) + .expected_stderr(format!("error: sat at {reveal}:0:0 already inscribed\n")) + .run(); } #[test] @@ -181,7 +184,7 @@ fn refuse_to_inscribe_already_inscribed_utxo() { }; CommandBuilder::new(format!( - "wallet inscribe --satpoint {output}:55555 hello.txt" + "wallet inscribe --satpoint {output}:55555 hello.txt --fee-rate 1" )) .write("hello.txt", "HELLOWORLD") .rpc_server(&rpc_server) @@ -198,11 +201,12 @@ fn inscribe_with_optional_satpoint_arg() { create_wallet(&rpc_server); let txid = rpc_server.mine_blocks(1)[0].txdata[0].txid(); - let Inscribe { inscription, .. } = - CommandBuilder::new(format!("wallet inscribe foo.txt --satpoint {txid}:0:0")) - .write("foo.txt", "FOO") - .rpc_server(&rpc_server) - .output(); + let Inscribe { inscription, .. } = CommandBuilder::new(format!( + "wallet inscribe foo.txt --satpoint {txid}:0:0 --fee-rate 1" + )) + .write("foo.txt", "FOO") + .rpc_server(&rpc_server) + .output(); rpc_server.mine_blocks(1); @@ -262,10 +266,12 @@ fn inscribe_with_commit_fee_rate() { create_wallet(&rpc_server); rpc_server.mine_blocks(1); - CommandBuilder::new("--index-sats wallet inscribe degenerate.png --commit-fee-rate 2.0") - .write("degenerate.png", [1; 520]) - .rpc_server(&rpc_server) - .output::(); + CommandBuilder::new( + "--index-sats wallet inscribe degenerate.png --commit-fee-rate 2.0 --fee-rate 1", + ) + .write("degenerate.png", [1; 520]) + .rpc_server(&rpc_server) + .output::(); let tx1 = &rpc_server.mempool()[0]; let mut fee = 0; @@ -307,7 +313,7 @@ fn inscribe_with_wallet_named_foo() { rpc_server.mine_blocks(1); - CommandBuilder::new("--wallet foo wallet inscribe degenerate.png") + CommandBuilder::new("--wallet foo wallet inscribe degenerate.png --fee-rate 1") .write("degenerate.png", [1; 520]) .rpc_server(&rpc_server) .output::(); @@ -319,14 +325,14 @@ fn inscribe_with_dry_run_flag() { create_wallet(&rpc_server); rpc_server.mine_blocks(1); - CommandBuilder::new("wallet inscribe --dry-run degenerate.png") + CommandBuilder::new("wallet inscribe --dry-run degenerate.png --fee-rate 1") .write("degenerate.png", [1; 520]) .rpc_server(&rpc_server) .output::(); assert!(rpc_server.mempool().is_empty()); - CommandBuilder::new("wallet inscribe degenerate.png") + CommandBuilder::new("wallet inscribe degenerate.png --fee-rate 1") .write("degenerate.png", [1; 520]) .rpc_server(&rpc_server) .output::(); @@ -335,16 +341,17 @@ fn inscribe_with_dry_run_flag() { } #[test] -fn inscribe_with_dry_run_flag_fees_inscrease() { +fn inscribe_with_dry_run_flag_fees_increase() { let rpc_server = test_bitcoincore_rpc::spawn(); create_wallet(&rpc_server); rpc_server.mine_blocks(1); - let total_fee_dry_run = CommandBuilder::new("wallet inscribe --dry-run degenerate.png") - .write("degenerate.png", [1; 520]) - .rpc_server(&rpc_server) - .output::() - .fees; + let total_fee_dry_run = + CommandBuilder::new("wallet inscribe --dry-run degenerate.png --fee-rate 1") + .write("degenerate.png", [1; 520]) + .rpc_server(&rpc_server) + .output::() + .fees; let total_fee_normal = CommandBuilder::new("wallet inscribe --dry-run degenerate.png --fee-rate 1.1") @@ -368,7 +375,7 @@ fn inscribe_to_specific_destination() { .address; let txid = CommandBuilder::new(format!( - "wallet inscribe --destination {destination} degenerate.png" + "wallet inscribe --destination {destination} degenerate.png --fee-rate 1" )) .write("degenerate.png", [1; 520]) .rpc_server(&rpc_server) @@ -390,7 +397,106 @@ fn inscribe_with_no_limit() { rpc_server.mine_blocks(1); let four_megger = std::iter::repeat(0).take(4_000_000).collect::>(); - CommandBuilder::new("wallet inscribe --no-limit degenerate.png") + CommandBuilder::new("wallet inscribe --no-limit degenerate.png --fee-rate 1") .write("degenerate.png", four_megger) .rpc_server(&rpc_server); } + +#[test] +fn inscribe_with_parent_inscription() { + let rpc_server = test_bitcoincore_rpc::spawn(); + create_wallet(&rpc_server); + rpc_server.mine_blocks(1); + + let parent_id = CommandBuilder::new("wallet inscribe --fee-rate 1.0 parent.png") + .write("parent.png", [1; 520]) + .rpc_server(&rpc_server) + .output::() + .inscription; + + rpc_server.mine_blocks(1); + + TestServer::spawn_with_args(&rpc_server, &[]) + .assert_response_regex(format!("/inscription/{parent_id}"), ".*"); + + let child_output = CommandBuilder::new(format!( + "wallet inscribe --fee-rate 1.0 --parent {parent_id} child.png" + )) + .write("child.png", [1; 520]) + .rpc_server(&rpc_server) + .output::(); + + assert_eq!(parent_id, child_output.parent.unwrap()); + + rpc_server.mine_blocks(1); + + TestServer::spawn_with_args(&rpc_server, &[]).assert_response_regex( + format!("/inscription/{}", child_output.inscription), + format!(".*parent.*{}.*", parent_id), + ); +} + +#[test] +fn inscribe_with_non_existent_parent_inscription() { + let rpc_server = test_bitcoincore_rpc::spawn(); + create_wallet(&rpc_server); + rpc_server.mine_blocks(1); + + let parent_id = "3ac40a8f3c0d295386e1e597467a1ee0578df780834be885cd62337c2ed738a5i0"; + + CommandBuilder::new(format!( + "wallet inscribe --fee-rate 1.0 --parent {parent_id} child.png" + )) + .write("child.png", [1; 520]) + .rpc_server(&rpc_server) + .expected_stderr(format!( + "error: specified parent {parent_id} does not exist\n" + )) + .expected_exit_code(1) + .run(); +} + +#[test] +fn inscribe_with_parent_inscription_and_fee_rate() { + let rpc_server = test_bitcoincore_rpc::spawn(); + create_wallet(&rpc_server); + rpc_server.mine_blocks(1); + + let parent_output = CommandBuilder::new("wallet inscribe --fee-rate 5.0 parent.png") + .write("parent.png", [1; 520]) + .rpc_server(&rpc_server) + .output::(); + + let parent_id = parent_output.inscription; + + let commit_tx = &rpc_server.mempool()[0]; + let reveal_tx = &rpc_server.mempool()[1]; + assert_eq!( + ord::FeeRate::try_from(5.0) + .unwrap() + .fee(commit_tx.vsize() + reveal_tx.vsize()) + .to_sat(), + parent_output.fees + ); + + rpc_server.mine_blocks(1); + + let child_output = CommandBuilder::new(format!( + "wallet inscribe --fee-rate 5.0 --parent {parent_id} child.png" + )) + .write("child.png", [1; 520]) + .rpc_server(&rpc_server) + .output::(); + + assert_eq!(parent_id, child_output.parent.unwrap()); + + let commit_tx = &rpc_server.mempool()[0]; + let reveal_tx = &rpc_server.mempool()[1]; + assert_eq!( + ord::FeeRate::try_from(5.0) + .unwrap() + .fee(commit_tx.vsize() + reveal_tx.vsize()) + .to_sat(), + child_output.fees + ); +}