diff --git a/Cargo.lock b/Cargo.lock index 6241423d4b..fc10fec9c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1943,6 +1943,7 @@ dependencies = [ "anyhow", "axum", "axum-server", + "base64", "bitcoin", "boilerplate", "chrono", diff --git a/Cargo.toml b/Cargo.toml index caf1cc8e74..d0cc484531 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [".", "test-bitcoincore-rpc"] anyhow = { version = "1.0.56", features = ["backtrace"] } axum = "0.5.6" axum-server = "0.4.0" +base64 = "0.13.1" bitcoin = { version = "0.29.1", features = ["rand"] } boilerplate = { version = "0.2.1", features = ["axum"] } chrono = "0.4.19" diff --git a/src/index.rs b/src/index.rs index bc34a8b87b..3b31e9ab69 100644 --- a/src/index.rs +++ b/src/index.rs @@ -342,7 +342,10 @@ impl Index { .begin_read()? .open_table(ORDINAL_TO_INSCRIPTION)? .get(&ordinal.n())? - .map(|inscription| Inscription(inscription.to_owned())), + .map(|inscription| { + serde_json::from_str(inscription) + .expect("failed to deserialize inscription (JSON) from database") + }), ) } diff --git a/src/index/updater.rs b/src/index/updater.rs index 54054eaa5e..dc14a45d77 100644 --- a/src/index/updater.rs +++ b/src/index/updater.rs @@ -295,7 +295,9 @@ impl Updater { if self.chain != Chain::Mainnet { if let Some((ordinal, inscription)) = Inscription::from_transaction(tx, input_ordinal_ranges) { - ordinal_to_inscription.insert(&ordinal.n(), &inscription.0)?; + let json = serde_json::to_string(&inscription) + .expect("Inscription serialization should always succeed"); + ordinal_to_inscription.insert(&ordinal.n(), &json)?; } } diff --git a/src/inscription.rs b/src/inscription.rs index 79b41aeac1..2b82fbe37d 100644 --- a/src/inscription.rs +++ b/src/inscription.rs @@ -11,8 +11,11 @@ use { std::str::{self, Utf8Error}, }; -#[derive(Debug, PartialEq)] -pub(crate) struct Inscription(pub(crate) String); +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub(crate) enum Inscription { + Text(String), + Png(Vec), +} impl Inscription { pub(crate) fn from_transaction( @@ -25,6 +28,41 @@ impl Inscription { Some((Ordinal(*start), inscription)) } + + pub(crate) fn from_file(path: PathBuf) -> Result { + let file = fs::read(&path).with_context(|| format!("io error reading {}", path.display()))?; + + if file.len() > 520 { + bail!("file size exceeds 520 bytes"); + } + + match path + .extension() + .ok_or_else(|| anyhow!("file must have extension"))? + .to_str() + .ok_or_else(|| anyhow!("unrecognized extension"))? + { + "txt" => Ok(Inscription::Text(String::from_utf8(file)?)), + "png" => Ok(Inscription::Png(file)), + other => Err(anyhow!( + "unrecognized file extension `.{other}`, only .txt and .png accepted" + )), + } + } + + pub(crate) fn media_type(&self) -> &str { + match self { + Inscription::Text(_) => "text/plain;charset=utf-8", + Inscription::Png(_) => "image/png", + } + } + + pub(crate) fn content(&self) -> &[u8] { + match self { + Inscription::Text(text) => text.as_bytes(), + Inscription::Png(png) => png.as_ref(), + } + } } #[derive(Debug, PartialEq)] @@ -99,19 +137,33 @@ impl<'a> InscriptionParser<'a> { fn parse_inscription(&mut self) -> Result> { if self.advance()? == Instruction::Op(opcodes::all::OP_IF) { - let content = self.advance()?; - - let content = if let Instruction::PushBytes(bytes) = content { + let media_type = if let Instruction::PushBytes(bytes) = self.advance()? { str::from_utf8(bytes).map_err(InscriptionError::Utf8Decode)? } else { return Err(InscriptionError::InvalidInscription); }; + let content = if let Instruction::PushBytes(bytes) = self.advance()? { + bytes + } else { + return Err(InscriptionError::InvalidInscription); + }; + + let inscription = match media_type { + "text/plain;charset=utf-8" => Some(Inscription::Text( + str::from_utf8(content) + .map_err(InscriptionError::Utf8Decode)? + .into(), + )), + "image/png" => Some(Inscription::Png(content.to_vec())), + _ => None, + }; + if self.advance()? != Instruction::Op(opcodes::all::OP_ENDIF) { return Err(InscriptionError::InvalidInscription); } - return Ok(Some(Inscription(content.to_string()))); + return Ok(inscription); } Ok(None) @@ -167,13 +219,14 @@ mod tests { let script = script::Builder::new() .push_opcode(opcodes::OP_FALSE) .push_opcode(opcodes::all::OP_IF) + .push_slice("text/plain;charset=utf-8".as_bytes()) .push_slice("ord".as_bytes()) .push_opcode(opcodes::all::OP_ENDIF) .into_script(); assert_eq!( InscriptionParser::parse(&Witness::from_vec(vec![script.into_bytes(), vec![]])), - Ok(Inscription("ord".into())) + Ok(Inscription::Text("ord".into())) ); } @@ -182,6 +235,7 @@ mod tests { let script = script::Builder::new() .push_opcode(opcodes::OP_FALSE) .push_opcode(opcodes::all::OP_IF) + .push_slice("text/plain;charset=utf-8".as_bytes()) .push_slice("ord".as_bytes()) .push_opcode(opcodes::all::OP_ENDIF) .push_opcode(opcodes::all::OP_CHECKSIG) @@ -189,7 +243,7 @@ mod tests { assert_eq!( InscriptionParser::parse(&Witness::from_vec(vec![script.into_bytes(), vec![]])), - Ok(Inscription("ord".into())) + Ok(Inscription::Text("ord".into())) ); } @@ -199,13 +253,14 @@ mod tests { .push_opcode(opcodes::all::OP_CHECKSIG) .push_opcode(opcodes::OP_FALSE) .push_opcode(opcodes::all::OP_IF) + .push_slice("text/plain;charset=utf-8".as_bytes()) .push_slice("ord".as_bytes()) .push_opcode(opcodes::all::OP_ENDIF) .into_script(); assert_eq!( InscriptionParser::parse(&Witness::from_vec(vec![script.into_bytes(), vec![]])), - Ok(Inscription("ord".into())) + Ok(Inscription::Text("ord".into())) ); } @@ -214,17 +269,19 @@ mod tests { let script = script::Builder::new() .push_opcode(opcodes::OP_FALSE) .push_opcode(opcodes::all::OP_IF) + .push_slice("text/plain;charset=utf-8".as_bytes()) .push_slice("foo".as_bytes()) .push_opcode(opcodes::all::OP_ENDIF) .push_opcode(opcodes::OP_FALSE) .push_opcode(opcodes::all::OP_IF) + .push_slice("text/plain;charset=utf-8".as_bytes()) .push_slice("bar".as_bytes()) .push_opcode(opcodes::all::OP_ENDIF) .into_script(); assert_eq!( InscriptionParser::parse(&Witness::from_vec(vec![script.into_bytes(), vec![]])), - Ok(Inscription("foo".into())) + Ok(Inscription::Text("foo".into())) ); } @@ -233,6 +290,7 @@ mod tests { let script = script::Builder::new() .push_opcode(opcodes::OP_FALSE) .push_opcode(opcodes::all::OP_IF) + .push_slice("text/plain;charset=utf-8".as_bytes()) .push_slice(&[0b10000000]) .push_opcode(opcodes::all::OP_ENDIF) .into_script(); @@ -278,12 +336,13 @@ mod tests { .push_opcode(opcodes::all::OP_IF) .push_slice("ord".as_bytes()) .push_slice("ord".as_bytes()) + .push_slice("ord".as_bytes()) .push_opcode(opcodes::all::OP_ENDIF) .into_script(); assert_eq!( InscriptionParser::parse(&Witness::from_vec(vec![script.into_bytes(), vec![]])), - Err(InscriptionError::InvalidInscription) + Err(InscriptionError::InvalidInscription), ); } @@ -292,6 +351,7 @@ mod tests { let script = script::Builder::new() .push_opcode(opcodes::OP_FALSE) .push_opcode(opcodes::all::OP_IF) + .push_slice("text/plain;charset=utf-8".as_bytes()) .push_slice("ord".as_bytes()) .push_opcode(opcodes::all::OP_ENDIF) .into_script(); @@ -313,7 +373,7 @@ mod tests { assert_eq!( Inscription::from_transaction(&tx, &ranges), - Some((Ordinal(1), Inscription("ord".into()))) + Some((Ordinal(1), Inscription::Text("ord".into()))) ); } @@ -377,4 +437,20 @@ mod tests { assert_eq!(Inscription::from_transaction(&tx, &ranges), None,); } + + #[test] + fn inscribe_png() { + let script = script::Builder::new() + .push_opcode(opcodes::OP_FALSE) + .push_opcode(opcodes::all::OP_IF) + .push_slice("image/png".as_bytes()) + .push_slice(&[1; 100]) + .push_opcode(opcodes::all::OP_ENDIF) + .into_script(); + + assert_eq!( + InscriptionParser::parse(&Witness::from_vec(vec![script.into_bytes(), vec![]])), + Ok(Inscription::Png(vec![1; 100])) + ); + } } diff --git a/src/subcommand/server/templates/ordinal.rs b/src/subcommand/server/templates/ordinal.rs index b1d547ad50..ab991566e5 100644 --- a/src/subcommand/server/templates/ordinal.rs +++ b/src/subcommand/server/templates/ordinal.rs @@ -85,7 +85,7 @@ mod tests { OrdinalHtml { ordinal: Ordinal(0), blocktime: Blocktime::Confirmed(0), - inscription: Some(Inscription("HELLOWORLD".to_string())), + inscription: Some(Inscription::Text("HELLOWORLD".into())), } .to_string(), " @@ -117,8 +117,8 @@ mod tests { OrdinalHtml { ordinal: Ordinal(0), blocktime: Blocktime::Confirmed(0), - inscription: Some(Inscription( - "".to_string() + inscription: Some(Inscription::Text( + "".into() )), } .to_string(), diff --git a/src/subcommand/wallet/inscribe.rs b/src/subcommand/wallet/inscribe.rs index 332fbc38ff..04d75e36e9 100644 --- a/src/subcommand/wallet/inscribe.rs +++ b/src/subcommand/wallet/inscribe.rs @@ -14,14 +14,18 @@ use { #[derive(Debug, Parser)] pub(crate) struct Inscribe { + #[clap(long, help = "Inscribe ")] ordinal: Ordinal, - content: String, + #[clap(long, help = "Inscribe ordinal with contents of ")] + file: PathBuf, } impl Inscribe { pub(crate) fn run(self, options: Options) -> Result { let client = options.bitcoin_rpc_client_mainnet_forbidden("ord wallet inscribe")?; + let inscription = Inscription::from_file(self.file)?; + let index = Index::open(&options)?; index.update()?; @@ -33,7 +37,7 @@ impl Inscribe { let (unsigned_commit_tx, reveal_tx) = Inscribe::create_inscription_transactions( self.ordinal, - self.content.as_bytes(), + inscription, options.chain.network(), utxos, commit_tx_change, @@ -59,7 +63,7 @@ impl Inscribe { fn create_inscription_transactions( ordinal: Ordinal, - content: &[u8], + inscription: Inscription, network: bitcoin::Network, utxos: Vec<(OutPoint, Vec<(u64, u64)>)>, change: Vec
, @@ -74,7 +78,8 @@ impl Inscribe { .push_opcode(opcodes::all::OP_CHECKSIG) .push_opcode(opcodes::OP_FALSE) .push_opcode(opcodes::all::OP_IF) - .push_slice(content) + .push_slice(inscription.media_type().as_bytes()) + .push_slice(inscription.content()) .push_opcode(opcodes::all::OP_ENDIF) .into_script(); @@ -180,14 +185,14 @@ mod tests { #[test] fn reveal_transaction_pays_fee() { let utxos = vec![(outpoint(1), vec![(10_000, 15_000)])]; - let content = b"ord"; + let inscription = Inscription::Text("ord".into()); let ordinal = Ordinal(10_000); let commit_address = change(0); let reveal_address = recipient(); let (commit_tx, reveal_tx) = Inscribe::create_inscription_transactions( ordinal, - content, + inscription, bitcoin::Network::Signet, utxos, vec![commit_address, change(1)], @@ -206,14 +211,14 @@ mod tests { #[test] fn reveal_transaction_value_insufficient_to_pay_fee() { let utxos = vec![(outpoint(1), vec![(10_000, 11_000)])]; - let content = [b'a'; 5000]; let ordinal = Ordinal(10_000); + let inscription = Inscription::Png([1; 10_000].to_vec()); let commit_address = change(0); let reveal_address = recipient(); assert!(Inscribe::create_inscription_transactions( ordinal, - &content, + inscription, bitcoin::Network::Signet, utxos, vec![commit_address, change(1)], @@ -227,14 +232,14 @@ mod tests { #[test] fn reveal_transaction_would_create_dust() { let utxos = vec![(outpoint(1), vec![(10_000, 10_600)])]; - let content = [b'a'; 1]; + let inscription = Inscription::Text("ord".into()); let ordinal = Ordinal(10_000); let commit_address = change(0); let reveal_address = recipient(); let error = Inscribe::create_inscription_transactions( ordinal, - &content, + inscription, bitcoin::Network::Signet, utxos, vec![commit_address, change(1)], diff --git a/templates/ordinal.html b/templates/ordinal.html index 14c4f052ab..a2841cad51 100644 --- a/templates/ordinal.html +++ b/templates/ordinal.html @@ -11,8 +11,15 @@

Ordinal {{ self.ordinal.n() }}

offset
{{ self.ordinal.third() }}
rarity
{{ self.ordinal.rarity() }}
time
{{ self.blocktime }}
-%% if let Some(Inscription(inscription)) = &self.inscription { -
inscription
{{inscription}}
+%% if let Some(inscription) = &self.inscription { +%% match inscription { +%% Inscription::Text(content) => { +
inscription
{{ content }}
+%% }, +%% Inscription::Png(content) => { +
inscription
+%% }, +%% } %% } %% if self.ordinal.n() > 0 { diff --git a/tests/wallet.rs b/tests/wallet.rs index fa3ee6b6a5..4029855871 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -165,7 +165,8 @@ fn inscribe() { let rpc_server = test_bitcoincore_rpc::spawn_with(Network::Regtest, "ord"); rpc_server.mine_blocks(1); - CommandBuilder::new("--chain regtest wallet inscribe 5000000000 HELLOWORLD") + CommandBuilder::new("--chain regtest wallet inscribe --ordinal 5000000000 --file hello.txt") + .write("hello.txt", "HELLOWORLD") .rpc_server(&rpc_server) .stdout_regex("commit\t[[:xdigit:]]{64}\nreveal\t[[:xdigit:]]{64}\n") .run(); @@ -185,9 +186,56 @@ fn inscribe_forbidden_on_mainnet() { let rpc_server = test_bitcoincore_rpc::spawn_with(Network::Bitcoin, "ord"); rpc_server.mine_blocks(1); - CommandBuilder::new("wallet inscribe 5000000000 HELLOWORLD") + CommandBuilder::new("wallet inscribe --ordinal 5000000000 --file hello.txt") .rpc_server(&rpc_server) .expected_exit_code(1) .expected_stderr("error: `ord wallet inscribe` is unstable and not yet supported on mainnet.\n") .run(); } + +#[test] +fn inscribe_unknown_file_extension() { + let rpc_server = test_bitcoincore_rpc::spawn_with(Network::Regtest, "ord"); + rpc_server.mine_blocks(1); + + CommandBuilder::new("--chain regtest wallet inscribe --ordinal 5000000000 --file pepe.jpg") + .write("pepe.jpg", [1; 520]) + .rpc_server(&rpc_server) + .expected_exit_code(1) + .expected_stderr("error: unrecognized file extension `.jpg`, only .txt and .png accepted\n") + .run(); +} + +#[test] +fn inscribe_png() { + let rpc_server = test_bitcoincore_rpc::spawn_with(Network::Regtest, "ord"); + rpc_server.mine_blocks(1); + + CommandBuilder::new("--chain regtest wallet inscribe --ordinal 5000000000 --file degenerate.png") + .write("degenerate.png", [1; 520]) + .rpc_server(&rpc_server) + .stdout_regex("commit\t[[:xdigit:]]{64}\nreveal\t[[:xdigit:]]{64}\n") + .run(); + + rpc_server.mine_blocks(1); + + let ord_server = TestServer::spawn(&rpc_server); + + ord_server.assert_response_regex( + "/ordinal/5000000000", + ".*
inscription