Skip to content

Commit 45e0147

Browse files
author
Ludo Galabru
committed
feat: import inscription parser
1 parent 20dc1a4 commit 45e0147

File tree

6 files changed

+318
-41
lines changed

6 files changed

+318
-41
lines changed

components/chainhook-event-observer/src/chainhooks/bitcoin/mod.rs

+10-2
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,15 @@ pub fn serialize_bitcoin_payload_to_json<'a>(
141141
"transaction_identifier": transaction.transaction_identifier,
142142
"operations": transaction.operations,
143143
"metadata": json!({
144-
"inputs": transaction.metadata.inputs,
144+
"inputs": transaction.metadata.inputs.iter().map(|input| {
145+
json!({
146+
"previous_output": {
147+
"txid": format!("0x{}", input.previous_output.txid),
148+
"vout": input.previous_output.vout,
149+
},
150+
"sequence": input.sequence,
151+
})
152+
}).collect::<Vec<_>>(),
145153
"outputs": transaction.metadata.outputs,
146154
"stacks_operations": transaction.metadata.stacks_operations,
147155
"ordinal_operations": transaction.metadata.ordinal_operations,
@@ -388,7 +396,7 @@ impl BitcoinChainhookSpecification {
388396
false
389397
}
390398
BitcoinPredicateType::Protocol(Protocols::Ordinal(
391-
OrdinalOperations::NewInscription,
399+
OrdinalOperations::InscriptionRevealed,
392400
)) => {
393401
for op in tx.metadata.ordinal_operations.iter() {
394402
if let OrdinalOperation::InscriptionReveal(_) = op {

components/chainhook-event-observer/src/chainhooks/types.rs

+1-5
Original file line numberDiff line numberDiff line change
@@ -337,7 +337,7 @@ pub enum StacksOperations {
337337
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, JsonSchema)]
338338
#[serde(rename_all = "snake_case")]
339339
pub enum OrdinalOperations {
340-
NewInscription,
340+
InscriptionRevealed,
341341
}
342342

343343
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] {
348348
}
349349
}
350350

351-
pub fn get_ordinal_canonical_magic_bytes() -> (usize, [u8; 3]) {
352-
return (37, *b"ord");
353-
}
354-
355351
pub struct PoxConfig {
356352
pub genesis_block_height: u64,
357353
pub prepare_phase_len: u64,

components/chainhook-event-observer/src/indexer/bitcoin/mod.rs

+23-23
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
mod blocks_pool;
2+
mod ordinal;
23

34
use std::time::Duration;
45

56
use crate::chainhooks::types::{
6-
get_canonical_pox_config, get_ordinal_canonical_magic_bytes, get_stacks_canonical_magic_bytes,
7-
PoxConfig, StacksOpcodes,
7+
get_canonical_pox_config, get_stacks_canonical_magic_bytes, PoxConfig, StacksOpcodes,
88
};
99
use crate::indexer::IndexerConfig;
1010
use crate::observer::BitcoinConfig;
1111
use crate::utils::Context;
12-
use bitcoincore_rpc::bitcoin;
12+
use bitcoincore_rpc::bitcoin::{self, Script};
1313
use bitcoincore_rpc_json::{GetRawTransactionResult, GetRawTransactionResultVout};
1414
pub use blocks_pool::BitcoinBlockPool;
1515
use chainhook_types::bitcoin::{OutPoint, TxIn, TxOut};
@@ -23,6 +23,8 @@ use clarity_repl::clarity::util::hash::{hex_bytes, to_hex};
2323
use hiro_system_kit::slog;
2424
use rocket::serde::json::Value as JsonValue;
2525

26+
use self::ordinal::InscriptionParser;
27+
2628
#[derive(Clone, PartialEq, Debug, Deserialize, Serialize)]
2729
#[serde(rename_all = "camelCase")]
2830
pub struct Block {
@@ -205,28 +207,26 @@ fn try_parse_ordinal_operation(
205207
block_height: u64,
206208
ctx: &Context,
207209
) -> Option<OrdinalOperation> {
208-
let (pos, magic_bytes) = get_ordinal_canonical_magic_bytes();
209-
let limit = pos + magic_bytes.len();
210-
211210
for input in tx.vin.iter() {
212211
if let Some(ref witnesses) = input.txinwitness {
213-
for witness in witnesses.iter() {
214-
if witness.len() > limit && witness[pos..limit] == magic_bytes {
215-
ctx.try_log(|logger| {
216-
slog::info!(
217-
logger,
218-
"Ordinal operation detected in transaction {}",
219-
tx.txid,
220-
)
221-
});
222-
return Some(OrdinalOperation::InscriptionReveal(
223-
OrdinalInscriptionRevealData {
224-
satoshi_point: "".into(),
225-
content_type: "".into(),
226-
content: vec![],
227-
},
228-
));
229-
}
212+
for bytes in witnesses.iter() {
213+
let script = Script::from(bytes.to_vec());
214+
let parser = InscriptionParser {
215+
instructions: script.instructions().peekable(),
216+
};
217+
218+
let inscription = match parser.parse_script() {
219+
Ok(inscription) => inscription,
220+
Err(_) => continue,
221+
};
222+
223+
return Some(OrdinalOperation::InscriptionReveal(
224+
OrdinalInscriptionRevealData {
225+
satoshi_point: "".into(),
226+
content_type: inscription.content_type().unwrap_or("unknown").to_string(),
227+
content: format!("0x{}", to_hex(&inscription.body().unwrap_or(&vec![]))),
228+
},
229+
));
230230
}
231231
}
232232
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
use std::collections::BTreeMap;
2+
use std::str::FromStr;
3+
use {
4+
bitcoincore_rpc::bitcoin::{
5+
blockdata::{
6+
opcodes,
7+
script::{self, Instruction, Instructions},
8+
},
9+
util::taproot::TAPROOT_ANNEX_PREFIX,
10+
Script, Witness,
11+
},
12+
std::{iter::Peekable, str},
13+
};
14+
15+
const PROTOCOL_ID: &[u8] = b"ord";
16+
17+
const BODY_TAG: &[u8] = &[];
18+
const CONTENT_TYPE_TAG: &[u8] = &[1];
19+
20+
#[derive(Debug, PartialEq, Clone)]
21+
pub struct Inscription {
22+
body: Option<Vec<u8>>,
23+
content_type: Option<Vec<u8>>,
24+
}
25+
26+
impl Inscription {
27+
fn append_reveal_script_to_builder(&self, mut builder: script::Builder) -> script::Builder {
28+
builder = builder
29+
.push_opcode(opcodes::OP_FALSE)
30+
.push_opcode(opcodes::all::OP_IF)
31+
.push_slice(PROTOCOL_ID);
32+
33+
if let Some(content_type) = &self.content_type {
34+
builder = builder
35+
.push_slice(CONTENT_TYPE_TAG)
36+
.push_slice(content_type);
37+
}
38+
39+
if let Some(body) = &self.body {
40+
builder = builder.push_slice(BODY_TAG);
41+
for chunk in body.chunks(520) {
42+
builder = builder.push_slice(chunk);
43+
}
44+
}
45+
46+
builder.push_opcode(opcodes::all::OP_ENDIF)
47+
}
48+
49+
pub(crate) fn append_reveal_script(&self, builder: script::Builder) -> Script {
50+
self.append_reveal_script_to_builder(builder).into_script()
51+
}
52+
53+
pub(crate) fn media(&self) -> Media {
54+
if self.body.is_none() {
55+
return Media::Unknown;
56+
}
57+
58+
let content_type = match self.content_type() {
59+
Some(content_type) => content_type,
60+
None => return Media::Unknown,
61+
};
62+
63+
content_type.parse().unwrap_or(Media::Unknown)
64+
}
65+
66+
pub(crate) fn body(&self) -> Option<&[u8]> {
67+
Some(self.body.as_ref()?)
68+
}
69+
70+
pub(crate) fn into_body(self) -> Option<Vec<u8>> {
71+
self.body
72+
}
73+
74+
pub(crate) fn content_length(&self) -> Option<usize> {
75+
Some(self.body()?.len())
76+
}
77+
78+
pub(crate) fn content_type(&self) -> Option<&str> {
79+
str::from_utf8(self.content_type.as_ref()?).ok()
80+
}
81+
}
82+
83+
#[derive(Debug, PartialEq)]
84+
pub enum InscriptionError {
85+
EmptyWitness,
86+
InvalidInscription,
87+
KeyPathSpend,
88+
NoInscription,
89+
Script(script::Error),
90+
UnrecognizedEvenField,
91+
}
92+
93+
type Result<T, E = InscriptionError> = std::result::Result<T, E>;
94+
95+
pub struct InscriptionParser<'a> {
96+
pub instructions: Peekable<Instructions<'a>>,
97+
}
98+
99+
impl<'a> InscriptionParser<'a> {
100+
pub fn parse(witness: &Witness) -> Result<Inscription> {
101+
if witness.is_empty() {
102+
return Err(InscriptionError::EmptyWitness);
103+
}
104+
105+
if witness.len() == 1 {
106+
return Err(InscriptionError::KeyPathSpend);
107+
}
108+
109+
let annex = witness
110+
.last()
111+
.and_then(|element| element.first().map(|byte| *byte == TAPROOT_ANNEX_PREFIX))
112+
.unwrap_or(false);
113+
114+
if witness.len() == 2 && annex {
115+
return Err(InscriptionError::KeyPathSpend);
116+
}
117+
118+
let script = witness
119+
.iter()
120+
.nth(if annex {
121+
witness.len() - 1
122+
} else {
123+
witness.len() - 2
124+
})
125+
.unwrap();
126+
127+
InscriptionParser {
128+
instructions: Script::from(Vec::from(script)).instructions().peekable(),
129+
}
130+
.parse_script()
131+
}
132+
133+
pub fn parse_script(mut self) -> Result<Inscription> {
134+
loop {
135+
let next = self.advance()?;
136+
137+
if next == Instruction::PushBytes(&[]) {
138+
if let Some(inscription) = self.parse_inscription()? {
139+
return Ok(inscription);
140+
}
141+
}
142+
}
143+
}
144+
145+
fn advance(&mut self) -> Result<Instruction<'a>> {
146+
self.instructions
147+
.next()
148+
.ok_or(InscriptionError::NoInscription)?
149+
.map_err(InscriptionError::Script)
150+
}
151+
152+
fn parse_inscription(&mut self) -> Result<Option<Inscription>> {
153+
if self.advance()? == Instruction::Op(opcodes::all::OP_IF) {
154+
if !self.accept(Instruction::PushBytes(PROTOCOL_ID))? {
155+
return Err(InscriptionError::NoInscription);
156+
}
157+
158+
let mut fields = BTreeMap::new();
159+
160+
loop {
161+
match self.advance()? {
162+
Instruction::PushBytes(BODY_TAG) => {
163+
let mut body = Vec::new();
164+
while !self.accept(Instruction::Op(opcodes::all::OP_ENDIF))? {
165+
body.extend_from_slice(self.expect_push()?);
166+
}
167+
fields.insert(BODY_TAG, body);
168+
break;
169+
}
170+
Instruction::PushBytes(tag) => {
171+
if fields.contains_key(tag) {
172+
return Err(InscriptionError::InvalidInscription);
173+
}
174+
fields.insert(tag, self.expect_push()?.to_vec());
175+
}
176+
Instruction::Op(opcodes::all::OP_ENDIF) => break,
177+
_ => return Err(InscriptionError::InvalidInscription),
178+
}
179+
}
180+
181+
let body = fields.remove(BODY_TAG);
182+
let content_type = fields.remove(CONTENT_TYPE_TAG);
183+
184+
for tag in fields.keys() {
185+
if let Some(lsb) = tag.first() {
186+
if lsb % 2 == 0 {
187+
return Err(InscriptionError::UnrecognizedEvenField);
188+
}
189+
}
190+
}
191+
192+
return Ok(Some(Inscription { body, content_type }));
193+
}
194+
195+
Ok(None)
196+
}
197+
198+
fn expect_push(&mut self) -> Result<&'a [u8]> {
199+
match self.advance()? {
200+
Instruction::PushBytes(bytes) => Ok(bytes),
201+
_ => Err(InscriptionError::InvalidInscription),
202+
}
203+
}
204+
205+
fn accept(&mut self, instruction: Instruction) -> Result<bool> {
206+
match self.instructions.peek() {
207+
Some(Ok(next)) => {
208+
if *next == instruction {
209+
self.advance()?;
210+
Ok(true)
211+
} else {
212+
Ok(false)
213+
}
214+
}
215+
Some(Err(err)) => Err(InscriptionError::Script(*err)),
216+
None => Ok(false),
217+
}
218+
}
219+
}
220+
221+
#[derive(Debug, PartialEq, Copy, Clone)]
222+
pub(crate) enum Media {
223+
Audio,
224+
Iframe,
225+
Image,
226+
Pdf,
227+
Text,
228+
Unknown,
229+
Video,
230+
}
231+
232+
impl Media {
233+
const TABLE: &'static [(&'static str, Media, &'static [&'static str])] = &[
234+
("application/json", Media::Text, &["json"]),
235+
("application/pdf", Media::Pdf, &["pdf"]),
236+
("application/pgp-signature", Media::Text, &["asc"]),
237+
("application/yaml", Media::Text, &["yaml", "yml"]),
238+
("audio/flac", Media::Audio, &["flac"]),
239+
("audio/mpeg", Media::Audio, &["mp3"]),
240+
("audio/wav", Media::Audio, &["wav"]),
241+
("image/apng", Media::Image, &["apng"]),
242+
("image/avif", Media::Image, &[]),
243+
("image/gif", Media::Image, &["gif"]),
244+
("image/jpeg", Media::Image, &["jpg", "jpeg"]),
245+
("image/png", Media::Image, &["png"]),
246+
("image/svg+xml", Media::Iframe, &["svg"]),
247+
("image/webp", Media::Image, &["webp"]),
248+
("model/stl", Media::Unknown, &["stl"]),
249+
("text/html;charset=utf-8", Media::Iframe, &["html"]),
250+
("text/plain;charset=utf-8", Media::Text, &["txt"]),
251+
("video/mp4", Media::Video, &["mp4"]),
252+
("video/webm", Media::Video, &["webm"]),
253+
];
254+
}
255+
256+
impl FromStr for Media {
257+
type Err = String;
258+
259+
fn from_str(s: &str) -> Result<Self, Self::Err> {
260+
for entry in Self::TABLE {
261+
if entry.0 == s {
262+
return Ok(entry.1);
263+
}
264+
}
265+
266+
Err("unknown content type: {s}".to_string())
267+
}
268+
}

0 commit comments

Comments
 (0)