diff --git a/src/index.rs b/src/index.rs index 5f920eb085..91fb5e6cb8 100644 --- a/src/index.rs +++ b/src/index.rs @@ -11,7 +11,7 @@ use { super::*, crate::{ subcommand::{find::FindRangeOutput, server::InscriptionQuery}, - templates::{RuneHtml, StatusHtml}, + templates::StatusHtml, }, bitcoin::block::Header, bitcoincore_rpc::{json::GetBlockHeaderResult, Client}, @@ -30,7 +30,7 @@ use { }, }; -pub(crate) use self::entry::RuneEntry; +pub use self::entry::RuneEntry; pub(crate) mod entry; mod fetcher; @@ -87,18 +87,18 @@ pub enum List { #[derive(Copy, Clone)] pub(crate) enum Statistic { Schema = 0, - BlessedInscriptions, - Commits, - CursedInscriptions, - IndexRunes, - IndexSats, - LostSats, - OutputsTraversed, - ReservedRunes, - Runes, - SatRanges, - UnboundInscriptions, - IndexTransactions, + BlessedInscriptions = 1, + Commits = 2, + CursedInscriptions = 3, + IndexRunes = 4, + IndexSats = 5, + LostSats = 6, + OutputsTraversed = 7, + ReservedRunes = 8, + Runes = 9, + SatRanges = 10, + UnboundInscriptions = 11, + IndexTransactions = 12, } impl Statistic { @@ -855,29 +855,10 @@ impl Index { ) } - pub(crate) fn rune(&self, rune: Rune) -> Result> { - let rtx = self.database.begin_read()?; - - let Some(id) = rtx - .open_table(RUNE_TO_RUNE_ID)? - .get(rune.0)? - .map(|guard| guard.value()) - else { - return Ok(None); - }; - - let entry = RuneEntry::load( - rtx - .open_table(RUNE_ID_TO_RUNE_ENTRY)? - .get(id)? - .unwrap() - .value(), - ); - - Ok(Some((RuneId::load(id), entry))) - } - - pub(crate) fn rune_html(&self, rune: Rune) -> Result> { + pub(crate) fn rune( + &self, + rune: Rune, + ) -> Result)>> { let rtx = self.database.begin_read()?; let Some(id) = rtx @@ -907,11 +888,7 @@ impl Index { .is_some() .then_some(parent); - Ok(Some(RuneHtml { - entry, - id: RuneId::load(id), - parent, - })) + Ok(Some((RuneId::load(id), entry, parent))) } pub(crate) fn runes(&self) -> Result> { @@ -5591,4 +5568,227 @@ mod tests { ); } } + + #[test] + fn pre_jubilee_first_reinscription_after_cursed_inscription_is_blessed() { + for context in Context::configurations() { + context.mine_blocks(1); + + // Before the jubilee, an inscription on a sat using a pushnum opcode is + // cursed and not vindicated. + + let script = script::Builder::new() + .push_opcode(opcodes::OP_FALSE) + .push_opcode(opcodes::all::OP_IF) + .push_slice(b"ord") + .push_slice([]) + .push_opcode(opcodes::all::OP_PUSHNUM_1) + .push_opcode(opcodes::all::OP_ENDIF) + .into_script(); + + let witness = Witness::from_slice(&[script.into_bytes(), Vec::new()]); + + let txid = context.rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[(1, 0, 0, witness)], + ..Default::default() + }); + + let inscription_id = InscriptionId { txid, index: 0 }; + + context.mine_blocks(1); + + let entry = context + .index + .get_inscription_entry(inscription_id) + .unwrap() + .unwrap(); + + assert!(Charm::charms(entry.charms) + .iter() + .any(|charm| *charm == Charm::Cursed)); + + assert!(!Charm::charms(entry.charms) + .iter() + .any(|charm| *charm == Charm::Vindicated)); + + let sat = entry.sat; + + assert_eq!(entry.inscription_number, -1); + + // Before the jubilee, reinscription on the same sat is not cursed and + // not vindicated. + + let inscription = Inscription::default(); + + let txid = context.rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[(2, 1, 0, inscription.to_witness())], + ..Default::default() + }); + + context.mine_blocks(1); + + let inscription_id = InscriptionId { txid, index: 0 }; + + let entry = context + .index + .get_inscription_entry(inscription_id) + .unwrap() + .unwrap(); + + assert_eq!(entry.inscription_number, 0); + + assert!(!Charm::charms(entry.charms) + .iter() + .any(|charm| *charm == Charm::Cursed)); + + assert!(!Charm::charms(entry.charms) + .iter() + .any(|charm| *charm == Charm::Vindicated)); + + assert_eq!(sat, entry.sat); + + // Before the jubilee, a third reinscription on the same sat is cursed + // and not vindicated. + + let inscription = Inscription::default(); + + let txid = context.rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[(3, 1, 0, inscription.to_witness())], + ..Default::default() + }); + + context.mine_blocks(1); + + let inscription_id = InscriptionId { txid, index: 0 }; + + let entry = context + .index + .get_inscription_entry(inscription_id) + .unwrap() + .unwrap(); + + assert!(Charm::charms(entry.charms) + .iter() + .any(|charm| *charm == Charm::Cursed)); + + assert!(!Charm::charms(entry.charms) + .iter() + .any(|charm| *charm == Charm::Vindicated)); + + assert_eq!(entry.inscription_number, -2); + + assert_eq!(sat, entry.sat); + } + } + + #[test] + fn post_jubilee_first_reinscription_after_vindicated_inscription_not_vindicated() { + for context in Context::configurations() { + context.mine_blocks(110); + // After the jubilee, an inscription on a sat using a pushnum opcode is + // vindicated and not cursed. + + let script = script::Builder::new() + .push_opcode(opcodes::OP_FALSE) + .push_opcode(opcodes::all::OP_IF) + .push_slice(b"ord") + .push_slice([]) + .push_opcode(opcodes::all::OP_PUSHNUM_1) + .push_opcode(opcodes::all::OP_ENDIF) + .into_script(); + + let witness = Witness::from_slice(&[script.into_bytes(), Vec::new()]); + + let txid = context.rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[(1, 0, 0, witness)], + ..Default::default() + }); + + let inscription_id = InscriptionId { txid, index: 0 }; + + context.mine_blocks(1); + + let entry = context + .index + .get_inscription_entry(inscription_id) + .unwrap() + .unwrap(); + + assert!(!Charm::charms(entry.charms) + .iter() + .any(|charm| *charm == Charm::Cursed)); + + assert!(Charm::charms(entry.charms) + .iter() + .any(|charm| *charm == Charm::Vindicated)); + + let sat = entry.sat; + + assert_eq!(entry.inscription_number, 0); + + // After the jubilee, a reinscription on the same is not cursed and not + // vindicated. + + let inscription = Inscription::default(); + + let txid = context.rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[(111, 1, 0, inscription.to_witness())], + ..Default::default() + }); + + context.mine_blocks(1); + + let inscription_id = InscriptionId { txid, index: 0 }; + + let entry = context + .index + .get_inscription_entry(inscription_id) + .unwrap() + .unwrap(); + + assert!(!Charm::charms(entry.charms) + .iter() + .any(|charm| *charm == Charm::Cursed)); + + assert!(!Charm::charms(entry.charms) + .iter() + .any(|charm| *charm == Charm::Vindicated)); + + assert_eq!(entry.inscription_number, 1); + + assert_eq!(sat, entry.sat); + + // After the jubilee, a third reinscription on the same is vindicated and + // not cursed. + + let inscription = Inscription::default(); + + let txid = context.rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[(112, 1, 0, inscription.to_witness())], + ..Default::default() + }); + + context.mine_blocks(1); + + let inscription_id = InscriptionId { txid, index: 0 }; + + let entry = context + .index + .get_inscription_entry(inscription_id) + .unwrap() + .unwrap(); + + assert!(!Charm::charms(entry.charms) + .iter() + .any(|charm| *charm == Charm::Cursed)); + + assert!(Charm::charms(entry.charms) + .iter() + .any(|charm| *charm == Charm::Vindicated)); + + assert_eq!(entry.inscription_number, 2); + + assert_eq!(sat, entry.sat); + } + } } diff --git a/src/index/entry.rs b/src/index/entry.rs index e5b1a9976c..116ee448ea 100644 --- a/src/index/entry.rs +++ b/src/index/entry.rs @@ -28,21 +28,21 @@ impl Entry for Header { } } -#[derive(Debug, PartialEq, Copy, Clone)] -pub(crate) struct RuneEntry { - pub(crate) burned: u128, - pub(crate) deadline: Option, - pub(crate) divisibility: u8, - pub(crate) end: Option, - pub(crate) etching: Txid, - pub(crate) limit: Option, - pub(crate) mints: u64, - pub(crate) number: u64, - pub(crate) rune: Rune, - pub(crate) spacers: u32, - pub(crate) supply: u128, - pub(crate) symbol: Option, - pub(crate) timestamp: u32, +#[derive(Debug, PartialEq, Copy, Clone, Serialize, Deserialize)] +pub struct RuneEntry { + pub burned: u128, + pub deadline: Option, + pub divisibility: u8, + pub end: Option, + pub etching: Txid, + pub limit: Option, + pub mints: u64, + pub number: u64, + pub rune: Rune, + pub spacers: u32, + pub supply: u128, + pub symbol: Option, + pub timestamp: u32, } pub(super) type RuneEntryValue = ( diff --git a/src/index/updater/inscription_updater.rs b/src/index/updater/inscription_updater.rs index a491950073..19f99033cc 100644 --- a/src/index/updater/inscription_updater.rs +++ b/src/index/updater/inscription_updater.rs @@ -30,6 +30,7 @@ enum Origin { pointer: Option, reinscription: bool, unbound: bool, + vindicated: bool, }, Old { old_satpoint: SatPoint, @@ -76,6 +77,7 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { let mut floating_inscriptions = Vec::new(); let mut id_counter = 0; let mut inscribed_offsets = BTreeMap::new(); + let jubilant = self.height >= self.chain.jubilee_height(); let mut total_input_value = 0; let total_output_value = tx.output.iter().map(|txout| txout.value).sum::(); @@ -142,9 +144,7 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { index: id_counter, }; - let curse = if self.height >= self.chain.jubilee_height() { - None - } else if inscription.payload.unrecognized_even_field { + let curse = if inscription.payload.unrecognized_even_field { Some(Curse::UnrecognizedEvenField) } else if inscription.payload.duplicate_field { Some(Curse::DuplicateField) @@ -167,17 +167,18 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { let initial_inscription_sequence_number = self.id_to_sequence_number.get(id.store())?.unwrap().value(); - let initial_inscription_is_cursed = InscriptionEntry::load( + let entry = InscriptionEntry::load( self .sequence_number_to_entry .get(initial_inscription_sequence_number)? .unwrap() .value(), - ) - .inscription_number - < 0; + ); + + let initial_inscription_was_cursed_or_vindicated = + entry.inscription_number < 0 || Charm::Vindicated.is_set(entry.charms); - if initial_inscription_is_cursed { + if initial_inscription_was_cursed_or_vindicated { None } else { Some(Curse::Reinscription) @@ -201,13 +202,14 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { inscription_id, offset, origin: Origin::New { - reinscription: inscribed_offsets.get(&offset).is_some(), - cursed: curse.is_some(), + cursed: curse.is_some() && !jubilant, fee: 0, hidden: inscription.payload.hidden(), parent: inscription.payload.parent(), pointer: inscription.payload.pointer(), + reinscription: inscribed_offsets.get(&offset).is_some(), unbound, + vindicated: curse.is_some() && jubilant, }, }); @@ -404,6 +406,7 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { pointer: _, reinscription, unbound, + vindicated, } => { let inscription_number = if cursed { let number: i32 = self.cursed_inscription_count.try_into().unwrap(); @@ -467,6 +470,10 @@ impl<'a, 'db, 'tx> InscriptionUpdater<'a, 'db, 'tx> { Charm::Unbound.set(&mut charms); } + if vindicated { + Charm::Vindicated.set(&mut charms); + } + if let Some(Sat(n)) = sat { self.sat_to_sequence_number.insert(&n, &sequence_number)?; } diff --git a/src/inscriptions/charm.rs b/src/inscriptions/charm.rs index b80c5c6616..d0770886d7 100644 --- a/src/inscriptions/charm.rs +++ b/src/inscriptions/charm.rs @@ -1,19 +1,20 @@ -#[derive(Copy, Clone)] +#[derive(Copy, Clone, Debug, PartialEq)] pub(crate) enum Charm { - Coin, - Cursed, - Epic, - Legendary, - Lost, - Nineball, - Rare, - Reinscription, - Unbound, - Uncommon, + Coin = 0, + Cursed = 1, + Epic = 2, + Legendary = 3, + Lost = 4, + Nineball = 5, + Rare = 6, + Reinscription = 7, + Unbound = 8, + Uncommon = 9, + Vindicated = 10, } impl Charm { - pub(crate) const ALL: [Charm; 10] = [ + pub(crate) const ALL: [Charm; 11] = [ Self::Coin, Self::Uncommon, Self::Rare, @@ -24,6 +25,7 @@ impl Charm { Self::Cursed, Self::Unbound, Self::Lost, + Self::Vindicated, ]; fn flag(self) -> u16 { @@ -50,6 +52,7 @@ impl Charm { Self::Reinscription => "♻️", Self::Unbound => "🔓", Self::Uncommon => "🌱", + Self::Vindicated => "❤️‍🔥", } } @@ -65,6 +68,16 @@ impl Charm { Self::Reinscription => "reinscription", Self::Unbound => "unbound", Self::Uncommon => "uncommon", + Self::Vindicated => "vindicated", } } + + #[cfg(test)] + pub(crate) fn charms(charms: u16) -> Vec { + Self::ALL + .iter() + .filter(|charm| charm.is_set(charms)) + .cloned() + .collect() + } } diff --git a/src/lib.rs b/src/lib.rs index 55b2379d9d..9102594701 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,7 +21,7 @@ use { deserialize_from_str::DeserializeFromStr, epoch::Epoch, height::Height, - index::{List, RuneEntry}, + index::List, inscriptions::{media, teleburn, Charm, Media, ParsedEnvelope}, outgoing::Outgoing, representation::Representation, @@ -85,7 +85,7 @@ use { pub use self::{ chain::Chain, fee_rate::FeeRate, - index::Index, + index::{Index, RuneEntry}, inscriptions::{Envelope, Inscription, InscriptionId}, object::Object, options::Options, diff --git a/src/subcommand/server.rs b/src/subcommand/server.rs index d767ba53d6..d1e60c57e5 100644 --- a/src/subcommand/server.rs +++ b/src/subcommand/server.rs @@ -14,8 +14,8 @@ use { InscriptionsHtml, InscriptionsJson, OutputHtml, OutputJson, PageContent, PageHtml, PreviewAudioHtml, PreviewCodeHtml, PreviewFontHtml, PreviewImageHtml, PreviewMarkdownHtml, PreviewModelHtml, PreviewPdfHtml, PreviewTextHtml, PreviewUnknownHtml, PreviewVideoHtml, - RangeHtml, RareTxt, RuneHtml, RunesHtml, SatHtml, SatInscriptionJson, SatInscriptionsJson, - SatJson, TransactionHtml, + RangeHtml, RareTxt, RuneHtml, RuneJson, RunesHtml, RunesJson, SatHtml, SatInscriptionJson, + SatInscriptionsJson, SatJson, TransactionHtml, }, }, axum::{ @@ -170,6 +170,8 @@ pub(crate) struct Server { help = "Decompress encoded content. Currently only supports brotli. Be careful using this on production instances. A decompressed inscription may be arbitrarily large, making decompression a DoS vector." )] pub(crate) decompress: bool, + #[arg(long, alias = "nosync", help = "Do not update the index.")] + no_sync: bool, } impl Server { @@ -181,8 +183,10 @@ impl Server { if SHUTTING_DOWN.load(atomic::Ordering::Relaxed) { break; } - if let Err(error) = index_clone.update() { - log::warn!("Updating index: {error}"); + if !self.no_sync { + if let Err(error) = index_clone.update() { + log::warn!("Updating index: {error}"); + } } thread::sleep(Duration::from_millis(5000)); }); @@ -617,31 +621,44 @@ impl Server { Extension(server_config): Extension>, Extension(index): Extension>, Path(DeserializeFromStr(spaced_rune)): Path>, - ) -> ServerResult> { + AcceptJson(accept_json): AcceptJson, + ) -> ServerResult { if !index.has_rune_index() { return Err(ServerError::NotFound( "this server has no rune index".to_string(), )); } - Ok( - index - .rune_html(spaced_rune.rune)? - .ok_or_not_found(|| format!("rune {spaced_rune}"))? - .page(server_config), - ) + let (id, entry, parent) = index + .rune(spaced_rune.rune)? + .ok_or_not_found(|| format!("rune {spaced_rune}"))?; + + Ok(if accept_json { + Json(RuneJson { entry, id, parent }).into_response() + } else { + RuneHtml { entry, id, parent } + .page(server_config) + .into_response() + }) } async fn runes( Extension(server_config): Extension>, Extension(index): Extension>, - ) -> ServerResult> { - Ok( + AcceptJson(accept_json): AcceptJson, + ) -> ServerResult { + Ok(if accept_json { + Json(RunesJson { + entries: index.runes()?, + }) + .into_response() + } else { RunesHtml { entries: index.runes()?, } - .page(server_config), - ) + .page(server_config) + .into_response() + }) } async fn home( @@ -1224,6 +1241,11 @@ impl Server { Ok(if accept_json { Json(InscriptionJson { inscription_id: info.entry.id, + charms: Charm::ALL + .iter() + .filter(|charm| charm.is_set(info.charms)) + .map(|charm| charm.title().into()) + .collect(), children: info.children, inscription_number: info.entry.inscription_number, genesis_height: info.entry.height, @@ -4366,6 +4388,45 @@ next ); } + #[test] + fn charm_vindicated() { + let server = TestServer::new_with_regtest(); + + server.mine_blocks(110); + + let txid = server.bitcoin_rpc_server.broadcast_tx(TransactionTemplate { + inputs: &[ + (1, 0, 0, Witness::default()), + (2, 0, 0, inscription("text/plain", "cursed").to_witness()), + ], + outputs: 2, + ..Default::default() + }); + + let id = InscriptionId { txid, index: 0 }; + + server.mine_blocks(1); + + server.assert_response_regex( + format!("/inscription/{id}"), + StatusCode::OK, + format!( + ".*

Inscription 0

.* +
+
id
+
{id}
+
charms
+
+ ❤️‍🔥 +
+ .* +
+.* +" + ), + ); + } + #[test] fn charm_coin() { let server = TestServer::new_with_regtest_with_index_sats(); diff --git a/src/subcommand/wallet/etch.rs b/src/subcommand/wallet/etch.rs index b02ea68015..1c00a36370 100644 --- a/src/subcommand/wallet/etch.rs +++ b/src/subcommand/wallet/etch.rs @@ -16,6 +16,7 @@ pub(crate) struct Etch { #[derive(Serialize, Deserialize, Debug)] pub struct Output { + pub rune: SpacedRune, pub transaction: Txid, } @@ -123,6 +124,9 @@ impl Etch { let transaction = client.send_raw_transaction(&signed_transaction)?; - Ok(Box::new(Output { transaction })) + Ok(Box::new(Output { + rune: self.rune, + transaction, + })) } } diff --git a/src/subcommand/wallet/inscribe.rs b/src/subcommand/wallet/inscribe.rs index 71dc9b039a..a27f88e438 100644 --- a/src/subcommand/wallet/inscribe.rs +++ b/src/subcommand/wallet/inscribe.rs @@ -85,10 +85,11 @@ pub(crate) struct Inscribe { pub(crate) json_metadata: Option, #[clap(long, help = "Set inscription metaprotocol to .")] pub(crate) metaprotocol: Option, - #[arg(long, help = "Do not back up recovery key.")] + #[arg(long, alias = "nobackup", help = "Do not back up recovery key.")] pub(crate) no_backup: bool, #[arg( long, + alias = "nolimit", help = "Do not check that transactions are equal to or below the MAX_STANDARD_TX_WEIGHT of 400,000 weight units. Transactions over this limit are currently nonstandard and will not be relayed by bitcoind in its default configuration. Do not use this flag unless you understand the implications." )] pub(crate) no_limit: bool, diff --git a/src/subcommand/wallet/send.rs b/src/subcommand/wallet/send.rs index 24575ceac2..bf1d98b0c4 100644 --- a/src/subcommand/wallet/send.rs +++ b/src/subcommand/wallet/send.rs @@ -182,7 +182,7 @@ impl Send { Self::lock_non_cardinal_outputs(client, &inscriptions, &runic_outputs, unspent_outputs)?; - let (id, entry) = index + let (id, entry, _parent) = index .rune(spaced_rune.rune)? .with_context(|| format!("rune `{}` has not been etched", spaced_rune.rune))?; diff --git a/src/templates.rs b/src/templates.rs index 4a53f94836..fc9385d7b7 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -20,8 +20,8 @@ pub(crate) use { }, range::RangeHtml, rare::RareTxt, - rune::RuneHtml, - runes::RunesHtml, + rune::{RuneHtml, RuneJson}, + runes::{RunesHtml, RunesJson}, sat::{SatHtml, SatInscriptionJson, SatInscriptionsJson, SatJson}, server_config::ServerConfig, status::StatusHtml, @@ -44,8 +44,8 @@ pub mod output; mod preview; mod range; mod rare; -mod rune; -mod runes; +pub mod rune; +pub mod runes; pub mod sat; pub mod status; mod transaction; diff --git a/src/templates/inscription.rs b/src/templates/inscription.rs index 4faff866ab..2470424a35 100644 --- a/src/templates/inscription.rs +++ b/src/templates/inscription.rs @@ -23,6 +23,7 @@ pub(crate) struct InscriptionHtml { #[derive(Debug, PartialEq, Serialize, Deserialize)] pub struct InscriptionJson { pub address: Option, + pub charms: Vec, pub children: Vec, pub content_length: Option, pub content_type: Option, diff --git a/src/templates/rune.rs b/src/templates/rune.rs index 76ed61dbc0..78a688a66a 100644 --- a/src/templates/rune.rs +++ b/src/templates/rune.rs @@ -1,10 +1,12 @@ use super::*; -#[derive(Boilerplate)] -pub(crate) struct RuneHtml { - pub(crate) entry: RuneEntry, - pub(crate) id: RuneId, - pub(crate) parent: Option, +pub type RuneJson = RuneHtml; + +#[derive(Boilerplate, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuneHtml { + pub entry: RuneEntry, + pub id: RuneId, + pub parent: Option, } impl PageContent for RuneHtml { diff --git a/src/templates/runes.rs b/src/templates/runes.rs index 976714ab2f..ace35fca32 100644 --- a/src/templates/runes.rs +++ b/src/templates/runes.rs @@ -1,8 +1,10 @@ use super::*; -#[derive(Boilerplate)] -pub(crate) struct RunesHtml { - pub(crate) entries: Vec<(RuneId, RuneEntry)>, +pub type RunesJson = RunesHtml; + +#[derive(Boilerplate, Debug, PartialEq, Serialize, Deserialize)] +pub struct RunesHtml { + pub entries: Vec<(RuneId, RuneEntry)>, } impl PageContent for RunesHtml { diff --git a/tests/command_builder.rs b/tests/command_builder.rs index 5cc9171c8e..01fc621fe2 100644 --- a/tests/command_builder.rs +++ b/tests/command_builder.rs @@ -36,7 +36,7 @@ pub(crate) struct CommandBuilder { rpc_server_cookie_file: Option, rpc_server_url: Option, stdin: Vec, - tempdir: TempDir, + tempdir: Arc, } impl CommandBuilder { @@ -49,7 +49,7 @@ impl CommandBuilder { rpc_server_cookie_file: None, rpc_server_url: None, stdin: Vec::new(), - tempdir: TempDir::new().unwrap(), + tempdir: Arc::new(TempDir::new().unwrap()), } } @@ -98,7 +98,7 @@ impl CommandBuilder { } } - pub(crate) fn temp_dir(self, tempdir: TempDir) -> Self { + pub(crate) fn temp_dir(self, tempdir: Arc) -> Self { Self { tempdir, ..self } } @@ -124,7 +124,7 @@ impl CommandBuilder { .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) - .current_dir(&self.tempdir) + .current_dir(&*self.tempdir) .arg("--data-dir") .arg(self.tempdir.path()) .args(&self.args); @@ -134,7 +134,8 @@ impl CommandBuilder { #[track_caller] fn run(self) -> (TempDir, String) { - let child = self.command().spawn().unwrap(); + let mut command = self.command(); + let child = command.spawn().unwrap(); child .stdin @@ -157,7 +158,7 @@ impl CommandBuilder { self.expected_stderr.assert_match(stderr); self.expected_stdout.assert_match(stdout); - (self.tempdir, stdout.into()) + (Arc::try_unwrap(self.tempdir).unwrap(), stdout.into()) } pub(crate) fn run_and_extract_file(self, path: impl AsRef) -> String { diff --git a/tests/index.rs b/tests/index.rs index 143f260af4..72e3a20ecf 100644 --- a/tests/index.rs +++ b/tests/index.rs @@ -95,7 +95,7 @@ fn export_inscription_number_to_id_tsv() { let tsv = CommandBuilder::new("index export --tsv foo.tsv") .rpc_server(&rpc_server) - .temp_dir(temp_dir) + .temp_dir(Arc::new(temp_dir)) .stdout_regex(r"\{\}\n") .run_and_extract_file("foo.tsv"); diff --git a/tests/json_api.rs b/tests/json_api.rs index 2be51014f4..fc0d471a19 100644 --- a/tests/json_api.rs +++ b/tests/json_api.rs @@ -147,22 +147,23 @@ fn get_inscription() { pretty_assert_eq!( inscription_json, InscriptionJson { - parent: None, + address: None, + charms: vec!["coin".into(), "uncommon".into()], children: Vec::new(), + content_length: Some(3), + content_type: Some("text/plain;charset=utf-8".to_string()), + genesis_fee: 138, + genesis_height: 2, inscription_id, inscription_number: 0, - genesis_height: 2, - genesis_fee: 138, + next: None, output_value: Some(10000), - address: None, + parent: None, + previous: None, + rune: None, sat: Some(ord::Sat(50 * COIN_VALUE)), satpoint: SatPoint::from_str(&format!("{}:{}:{}", reveal, 0, 0)).unwrap(), - content_type: Some("text/plain;charset=utf-8".to_string()), - content_length: Some(3), timestamp: 2, - previous: None, - next: None, - rune: None, } ) } @@ -368,15 +369,21 @@ fn get_block() { #[test] fn get_status() { - let rpc_server = test_bitcoincore_rpc::spawn(); + let rpc_server = test_bitcoincore_rpc::builder() + .network(Network::Regtest) + .build(); create_wallet(&rpc_server); + rpc_server.mine_blocks(1); inscribe(&rpc_server); - let response = - TestServer::spawn_with_server_args(&rpc_server, &["--index-sats"], &["--enable-json-api"]) - .json_request("/status"); + let response = TestServer::spawn_with_server_args( + &rpc_server, + &["--regtest", "--index-sats", "--index-runes"], + &["--enable-json-api"], + ) + .json_request("/status"); assert_eq!(response.status(), StatusCode::OK); @@ -396,12 +403,12 @@ fn get_status() { StatusHtml { blessed_inscriptions: 1, cursed_inscriptions: 0, - chain: Chain::Mainnet, - height: Some(2), + chain: Chain::Regtest, + height: Some(3), inscriptions: 1, lost_sats: 0, - minimum_rune_for_next_block: Rune(99246114928149462), - rune_index: false, + minimum_rune_for_next_block: Rune(99218849511960410), + rune_index: true, runes: 0, sat_index: true, started: dummy_started, @@ -411,3 +418,133 @@ fn get_status() { } ); } + +#[test] +fn get_runes() { + let rpc_server = test_bitcoincore_rpc::builder() + .network(Network::Regtest) + .build(); + + create_wallet(&rpc_server); + rpc_server.mine_blocks(3); + + let a = etch(&rpc_server, Rune(RUNE)); + let b = etch(&rpc_server, Rune(RUNE + 1)); + let c = etch(&rpc_server, Rune(RUNE + 2)); + + rpc_server.mine_blocks(1); + + let server = TestServer::spawn_with_server_args( + &rpc_server, + &["--index-runes", "--regtest"], + &["--enable-json-api"], + ); + + let response = server.json_request(format!("/rune/{}", a.rune)); + assert_eq!(response.status(), StatusCode::OK); + + let rune_json: RuneJson = serde_json::from_str(&response.text().unwrap()).unwrap(); + + pretty_assert_eq!( + rune_json, + RuneJson { + entry: RuneEntry { + burned: 0, + deadline: None, + divisibility: 0, + end: None, + etching: a.transaction, + limit: None, + mints: 0, + number: 0, + rune: Rune(RUNE), + spacers: 0, + supply: 1000, + symbol: Some('¢'), + timestamp: 5, + }, + id: RuneId { + height: 5, + index: 1 + }, + parent: None, + } + ); + + let response = server.json_request("/runes"); + + assert_eq!(response.status(), StatusCode::OK); + + let runes_json: RunesJson = serde_json::from_str(&response.text().unwrap()).unwrap(); + + pretty_assert_eq!( + runes_json, + RunesJson { + entries: vec![ + ( + RuneId { + height: 5, + index: 1 + }, + RuneEntry { + burned: 0, + deadline: None, + divisibility: 0, + end: None, + etching: a.transaction, + limit: None, + mints: 0, + number: 0, + rune: Rune(RUNE), + spacers: 0, + supply: 1000, + symbol: Some('¢'), + timestamp: 5, + } + ), + ( + RuneId { + height: 7, + index: 1 + }, + RuneEntry { + burned: 0, + deadline: None, + divisibility: 0, + end: None, + etching: b.transaction, + limit: None, + mints: 0, + number: 1, + rune: Rune(RUNE + 1), + spacers: 0, + supply: 1000, + symbol: Some('¢'), + timestamp: 7, + } + ), + ( + RuneId { + height: 9, + index: 1 + }, + RuneEntry { + burned: 0, + deadline: None, + divisibility: 0, + end: None, + etching: c.transaction, + limit: None, + mints: 0, + number: 2, + rune: Rune(RUNE + 2), + spacers: 0, + supply: 1000, + symbol: Some('¢'), + timestamp: 9, + } + ) + ] + } + ); +} diff --git a/tests/lib.rs b/tests/lib.rs index 0942ad1acf..5037851cca 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -15,14 +15,15 @@ use { subcommand::runes::RuneInfo, templates::{ block::BlockJson, inscription::InscriptionJson, inscriptions::InscriptionsJson, - output::OutputJson, sat::SatJson, status::StatusHtml, + output::OutputJson, rune::RuneJson, runes::RunesJson, sat::SatJson, status::StatusHtml, }, - Edict, InscriptionId, Rune, RuneId, Runestone, SatPoint, + Edict, InscriptionId, Rune, RuneEntry, RuneId, Runestone, SatPoint, }, pretty_assertions::assert_eq as pretty_assert_eq, regex::Regex, reqwest::{StatusCode, Url}, serde::de::DeserializeOwned, + std::sync::Arc, std::{ collections::BTreeMap, fs, @@ -108,10 +109,13 @@ fn runes(rpc_server: &test_bitcoincore_rpc::Handle) -> BTreeMap fn inscribe(rpc_server: &test_bitcoincore_rpc::Handle) -> (InscriptionId, Txid) { rpc_server.mine_blocks(1); - let output = CommandBuilder::new("wallet inscribe --fee-rate 1 --file foo.txt") - .write("foo.txt", "FOO") - .rpc_server(rpc_server) - .run_and_deserialize_output::(); + let output = CommandBuilder::new(format!( + "--chain {} wallet inscribe --fee-rate 1 --file foo.txt", + rpc_server.network() + )) + .write("foo.txt", "FOO") + .rpc_server(rpc_server) + .run_and_deserialize_output::(); rpc_server.mine_blocks(1); diff --git a/tests/server.rs b/tests/server.rs index 71edfe1103..c7792fb279 100644 --- a/tests/server.rs +++ b/tests/server.rs @@ -492,3 +492,72 @@ fn inscription_transactions_are_stored_with_transaction_index() { StatusCode::NOT_FOUND, ); } + +#[test] +fn run_no_sync() { + let rpc_server = test_bitcoincore_rpc::spawn(); + + let port = TcpListener::bind("127.0.0.1:0") + .unwrap() + .local_addr() + .unwrap() + .port(); + + let tempdir = Arc::new(TempDir::new().unwrap()); + + let builder = CommandBuilder::new(format!("server --address 127.0.0.1 --http-port {port}",)) + .rpc_server(&rpc_server) + .temp_dir(tempdir.clone()); + + let mut command = builder.command(); + + let mut child = command.spawn().unwrap(); + + rpc_server.mine_blocks(1); + + for attempt in 0.. { + if let Ok(response) = reqwest::blocking::get(format!("http://localhost:{port}/blockheight")) { + if response.status() == 200 { + assert_eq!(response.text().unwrap(), "1"); + break; + } + } + + if attempt == 100 { + panic!("Server did not respond to status check",); + } + + thread::sleep(Duration::from_millis(50)); + } + + child.kill().unwrap(); + + let builder = CommandBuilder::new(format!( + "server --no-sync --address 127.0.0.1 --http-port {port}", + )) + .rpc_server(&rpc_server) + .temp_dir(tempdir); + + let mut command = builder.command(); + + let mut child = command.spawn().unwrap(); + + rpc_server.mine_blocks(2); + + for attempt in 0.. { + if let Ok(response) = reqwest::blocking::get(format!("http://localhost:{port}/blockheight")) { + if response.status() == 200 { + assert_eq!(response.text().unwrap(), "1"); + break; + } + } + + if attempt == 100 { + panic!("Server did not respond to status check",); + } + + thread::sleep(Duration::from_millis(50)); + } + + child.kill().unwrap(); +} diff --git a/tests/test_server.rs b/tests/test_server.rs index 87a7f794cc..7541264411 100644 --- a/tests/test_server.rs +++ b/tests/test_server.rs @@ -27,7 +27,17 @@ impl TestServer { server_args: &[&str], ) -> Self { let tempdir = TempDir::new().unwrap(); - fs::write(tempdir.path().join(".cookie"), "foo:bar").unwrap(); + + let cookie_file = match rpc_server.network().as_str() { + "mainnet" => tempdir.path().join(".cookie"), + network => { + fs::create_dir(tempdir.path().join(network)).unwrap(); + tempdir.path().join(format!("{network}/.cookie")) + } + }; + + fs::write(cookie_file.clone(), "foo:bar").unwrap(); + let port = TcpListener::bind("127.0.0.1:0") .unwrap() .local_addr()