diff --git a/Cargo.lock b/Cargo.lock index 50cf4603db..1cb7a25c28 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -517,6 +517,7 @@ version = "0.29.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0694ea59225b0c5f3cb405ff3f670e4828358ed26aec49dc352f730f0cb1a8a3" dependencies = [ + "base64 0.13.1", "bech32", "bitcoin_hashes 0.11.0", "secp256k1", diff --git a/Cargo.toml b/Cargo.toml index 3064f5ed53..12763f54f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ axum-server = "0.4.0" base64 = "0.13.1" bech32 = "0.9.1" bip39 = "1.0.1" -bitcoin = { version = "0.29.1", features = ["rand"] } +bitcoin = { version = "0.29.1", features = ["rand", "base64"] } boilerplate = { version = "0.2.3", features = ["axum"] } chrono = "0.4.19" clap = { version = "3.1.0", features = ["derive"] } @@ -78,3 +78,6 @@ path = "tests/lib.rs" [build-dependencies] pulldown-cmark = "0.9.2" + +# [patch.crates-io] +# ord-bitcoincore-rpc = { path = "../ord-rust-bitcoincore-rpc"} diff --git a/src/index.rs b/src/index.rs index e4c02d853c..6674ce41fe 100644 --- a/src/index.rs +++ b/src/index.rs @@ -18,12 +18,12 @@ use { std::sync::atomic::{self, AtomicBool}, }; -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) => { @@ -552,6 +552,7 @@ impl Index { Ok( self .get_transaction(inscription_id.txid)? + // .and_then(|tx| Inscription::from_tx_input(tx.input.get(inscription_id.index as usize)?)), .and_then(|tx| Inscription::from_transaction(&tx)), ) } @@ -568,7 +569,6 @@ impl Index { .open_table(SATPOINT_TO_INSCRIPTION_ID)?, outpoint, )? - .into_iter() .map(|(_satpoint, inscription_id)| inscription_id) .collect(), ) @@ -1032,7 +1032,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 +1378,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 +1403,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 +1447,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 +1455,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 +1502,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 +1551,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 +1595,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 +1632,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 +1661,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 +1687,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 +1698,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 +1812,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 +1844,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 +1870,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 +1979,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 +2038,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 +2071,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 +2110,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 +2137,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 +2190,76 @@ 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 + }) + ); + } } diff --git a/src/index/entry.rs b/src/index/entry.rs index 15ff3d8ecb..5a971c1af7 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)) + } + } + // TODO: test head and tail byte order + 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..9ce9292b95 100644 --- a/src/index/updater.rs +++ b/src/index/updater.rs @@ -65,6 +65,7 @@ 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, @@ -523,6 +524,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..6b232d8b88 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), } @@ -73,50 +76,99 @@ 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, - inscription_id, - origin: Origin::Old(old_satpoint), + 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 parent = if let Some(parent_id) = inscription.get_parent_id() { + // parent has to be in an input before child + // think about specifying a more general approach in a protocol doc/BIP + if floating_inscriptions + .iter() + .any(|flotsam| flotsam.inscription_id == parent_id) + { + Some(parent_id) + } else { + None + } + } else { + None + }; + + floating_inscriptions.push(Flotsam { + inscription_id: InscriptionId { + txid, + index: 0, + }, + 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::()), - }); - }; + // TODO: inefficient + // 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 +177,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,6 +202,7 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { self.update_inscription_location( input_sat_ranges, + // TODO: do something with two inscriptions in the input inscriptions.next().unwrap(), new_satpoint, )?; @@ -198,7 +251,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)?; @@ -224,6 +277,7 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { fee, height: self.height, number: self.next_number, + parent, sat, timestamp: self.timestamp, } diff --git a/src/inscription.rs b/src/inscription.rs index d0fba77016..5169194db8 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,42 @@ 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() } - 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 +66,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 +78,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 +130,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 +250,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 +260,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 +392,7 @@ mod tests { b"ord", &[1], b"text/plain;charset=utf-8", - &[3], + &[5], b"bar", &[], b"ord", @@ -372,6 +406,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 +418,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()), }), @@ -705,6 +741,7 @@ mod tests { witness.push( &Inscription { + parent: None, content_type: None, body: None, } @@ -716,6 +753,7 @@ mod tests { assert_eq!( InscriptionParser::parse(&witness).unwrap(), Inscription { + parent: None, content_type: None, body: None, } @@ -725,8 +763,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..e5aabc6219 100644 --- a/src/subcommand/server.rs +++ b/src/subcommand/server.rs @@ -768,7 +768,7 @@ impl Server { .get_inscription_by_id(inscription_id)? .ok_or_not_found(|| format!("inscription {inscription_id}"))?; - return match inscription.media() { + match inscription.media() { Media::Audio => Ok(PreviewAudioHtml { inscription_id }.into_response()), Media::Iframe => Ok( Self::content_response(inscription) @@ -809,7 +809,7 @@ impl Server { } Media::Unknown => Ok(PreviewUnknownHtml.into_response()), Media::Video => Ok(PreviewVideoHtml { inscription_id }.into_response()), - }; + } } async fn inscription( @@ -859,6 +859,7 @@ impl Server { next, number: entry.number, output, + parent: dbg!(entry.parent), previous, sat: entry.sat, satpoint, @@ -1971,6 +1972,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 +1983,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 +1996,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 +2009,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 +2030,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 +2050,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 +2075,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 +2096,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 +2117,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 +2139,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 +2160,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 +2181,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 +2202,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 +2222,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 +2242,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 +2274,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 +2294,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 +2318,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 +2342,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 +2374,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 +2396,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 +2460,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); diff --git a/src/subcommand/wallet/inscribe.rs b/src/subcommand/wallet/inscribe.rs index d9b537f82d..037ddd25ef 100644 --- a/src/subcommand/wallet/inscribe.rs +++ b/src/subcommand/wallet/inscribe.rs @@ -9,11 +9,15 @@ use { self, constants::SCHNORR_SIGNATURE_SIZE, rand, schnorr::Signature, Secp256k1, XOnlyPublicKey, }, util::key::PrivateKey, + util::psbt::{self, Input, PartiallySignedTransaction, PsbtSighashType}, util::sighash::{Prevouts, SighashCache}, util::taproot::{ControlBlock, LeafVersion, TapLeafHash, TaprootBuilder}, - PackedLockTime, SchnorrSighashType, Witness, + PackedLockTime, SchnorrSig, SchnorrSighashType, Witness, + }, + bitcoincore_rpc::bitcoincore_rpc_json::{ + CreateRawTransactionInput, ImportDescriptors, SigHashType, Timestamp, + WalletCreateFundedPsbtOptions, }, - bitcoincore_rpc::bitcoincore_rpc_json::{ImportDescriptors, Timestamp}, bitcoincore_rpc::Client, std::collections::BTreeSet, }; @@ -22,6 +26,7 @@ use { struct Output { commit: Txid, inscription: InscriptionId, + parent: Option, reveal: Txid, fees: u64, } @@ -54,12 +59,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 +74,68 @@ impl Inscribe { let inscriptions = index.get_inscriptions(None)?; + let (parent_psbt, 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 tx_out = index + .get_transaction(satpoint.outpoint.txid)? + .expect("not found") + .output + .into_iter() + .nth(satpoint.outpoint.vout.try_into().unwrap()) + .expect("current transaction output"); + + let parent_input = CreateRawTransactionInput { + txid: satpoint.outpoint.txid, + vout: satpoint.outpoint.vout, + sequence: None, + }; + + let outputs = std::iter::once(( + Address::from_script(&tx_out.script_pubkey, options.chain().network()) + .unwrap() + .to_string(), + Amount::from_sat(tx_out.value), + )) + .collect::>(); + + let options = WalletCreateFundedPsbtOptions { + fee_rate: Some(Amount::ZERO), + ..Default::default() + }; + + let parent_psbt = client + .wallet_create_funded_psbt(&[parent_input], &outputs, None, Some(options), None)? + .psbt; + + let result = &client.wallet_process_psbt( + &parent_psbt.clone().to_string(), + None, + Some(SigHashType::from( + bitcoin::blockdata::transaction::EcdsaSighashType::AllPlusAnyoneCanPay, + )), // TODO: use SchnorrSighashType + None, + )?; + + let updated_psbt = PartiallySignedTransaction::from_str(&result.psbt).unwrap(); + + (Some(parent_psbt), 0) + } 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 +143,11 @@ impl Inscribe { .map(Ok) .unwrap_or_else(|| get_change_address(&client))?; - let (unsigned_commit_tx, reveal_tx, recovery_key_pair) = + + let (unsigned_commit_tx, reveal_psbt, _recovery_key_pair) = Inscribe::create_inscription_transactions( self.satpoint, + None, inscription, inscriptions, options.chain().network(), @@ -90,32 +159,63 @@ impl Inscribe { self.no_limit, )?; + let reveal_tx = reveal_psbt.clone().extract_tx(); + utxos.insert( - reveal_tx.input[0].previous_output, + 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 + [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 joined_psbt = if let Some(parent_psbt) = parent_psbt { + dbg!(Some(client.join_psbt(&[ + parent_psbt.to_string(), + reveal_psbt.to_string() + ])?)) + } else { + None + }; + if self.dry_run { print_json(Output { commit: unsigned_commit_tx.txid(), reveal: reveal_tx.txid(), inscription: 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; + let reveal_tx = if self.parent.is_some() { + let result = &client.wallet_process_psbt( + &reveal_psbt.to_string(), + None, + Some(SigHashType::from( + bitcoin::blockdata::transaction::EcdsaSighashType::AllPlusAnyoneCanPay, + )), // TODO: use SchnorrSighashType + None, + )?; + + // if !result.complete { + // return Err(anyhow!("Bitcoin Core failed to sign psbt")); + // } + + let updated_psbt = PartiallySignedTransaction::from_str(&result.psbt).unwrap(); + + dbg!(updated_psbt.extract_tx()) + } else { + reveal_tx + }; + let commit = client .send_raw_transaction(&signed_raw_commit_tx) .context("Failed to send commit transaction")?; @@ -124,10 +224,16 @@ impl Inscribe { .send_raw_transaction(&reveal_tx) .context("Failed to send reveal transaction")?; + let inscription = InscriptionId { + txid: reveal, + index: 0, + }; + print_json(Output { commit, reveal, - inscription: reveal.into(), + inscription, + parent: self.parent, fees, })?; }; @@ -146,6 +252,7 @@ impl Inscribe { fn create_inscription_transactions( satpoint: Option, + parent: Option<(SatPoint, TxOut)>, inscription: Inscription, inscriptions: BTreeMap, network: Network, @@ -155,7 +262,7 @@ impl Inscribe { commit_fee_rate: FeeRate, reveal_fee_rate: FeeRate, no_limit: bool, - ) -> Result<(Transaction, Transaction, TweakedKeyPair)> { + ) -> Result<(Transaction, PartiallySignedTransaction, TweakedKeyPair)> { let satpoint = if let Some(satpoint) = satpoint { satpoint } else { @@ -209,17 +316,42 @@ 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, tx_out)) = parent.clone() { + ( + vec![parent_satpoint.outpoint, OutPoint::null()], + vec![ + TxOut { + script_pubkey: tx_out.script_pubkey, + value: tx_out.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(), + outputs.clone(), &reveal_script, ); + // watch out that parent and inscription preserved let unsigned_commit_tx = TransactionBuilder::build_transaction_with_value( satpoint, inscriptions, @@ -237,53 +369,84 @@ 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, + 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() { + // sanity check fee + 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, mut reveal_psbt) = if parent.is_some() { + ( + Prevouts::One(commit_input_offset, output), + SchnorrSighashType::AllPlusAnyoneCanPay, + Self::build_reveal_psbt_with_parent(reveal_tx.clone()), + ) + } else { + ( + Prevouts::All(prevouts_all_inputs), + SchnorrSighashType::Default, + Self::build_reveal_psbt(reveal_tx.clone()), + ) + }; + 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()); + reveal_psbt.inputs[commit_input_offset].final_script_witness = Some(witness.clone()); + let recovery_key_pair = key_pair.tap_tweak(&secp256k1, taproot_spend_info.merkle_root()); let (x_only_pub_key, _parity) = recovery_key_pair.to_inner().x_only_public_key(); @@ -295,7 +458,7 @@ impl Inscribe { commit_tx_address ); - let reveal_weight = reveal_tx.weight(); + let reveal_weight = reveal_psbt.clone().extract_tx().weight(); if !no_limit && reveal_weight > MAX_STANDARD_TX_WEIGHT.try_into().unwrap() { bail!( @@ -303,10 +466,55 @@ impl Inscribe { ); } - Ok((unsigned_commit_tx, reveal_tx, recovery_key_pair)) + Ok((unsigned_commit_tx, reveal_psbt, recovery_key_pair)) } - fn backup_recovery_key( + fn build_reveal_psbt(reveal_tx: Transaction) -> PartiallySignedTransaction { + let mut psbt = PartiallySignedTransaction::from_unsigned_tx(reveal_tx.clone()).unwrap(); + psbt.inputs = vec![Input { + sighash_type: Some(PsbtSighashType::from(SchnorrSighashType::Default)), + ..Default::default() + }]; + psbt.outputs = vec![psbt::Output { + witness_script: Some(reveal_tx.output[0].clone().script_pubkey), + ..Default::default() + }]; + + psbt + } + + fn build_reveal_psbt_with_parent(reveal_tx: Transaction) -> PartiallySignedTransaction { + let mut psbt = PartiallySignedTransaction::from_unsigned_tx(reveal_tx.clone()).unwrap(); + + psbt.inputs = vec![ + Input { + sighash_type: Some(PsbtSighashType::from( + SchnorrSighashType::AllPlusAnyoneCanPay, + )), + ..Default::default() + }, + Input { + sighash_type: Some(PsbtSighashType::from( + SchnorrSighashType::AllPlusAnyoneCanPay, + )), + ..Default::default() + }, + ]; + + psbt.outputs = vec![ + psbt::Output { + witness_script: Some(reveal_tx.output[0].clone().script_pubkey), + ..Default::default() + }, + psbt::Output { + witness_script: Some(reveal_tx.output[1].clone().script_pubkey), + ..Default::default() + }, + ]; + + psbt + } + fn _backup_recovery_key( client: &Client, recovery_key_pair: TweakedKeyPair, network: Network, @@ -337,37 +545,42 @@ impl Inscribe { fn build_reveal_transaction( control_block: &ControlBlock, fee_rate: FeeRate, - input: OutPoint, - output: TxOut, + inputs: Vec, + 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, }; - let fee = { + let estimated_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 txin in &mut reveal_tx.input { + txin.witness.push( + Signature::from_slice(&[0; SCHNORR_SIGNATURE_SIZE]) + .unwrap() + .as_ref(), + ); + txin.witness.push(script); + txin.witness.push(&control_block.serialize()); + } fee_rate.fee(reveal_tx.vsize()) }; - (reveal_tx, fee) + (reveal_tx, estimated_fee) } } @@ -382,27 +595,30 @@ mod tests { let commit_address = change(0); let reveal_address = recipient(); - let (commit_tx, reveal_tx, _private_key) = Inscribe::create_inscription_transactions( - Some(satpoint(1, 0)), - inscription, - BTreeMap::new(), - Network::Bitcoin, - utxos.into_iter().collect(), - [commit_address, change(1)], - reveal_address, - FeeRate::try_from(1.0).unwrap(), - FeeRate::try_from(1.0).unwrap(), - false, - ) - .unwrap(); + let (unsigned_commit_tx, reveal_psbt, _private_key) = + Inscribe::create_inscription_transactions( + Some(satpoint(1, 0)), + None, + inscription, + BTreeMap::new(), + Network::Bitcoin, + utxos.into_iter().collect(), + [commit_address, change(1)], + reveal_address, + FeeRate::try_from(1.0).unwrap(), + FeeRate::try_from(1.0).unwrap(), + false, + ) + .unwrap(); #[allow(clippy::cast_possible_truncation)] #[allow(clippy::cast_sign_loss)] - let fee = Amount::from_sat((1.0 * (reveal_tx.vsize() as f64)).ceil() as u64); + let reveal_fee = + Amount::from_sat((1.0 * (reveal_psbt.clone().extract_tx().vsize() as f64)).round() as u64); assert_eq!( - reveal_tx.output[0].value, - 20000 - fee.to_sat() - (20000 - commit_tx.output[0].value), + reveal_psbt.extract_tx().output[0].value, + 20000 - (20000 - unsigned_commit_tx.output[0].value + reveal_fee.to_sat()), ); } @@ -413,8 +629,9 @@ mod tests { let commit_address = change(0); let reveal_address = recipient(); - let (commit_tx, reveal_tx, _) = Inscribe::create_inscription_transactions( + let (commit_tx, reveal_psbt, _) = Inscribe::create_inscription_transactions( Some(satpoint(1, 0)), + None, inscription, BTreeMap::new(), Network::Bitcoin, @@ -428,7 +645,7 @@ mod tests { .unwrap(); assert!(commit_tx.is_explicitly_rbf()); - assert!(reveal_tx.is_explicitly_rbf()); + assert!(reveal_psbt.extract_tx().is_explicitly_rbf()); } #[test] @@ -450,6 +667,7 @@ mod tests { let error = Inscribe::create_inscription_transactions( satpoint, + None, inscription, inscriptions, Network::Bitcoin, @@ -492,6 +710,7 @@ mod tests { assert!(Inscribe::create_inscription_transactions( satpoint, + None, inscription, inscriptions, Network::Bitcoin, @@ -526,8 +745,9 @@ mod tests { let reveal_address = recipient(); let fee_rate = 3.3; - let (commit_tx, reveal_tx, _private_key) = Inscribe::create_inscription_transactions( + let (commit_tx, reveal_psbt, _private_key) = Inscribe::create_inscription_transactions( satpoint, + None, inscription, inscriptions, bitcoin::Network::Signet, @@ -557,11 +777,11 @@ mod tests { let fee = FeeRate::try_from(fee_rate) .unwrap() - .fee(reveal_tx.vsize()) + .fee(reveal_psbt.clone().extract_tx().vsize()) .to_sat(); assert_eq!( - reveal_tx.output[0].value, + reveal_psbt.extract_tx().output[0].value, 20_000 - fee - (20_000 - commit_tx.output[0].value), ); } @@ -588,8 +808,9 @@ mod tests { let commit_fee_rate = 3.3; let fee_rate = 1.0; - let (commit_tx, reveal_tx, _private_key) = Inscribe::create_inscription_transactions( + let (commit_tx, reveal_psbt, _private_key) = Inscribe::create_inscription_transactions( satpoint, + None, inscription, inscriptions, bitcoin::Network::Signet, @@ -617,6 +838,8 @@ mod tests { assert_eq!(reveal_value, 20_000 - fee); + let reveal_tx = reveal_psbt.extract_tx(); + let fee = FeeRate::try_from(fee_rate) .unwrap() .fee(reveal_tx.vsize()) @@ -639,6 +862,7 @@ mod tests { let error = Inscribe::create_inscription_transactions( satpoint, + None, inscription, BTreeMap::new(), Network::Bitcoin, @@ -668,8 +892,9 @@ mod tests { let commit_address = change(0); let reveal_address = recipient(); - let (_commit_tx, reveal_tx, _private_key) = Inscribe::create_inscription_transactions( + let (_commit_tx, reveal_psbt, _private_key) = Inscribe::create_inscription_transactions( satpoint, + None, inscription, BTreeMap::new(), Network::Bitcoin, @@ -682,6 +907,6 @@ mod tests { ) .unwrap(); - assert!(reveal_tx.size() >= MAX_STANDARD_TX_WEIGHT as usize); + assert!(reveal_psbt.extract_tx().size() >= MAX_STANDARD_TX_WEIGHT as usize); } } 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/inscription.rs b/src/templates/inscription.rs index 0f396903fe..c6c40dcfa9 100644 --- a/src/templates/inscription.rs +++ b/src/templates/inscription.rs @@ -10,6 +10,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, @@ -42,6 +43,7 @@ mod tests { next: None, number: 1, output: tx_out(1, address()), + parent: None, previous: None, sat: None, satpoint: satpoint(1, 0), @@ -102,6 +104,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), @@ -133,6 +136,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), 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/templates/inscription.html b/templates/inscription.html index 37573f3706..fd1144a6ca 100644 --- a/templates/inscription.html +++ b/templates/inscription.html @@ -15,6 +15,10 @@

Inscription {{ self.number }}

id
{{ self.inscription_id }}
+%% if let Some(parent) = self.parent { +
parent
+
{{parent}}
+%% } %% if let Ok(address) = self.chain.address_from_script(&self.output.script_pubkey ) {
address
{{ address }}
diff --git a/test-bitcoincore-rpc/src/api.rs b/test-bitcoincore-rpc/src/api.rs index 17728d3c6d..2f4399d548 100644 --- a/test-bitcoincore-rpc/src/api.rs +++ b/test-bitcoincore-rpc/src/api.rs @@ -151,4 +151,22 @@ pub trait Api { #[rpc(name = "listwallets")] fn list_wallets(&self) -> Result, jsonrpc_core::Error>; + + #[rpc(name = "walletprocesspsbt")] + fn wallet_process_psbt( + &self, + psbt: String, + _sign: Option, + _sighash_type: Option, + _bip32derivs: Option, + ) -> Result; + +// #[rpc(name = "walletcreatefundedpsbt")] +// fn wallet_create_funded_psbt( +// inputs: Vec, +// outputs: HashMap, +// locktime: Option, +// options: Option, +// bip32derivs: Option +// ) -> Result; } diff --git a/test-bitcoincore-rpc/src/lib.rs b/test-bitcoincore-rpc/src/lib.rs index 7051d283d6..9fc37dea06 100644 --- a/test-bitcoincore-rpc/src/lib.rs +++ b/test-bitcoincore-rpc/src/lib.rs @@ -18,7 +18,8 @@ use { GetNetworkInfoResult, GetRawTransactionResult, GetTransactionResult, GetTransactionResultDetail, GetTransactionResultDetailCategory, GetWalletInfoResult, ImportDescriptors, ImportMultiResult, ListDescriptorsResult, ListTransactionResult, - ListUnspentResultEntry, LoadWalletResult, SignRawTransactionResult, Timestamp, WalletTxInfo, + ListUnspentResultEntry, LoadWalletResult, SignRawTransactionResult, Timestamp, + WalletCreateFundedPsbtResult, WalletProcessPsbtResult, WalletCreateFundedPsbtOptions, WalletTxInfo, }, jsonrpc_core::{IoHandler, Value}, jsonrpc_http_server::{CloseHandle, ServerBuilder}, @@ -118,7 +119,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 +151,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..b28db5e9b3 100644 --- a/test-bitcoincore-rpc/src/server.rs +++ b/test-bitcoincore-rpc/src/server.rs @@ -589,4 +589,31 @@ impl Api for Server { .collect::>(), ) } + + fn wallet_process_psbt( + &self, + psbt: String, + _sign: Option, + _sighash_type: Option, + _bip32derivs: Option, + ) -> Result { + Ok(WalletProcessPsbtResult { + psbt, + complete: true, + }) + } + +// fn wallet_create_funded_psbt( +// inputs: Vec, +// outputs: HashMap, +// locktime: Option, +// options: Option, +// bip32derivs: Option, +// ) -> Result { +// Ok(WalletCreateFundedPsbtResult { +// psbt: "".into(), +// fee: Amount::ZERO, +// change_position: 0, +// }) +// } } 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..a29d492f3b 100644 --- a/tests/core.rs +++ b/tests/core.rs @@ -1,4 +1,8 @@ -use super::*; +use { + super::*, + bitcoincore_rpc::{Client, RpcApi}, + std::ffi::OsString, +}; struct KillOnDrop(std::process::Child); @@ -70,3 +74,207 @@ fn preview() { format!(".*( u16 { + TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port() +} + +fn ord( + cookiefile: &std::path::PathBuf, + ord_data_dir: &std::path::PathBuf, + 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.as_path()) + .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(format!("-rpcport={rpc_port}")) + .spawn() + .expect("failed to spawn `bitcoind`"), + ); + + let cookiefile = bitcoin_data_dir.as_path().join("regtest/.cookie"); + + for attempt in 0.. { + match Client::new( + &format!("127.0.0.1:{rpc_port}"), + bitcoincore_rpc::Auth::CookieFile(cookiefile.clone()), + ) { + 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"]); + + // get funds in wallet + // inscribe parent + // mine block + // inscribe child with parent + + 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(); + + rpc_client.generate_to_address(101, &address).unwrap(); + + fs::write(ord_data_dir.as_path().join("parent.txt"), "Pater").unwrap(); + + #[derive(Deserialize, Debug)] + 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", "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(); + + 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", + "--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; + let ord_port = 8080; + rpc_client.generate_to_address(1, &address).unwrap(); + + 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) + .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/{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..b871a03588 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -43,6 +43,7 @@ struct Inscribe { inscription: String, reveal: Txid, fees: u64, + parent: Option, } fn inscribe(rpc_server: &test_bitcoincore_rpc::Handle) -> Inscribe { diff --git a/tests/wallet/inscribe.rs b/tests/wallet/inscribe.rs index 16b486037e..630b2f6f27 100644 --- a/tests/wallet/inscribe.rs +++ b/tests/wallet/inscribe.rs @@ -10,8 +10,9 @@ fn inscribe_creates_inscriptions() { create_wallet(&rpc_server); let Inscribe { inscription, .. } = inscribe(&rpc_server); - - assert_eq!(rpc_server.descriptors().len(), 3); + + // two because now no backup + assert_eq!(rpc_server.descriptors().len(), 2); let request = TestServer::spawn_with_args(&rpc_server, &[]).request(format!("/content/{inscription}")); @@ -335,7 +336,7 @@ 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); @@ -394,3 +395,53 @@ fn inscribe_with_no_limit() { .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 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}"), format!(".*")); + + let child_output = CommandBuilder::new(format!("wallet inscribe --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/{}", dbg!(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 --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(); +}