diff --git a/crates/ordinals/src/rune.rs b/crates/ordinals/src/rune.rs index 90e26865cf..b6f9a7b090 100644 --- a/crates/ordinals/src/rune.rs +++ b/crates/ordinals/src/rune.rs @@ -6,8 +6,8 @@ use super::*; pub struct Rune(pub u128); impl Rune { - const RESERVED: u128 = 6402364363415443603228541259936211926; - + pub const RESERVED: u128 = 6402364363415443603228541259936211926; + const INTERVAL: u32 = SUBSIDY_HALVING_INTERVAL / 12; const STEPS: &'static [u128] = &[ 0, 26, @@ -57,8 +57,6 @@ impl Rune { pub fn minimum_at_height(chain: Network, height: Height) -> Self { let offset = height.0.saturating_add(1); - const INTERVAL: u32 = SUBSIDY_HALVING_INTERVAL / 12; - let start = Self::first_rune_height(chain); let end = start + SUBSIDY_HALVING_INTERVAL; @@ -68,20 +66,53 @@ impl Rune { } if offset >= end { - return Rune(0); + return Rune(Self::STEPS[0]); } let progress = offset.saturating_sub(start); - let length = 12u32.saturating_sub(progress / INTERVAL); + let length = 12u32.saturating_sub(progress / Self::INTERVAL); let end = Self::STEPS[usize::try_from(length - 1).unwrap()]; let start = Self::STEPS[usize::try_from(length).unwrap()]; - let remainder = u128::from(progress % INTERVAL); + let remainder = u128::from(progress % Self::INTERVAL); + + Rune(start - ((start - end) * remainder / u128::from(Self::INTERVAL))) + } + + pub fn unlock_height(&self, chain: Network) -> Option { + if self.is_reserved() { + return None; + } + + let mut min = 0; + let mut max = 26; + let mut step: usize = 11; // 0,1,2,3,4,5,6,7,8,9,10,11 (12 steps) + + for (i, val) in Self::STEPS.windows(2).enumerate() { + if self.n() == 0 { + break; + } + + step = step.saturating_sub(i); + + if self.n() <= val[1] && self.n() > val[0] { + min = val[0]; + max = val[1]; + break; + } + } + + let step_height = + Self::first_rune_height(chain) + (u32::try_from(step).unwrap() * Self::INTERVAL); + + let step_progress = (max - self.n()) as f64 / (max - min) as f64; + + let height = step_height + (step_progress * Self::INTERVAL as f64) as u32; - Rune(start - ((start - end) * remainder / u128::from(INTERVAL))) + Some(height) } pub fn is_reserved(self) -> bool { @@ -420,4 +451,41 @@ mod tests { case(65536, &[0, 0, 1]); case(u128::MAX, &[255; 16]); } + + #[test] + #[allow(clippy::identity_op)] + #[allow(clippy::erasing_op)] + #[allow(clippy::zero_prefixed_literal)] + fn unlock_height() { + #[track_caller] + fn case(rune: &str, height: u32) { + assert_eq!( + rune + .parse::() + .unwrap() + .unlock_height(Network::Bitcoin), + Some(height) + ); + } + const START: u32 = SUBSIDY_HALVING_INTERVAL * 4; + const END: u32 = START + SUBSIDY_HALVING_INTERVAL; + const INTERVAL: u32 = SUBSIDY_HALVING_INTERVAL / 12; + + case("ZZYZXBRKWXVA", START); + case("ZZZZZZZZZZZZ", START); + case("AAAAAAAAAAAAA", START); + case("ZZXZUDIVTVQA", START + 1); + + case("A", END - 0 * INTERVAL - 0 * (INTERVAL / 26)); + case("B", END - 0 * INTERVAL - 1 * (INTERVAL / 26) - 1); + case("C", END - 0 * INTERVAL - 2 * (INTERVAL / 26) - 1); + case("D", END - 0 * INTERVAL - 3 * (INTERVAL / 26) - 1); + + case("AA", END - 1 * INTERVAL - 0 * (INTERVAL / 26)); + case("BA", END - 1 * INTERVAL - 1 * (INTERVAL / 26) - 1); + case("CA", END - 1 * INTERVAL - 2 * (INTERVAL / 26) - 1); + case("DA", END - 1 * INTERVAL - 3 * (INTERVAL / 26) - 1); + + case("AAA", END - 2 * INTERVAL - 0 * (INTERVAL / 26)); + } } diff --git a/src/subcommand/server.rs b/src/subcommand/server.rs index 3af10b2352..3d13a8f612 100644 --- a/src/subcommand/server.rs +++ b/src/subcommand/server.rs @@ -10,7 +10,8 @@ use { InputHtml, InscriptionHtml, InscriptionsBlockHtml, InscriptionsHtml, OutputHtml, PageContent, PageHtml, ParentsHtml, PreviewAudioHtml, PreviewCodeHtml, PreviewFontHtml, PreviewImageHtml, PreviewMarkdownHtml, PreviewModelHtml, PreviewPdfHtml, PreviewTextHtml, PreviewUnknownHtml, - PreviewVideoHtml, RangeHtml, RareTxt, RuneHtml, RunesHtml, SatHtml, TransactionHtml, + PreviewVideoHtml, RangeHtml, RareTxt, RuneHtml, RuneNotFoundHtml, RunesHtml, SatHtml, + TransactionHtml, }, axum::{ body, @@ -693,32 +694,55 @@ impl Server { .ok_or_not_found(|| format!("rune number {number}"))?, }; - let (id, entry, parent) = index - .rune(rune)? - .ok_or_not_found(|| format!("rune {rune}"))?; + if let Some((id, entry, parent)) = index.rune(rune)? { + let block_height = index.block_height()?.unwrap_or(Height(0)); - let block_height = index.block_height()?.unwrap_or(Height(0)); + let mintable = entry.mintable((block_height.n() + 1).into()).is_ok(); - let mintable = entry.mintable((block_height.n() + 1).into()).is_ok(); - - Ok(if accept_json { - Json(api::Rune { - entry, - id, - mintable, - parent, + Ok(if accept_json { + Json(api::Rune { + entry, + id, + mintable, + parent, + }) + .into_response() + } else { + RuneHtml { + entry, + id, + mintable, + parent, + } + .page(server_config) + .into_response() }) - .into_response() } else { - RuneHtml { - entry, - id, - mintable, - parent, + let unlock_height = rune.unlock_height(server_config.chain.network()); + let etchable; + let reserved; + + if let Some(height) = unlock_height { + etchable = index.block_count()? >= height; + reserved = false; + } else { + etchable = false; + reserved = true; } - .page(server_config) - .into_response() - }) + + Ok(if accept_json { + StatusCode::NOT_FOUND.into_response() + } else { + RuneNotFoundHtml { + etchable, + reserved, + rune, + unlock_height, + } + .page(server_config) + .into_response() + }) + } }) } @@ -2797,8 +2821,6 @@ mod tests { let rune = Rune(RUNE); - server.assert_response_regex(format!("/rune/{rune}"), StatusCode::NOT_FOUND, ".*"); - server.etch( Runestone { edicts: vec![Edict { @@ -2967,6 +2989,58 @@ mod tests { ); } + #[test] + fn rune_not_etched_shows_unlock_height() { + let server = TestServer::builder() + .chain(Chain::Regtest) + .index_runes() + .build(); + + server.mine_blocks(1); + + server.assert_response_regex("/rune/0", StatusCode::NOT_FOUND, ".*"); + + server.mine_blocks(1); + + server.assert_response_regex( + format!("/rune/{}", Rune(RUNE)), + StatusCode::OK, + ".*Rune AAAAAAAAAAAAA.* +
+
unlock height
+
0
+
etchable
+
true
+
.* +", + ); + } + + #[test] + fn reserved_rune_not_etched_shows_reserved_status() { + let server = TestServer::builder() + .chain(Chain::Regtest) + .index_runes() + .build(); + + server.mine_blocks(1); + + server.assert_response_regex("/rune/0", StatusCode::NOT_FOUND, ".*"); + + server.mine_blocks(1); + + server.assert_response_regex( + format!("/rune/{}", Rune(Rune::RESERVED)), + StatusCode::OK, + ".*Rune AAAAAAAAAAAAAAAAAAAAAAAAAAA.* +
+
reserved
+
true
+
.* +", + ); + } + #[test] fn runes_are_displayed_on_runes_page() { let server = TestServer::builder() @@ -3047,8 +3121,6 @@ mod tests { let rune = Rune(RUNE); - server.assert_response_regex(format!("/rune/{rune}"), StatusCode::NOT_FOUND, ".*"); - let (txid, id) = server.etch( Runestone { edicts: vec![Edict { @@ -3213,8 +3285,6 @@ mod tests { let rune = Rune(RUNE); - server.assert_response_regex(format!("/rune/{rune}"), StatusCode::NOT_FOUND, ".*"); - let (txid, id) = server.etch( Runestone { edicts: vec![Edict { @@ -3378,8 +3448,6 @@ mod tests { let rune = Rune(RUNE); - server.assert_response_regex(format!("/rune/{rune}"), StatusCode::NOT_FOUND, ".*"); - let (txid, id) = server.etch( Runestone { edicts: vec![Edict { diff --git a/src/templates.rs b/src/templates.rs index 218975986a..27d5d02ba0 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -22,6 +22,7 @@ pub(crate) use { }, range::RangeHtml, rare::RareTxt, + rune_not_found::RuneNotFoundHtml, sat::SatHtml, }; @@ -49,6 +50,7 @@ mod preview; mod range; mod rare; pub mod rune; +pub mod rune_not_found; pub mod runes; pub mod sat; pub mod status; diff --git a/src/templates/rune_not_found.rs b/src/templates/rune_not_found.rs new file mode 100644 index 0000000000..e6476d2053 --- /dev/null +++ b/src/templates/rune_not_found.rs @@ -0,0 +1,40 @@ +use super::*; + +#[derive(Boilerplate, Debug, PartialEq, Serialize, Deserialize)] +pub struct RuneNotFoundHtml { + pub etchable: bool, + pub reserved: bool, + pub rune: Rune, + pub unlock_height: Option, +} + +impl PageContent for RuneNotFoundHtml { + fn title(&self) -> String { + format!("Rune {}", self.rune) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display() { + assert_regex_match!( + RuneNotFoundHtml { + etchable: true, + reserved: false, + rune: Rune(u128::MAX), + unlock_height: Some(111), + }, + "

BCGDENLQRQWDSLRUGSNLBTMFIJAV

+
+
unlock height
+
111
+
etchable
+
true
+
+" + ); + } +} diff --git a/templates/rune-not-found.html b/templates/rune-not-found.html new file mode 100644 index 0000000000..f2019ada06 --- /dev/null +++ b/templates/rune-not-found.html @@ -0,0 +1,12 @@ +

{{ self.rune }}

+
+%% if self.reserved { +
reserved
+
{{ self.reserved }}
+%% } else { +
unlock height
+
{{ self.unlock_height.unwrap() }}
+
etchable
+
{{ self.etchable }}
+%% } +