From 45e0147ecf34513e6c64d313e8e423d1501faf41 Mon Sep 17 00:00:00 2001 From: Ludo Galabru Date: Fri, 10 Feb 2023 10:17:12 -0500 Subject: [PATCH] feat: import inscription parser --- .../src/chainhooks/bitcoin/mod.rs | 12 +- .../src/chainhooks/types.rs | 6 +- .../src/indexer/bitcoin/mod.rs | 46 +-- .../src/indexer/bitcoin/ordinal/mod.rs | 268 ++++++++++++++++++ .../src/indexer/bitcoin/tests.rs | 24 +- components/chainhook-types-rs/src/rosetta.rs | 3 +- 6 files changed, 318 insertions(+), 41 deletions(-) create mode 100644 components/chainhook-event-observer/src/indexer/bitcoin/ordinal/mod.rs diff --git a/components/chainhook-event-observer/src/chainhooks/bitcoin/mod.rs b/components/chainhook-event-observer/src/chainhooks/bitcoin/mod.rs index 15a87e4..d29258c 100644 --- a/components/chainhook-event-observer/src/chainhooks/bitcoin/mod.rs +++ b/components/chainhook-event-observer/src/chainhooks/bitcoin/mod.rs @@ -141,7 +141,15 @@ pub fn serialize_bitcoin_payload_to_json<'a>( "transaction_identifier": transaction.transaction_identifier, "operations": transaction.operations, "metadata": json!({ - "inputs": transaction.metadata.inputs, + "inputs": transaction.metadata.inputs.iter().map(|input| { + json!({ + "previous_output": { + "txid": format!("0x{}", input.previous_output.txid), + "vout": input.previous_output.vout, + }, + "sequence": input.sequence, + }) + }).collect::>(), "outputs": transaction.metadata.outputs, "stacks_operations": transaction.metadata.stacks_operations, "ordinal_operations": transaction.metadata.ordinal_operations, @@ -388,7 +396,7 @@ impl BitcoinChainhookSpecification { false } BitcoinPredicateType::Protocol(Protocols::Ordinal( - OrdinalOperations::NewInscription, + OrdinalOperations::InscriptionRevealed, )) => { for op in tx.metadata.ordinal_operations.iter() { if let OrdinalOperation::InscriptionReveal(_) = op { diff --git a/components/chainhook-event-observer/src/chainhooks/types.rs b/components/chainhook-event-observer/src/chainhooks/types.rs index be8b3d8..c8615d3 100644 --- a/components/chainhook-event-observer/src/chainhooks/types.rs +++ b/components/chainhook-event-observer/src/chainhooks/types.rs @@ -337,7 +337,7 @@ pub enum StacksOperations { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)] #[serde(rename_all = "snake_case")] pub enum OrdinalOperations { - NewInscription, + InscriptionRevealed, } pub fn get_stacks_canonical_magic_bytes(network: &BitcoinNetwork) -> [u8; 2] { @@ -348,10 +348,6 @@ pub fn get_stacks_canonical_magic_bytes(network: &BitcoinNetwork) -> [u8; 2] { } } -pub fn get_ordinal_canonical_magic_bytes() -> (usize, [u8; 3]) { - return (37, *b"ord"); -} - pub struct PoxConfig { pub genesis_block_height: u64, pub prepare_phase_len: u64, diff --git a/components/chainhook-event-observer/src/indexer/bitcoin/mod.rs b/components/chainhook-event-observer/src/indexer/bitcoin/mod.rs index 2524c18..bbc7f51 100644 --- a/components/chainhook-event-observer/src/indexer/bitcoin/mod.rs +++ b/components/chainhook-event-observer/src/indexer/bitcoin/mod.rs @@ -1,15 +1,15 @@ mod blocks_pool; +mod ordinal; use std::time::Duration; use crate::chainhooks::types::{ - get_canonical_pox_config, get_ordinal_canonical_magic_bytes, get_stacks_canonical_magic_bytes, - PoxConfig, StacksOpcodes, + get_canonical_pox_config, get_stacks_canonical_magic_bytes, PoxConfig, StacksOpcodes, }; use crate::indexer::IndexerConfig; use crate::observer::BitcoinConfig; use crate::utils::Context; -use bitcoincore_rpc::bitcoin; +use bitcoincore_rpc::bitcoin::{self, Script}; use bitcoincore_rpc_json::{GetRawTransactionResult, GetRawTransactionResultVout}; pub use blocks_pool::BitcoinBlockPool; use chainhook_types::bitcoin::{OutPoint, TxIn, TxOut}; @@ -23,6 +23,8 @@ use clarity_repl::clarity::util::hash::{hex_bytes, to_hex}; use hiro_system_kit::slog; use rocket::serde::json::Value as JsonValue; +use self::ordinal::InscriptionParser; + #[derive(Clone, PartialEq, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] pub struct Block { @@ -205,28 +207,26 @@ fn try_parse_ordinal_operation( block_height: u64, ctx: &Context, ) -> Option { - let (pos, magic_bytes) = get_ordinal_canonical_magic_bytes(); - let limit = pos + magic_bytes.len(); - for input in tx.vin.iter() { if let Some(ref witnesses) = input.txinwitness { - for witness in witnesses.iter() { - if witness.len() > limit && witness[pos..limit] == magic_bytes { - ctx.try_log(|logger| { - slog::info!( - logger, - "Ordinal operation detected in transaction {}", - tx.txid, - ) - }); - return Some(OrdinalOperation::InscriptionReveal( - OrdinalInscriptionRevealData { - satoshi_point: "".into(), - content_type: "".into(), - content: vec![], - }, - )); - } + for bytes in witnesses.iter() { + let script = Script::from(bytes.to_vec()); + let parser = InscriptionParser { + instructions: script.instructions().peekable(), + }; + + let inscription = match parser.parse_script() { + Ok(inscription) => inscription, + Err(_) => continue, + }; + + return Some(OrdinalOperation::InscriptionReveal( + OrdinalInscriptionRevealData { + satoshi_point: "".into(), + content_type: inscription.content_type().unwrap_or("unknown").to_string(), + content: format!("0x{}", to_hex(&inscription.body().unwrap_or(&vec![]))), + }, + )); } } } diff --git a/components/chainhook-event-observer/src/indexer/bitcoin/ordinal/mod.rs b/components/chainhook-event-observer/src/indexer/bitcoin/ordinal/mod.rs new file mode 100644 index 0000000..b88481b --- /dev/null +++ b/components/chainhook-event-observer/src/indexer/bitcoin/ordinal/mod.rs @@ -0,0 +1,268 @@ +use std::collections::BTreeMap; +use std::str::FromStr; +use { + bitcoincore_rpc::bitcoin::{ + blockdata::{ + opcodes, + script::{self, Instruction, Instructions}, + }, + util::taproot::TAPROOT_ANNEX_PREFIX, + Script, Witness, + }, + std::{iter::Peekable, str}, +}; + +const PROTOCOL_ID: &[u8] = b"ord"; + +const BODY_TAG: &[u8] = &[]; +const CONTENT_TYPE_TAG: &[u8] = &[1]; + +#[derive(Debug, PartialEq, Clone)] +pub struct Inscription { + body: Option>, + content_type: Option>, +} + +impl Inscription { + fn append_reveal_script_to_builder(&self, mut builder: script::Builder) -> script::Builder { + builder = builder + .push_opcode(opcodes::OP_FALSE) + .push_opcode(opcodes::all::OP_IF) + .push_slice(PROTOCOL_ID); + + if let Some(content_type) = &self.content_type { + builder = builder + .push_slice(CONTENT_TYPE_TAG) + .push_slice(content_type); + } + + if let Some(body) = &self.body { + builder = builder.push_slice(BODY_TAG); + for chunk in body.chunks(520) { + builder = builder.push_slice(chunk); + } + } + + builder.push_opcode(opcodes::all::OP_ENDIF) + } + + pub(crate) fn append_reveal_script(&self, builder: script::Builder) -> Script { + self.append_reveal_script_to_builder(builder).into_script() + } + + pub(crate) fn media(&self) -> Media { + if self.body.is_none() { + return Media::Unknown; + } + + let content_type = match self.content_type() { + Some(content_type) => content_type, + None => return Media::Unknown, + }; + + content_type.parse().unwrap_or(Media::Unknown) + } + + pub(crate) fn body(&self) -> Option<&[u8]> { + Some(self.body.as_ref()?) + } + + pub(crate) fn into_body(self) -> Option> { + self.body + } + + pub(crate) fn content_length(&self) -> Option { + Some(self.body()?.len()) + } + + pub(crate) fn content_type(&self) -> Option<&str> { + str::from_utf8(self.content_type.as_ref()?).ok() + } +} + +#[derive(Debug, PartialEq)] +pub enum InscriptionError { + EmptyWitness, + InvalidInscription, + KeyPathSpend, + NoInscription, + Script(script::Error), + UnrecognizedEvenField, +} + +type Result = std::result::Result; + +pub struct InscriptionParser<'a> { + pub instructions: Peekable>, +} + +impl<'a> InscriptionParser<'a> { + pub fn parse(witness: &Witness) -> Result { + if witness.is_empty() { + return Err(InscriptionError::EmptyWitness); + } + + if witness.len() == 1 { + return Err(InscriptionError::KeyPathSpend); + } + + let annex = witness + .last() + .and_then(|element| element.first().map(|byte| *byte == TAPROOT_ANNEX_PREFIX)) + .unwrap_or(false); + + if witness.len() == 2 && annex { + return Err(InscriptionError::KeyPathSpend); + } + + let script = witness + .iter() + .nth(if annex { + witness.len() - 1 + } else { + witness.len() - 2 + }) + .unwrap(); + + InscriptionParser { + instructions: Script::from(Vec::from(script)).instructions().peekable(), + } + .parse_script() + } + + pub fn parse_script(mut self) -> Result { + loop { + let next = self.advance()?; + + if next == Instruction::PushBytes(&[]) { + if let Some(inscription) = self.parse_inscription()? { + return Ok(inscription); + } + } + } + } + + fn advance(&mut self) -> Result> { + self.instructions + .next() + .ok_or(InscriptionError::NoInscription)? + .map_err(InscriptionError::Script) + } + + fn parse_inscription(&mut self) -> Result> { + if self.advance()? == Instruction::Op(opcodes::all::OP_IF) { + if !self.accept(Instruction::PushBytes(PROTOCOL_ID))? { + return Err(InscriptionError::NoInscription); + } + + let mut fields = BTreeMap::new(); + + loop { + match self.advance()? { + Instruction::PushBytes(BODY_TAG) => { + let mut body = Vec::new(); + while !self.accept(Instruction::Op(opcodes::all::OP_ENDIF))? { + body.extend_from_slice(self.expect_push()?); + } + fields.insert(BODY_TAG, body); + break; + } + Instruction::PushBytes(tag) => { + if fields.contains_key(tag) { + return Err(InscriptionError::InvalidInscription); + } + fields.insert(tag, self.expect_push()?.to_vec()); + } + Instruction::Op(opcodes::all::OP_ENDIF) => break, + _ => return Err(InscriptionError::InvalidInscription), + } + } + + let body = fields.remove(BODY_TAG); + let content_type = fields.remove(CONTENT_TYPE_TAG); + + for tag in fields.keys() { + if let Some(lsb) = tag.first() { + if lsb % 2 == 0 { + return Err(InscriptionError::UnrecognizedEvenField); + } + } + } + + return Ok(Some(Inscription { body, content_type })); + } + + Ok(None) + } + + fn expect_push(&mut self) -> Result<&'a [u8]> { + match self.advance()? { + Instruction::PushBytes(bytes) => Ok(bytes), + _ => Err(InscriptionError::InvalidInscription), + } + } + + fn accept(&mut self, instruction: Instruction) -> Result { + match self.instructions.peek() { + Some(Ok(next)) => { + if *next == instruction { + self.advance()?; + Ok(true) + } else { + Ok(false) + } + } + Some(Err(err)) => Err(InscriptionError::Script(*err)), + None => Ok(false), + } + } +} + +#[derive(Debug, PartialEq, Copy, Clone)] +pub(crate) enum Media { + Audio, + Iframe, + Image, + Pdf, + Text, + Unknown, + Video, +} + +impl Media { + const TABLE: &'static [(&'static str, Media, &'static [&'static str])] = &[ + ("application/json", Media::Text, &["json"]), + ("application/pdf", Media::Pdf, &["pdf"]), + ("application/pgp-signature", Media::Text, &["asc"]), + ("application/yaml", Media::Text, &["yaml", "yml"]), + ("audio/flac", Media::Audio, &["flac"]), + ("audio/mpeg", Media::Audio, &["mp3"]), + ("audio/wav", Media::Audio, &["wav"]), + ("image/apng", Media::Image, &["apng"]), + ("image/avif", Media::Image, &[]), + ("image/gif", Media::Image, &["gif"]), + ("image/jpeg", Media::Image, &["jpg", "jpeg"]), + ("image/png", Media::Image, &["png"]), + ("image/svg+xml", Media::Iframe, &["svg"]), + ("image/webp", Media::Image, &["webp"]), + ("model/stl", Media::Unknown, &["stl"]), + ("text/html;charset=utf-8", Media::Iframe, &["html"]), + ("text/plain;charset=utf-8", Media::Text, &["txt"]), + ("video/mp4", Media::Video, &["mp4"]), + ("video/webm", Media::Video, &["webm"]), + ]; +} + +impl FromStr for Media { + type Err = String; + + fn from_str(s: &str) -> Result { + for entry in Self::TABLE { + if entry.0 == s { + return Ok(entry.1); + } + } + + Err("unknown content type: {s}".to_string()) + } +} diff --git a/components/chainhook-event-observer/src/indexer/bitcoin/tests.rs b/components/chainhook-event-observer/src/indexer/bitcoin/tests.rs index e3d663d..5309b33 100644 --- a/components/chainhook-event-observer/src/indexer/bitcoin/tests.rs +++ b/components/chainhook-event-observer/src/indexer/bitcoin/tests.rs @@ -1,3 +1,7 @@ +use bitcoincore_rpc::bitcoin::Script; + +use crate::indexer::bitcoin::ordinal::InscriptionParser; + use super::super::tests::{helpers, process_bitcoin_blocks_and_check_expectations}; #[test] @@ -209,17 +213,17 @@ fn test_bitcoin_vector_040() { fn test_ordinal_inscription_parsing() { use clarity_repl::clarity::util::hash::hex_bytes; - let witness = hex_bytes("208737bc46923c3e64c7e6768c0346879468bf3aba795a5f5f56efca288f50ed2aac0063036f7264010118746578742f706c61696e3b636861727365743d7574662d38004c9948656c6c6f2030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030300a68").unwrap(); + let bytes = hex_bytes("208737bc46923c3e64c7e6768c0346879468bf3aba795a5f5f56efca288f50ed2aac0063036f7264010118746578742f706c61696e3b636861727365743d7574662d38004c9948656c6c6f2030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030303030300a68").unwrap(); - let (pos, magic_bytes) = super::get_ordinal_canonical_magic_bytes(); - let limit = pos + magic_bytes.len(); + let script = Script::from(bytes); + let parser = InscriptionParser { + instructions: script.instructions().peekable(), + }; - println!("{:?}", &magic_bytes); + let inscription = match parser.parse_script() { + Ok(inscription) => inscription, + Err(_) => panic!(), + }; - // println!("{:?}", &witness); - println!("{:?}", &witness[pos..limit]); - if witness.len() > limit && witness[pos..limit] == magic_bytes { - } else { - panic!(); - } + println!("{:?}", inscription); } diff --git a/components/chainhook-types-rs/src/rosetta.rs b/components/chainhook-types-rs/src/rosetta.rs index 9ef0925..9359e9d 100644 --- a/components/chainhook-types-rs/src/rosetta.rs +++ b/components/chainhook-types-rs/src/rosetta.rs @@ -273,6 +273,7 @@ pub struct BitcoinTransactionMetadata { } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "snake_case")] pub enum OrdinalOperation { InscriptionCommit(OrdinalInscriptionCommitData), InscriptionReveal(OrdinalInscriptionRevealData), @@ -285,7 +286,7 @@ pub struct OrdinalInscriptionCommitData {} pub struct OrdinalInscriptionRevealData { pub satoshi_point: String, pub content_type: String, - pub content: Vec, + pub content: String, } #[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]