diff --git a/Cargo.lock b/Cargo.lock index 8b8451218a..3f40a0bbae 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -91,11 +91,29 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7ed72e1635e121ca3e79420540282af22da58be50de153d36f81ddc6b83aa9e" +dependencies = [ + "libc", +] + +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + [[package]] name = "anyhow" -version = "1.0.59" +version = "1.0.60" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91f1f46651137be86f3a2b9a8359f9ab421d04d941c62b5982e1ca21113adf9" +checksum = "c794e162a5eff65c72ef524dfe393eb923c354e350bb78b9c7383df13f3bc142" dependencies = [ "backtrace", ] @@ -113,7 +131,7 @@ dependencies = [ "num-traits", "rusticata-macros", "thiserror", - "time 0.3.12", + "time 0.3.13", ] [[package]] @@ -141,9 +159,9 @@ dependencies = [ [[package]] name = "async-channel" -version = "1.6.1" +version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2114d64672151c0c5eaa5e131ec84a74f06e1e559830dabba01ca30605d66319" +checksum = "e14485364214912d3b19cc3435dde4df66065127f05fa0d75c712f36f12c2f28" dependencies = [ "concurrent-queue", "event-listener", @@ -361,9 +379,9 @@ checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" [[package]] name = "axum" -version = "0.5.13" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9496f0c1d1afb7a2af4338bbe1d969cddfead41d87a9fb3aaa6d0bbc7af648" +checksum = "9de18bc5f2e9df8f52da03856bf40e29b747de5a84e43aefff90e3dc4a21529b" dependencies = [ "async-trait", "axum-core", @@ -406,9 +424,9 @@ dependencies = [ [[package]] name = "axum-server" -version = "0.4.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b26e2731cf84d9c54b3768a2faebe97626cb0568babf04bad68077e8b60c555" +checksum = "87ba6170b61f7b086609dabcae68d2e07352539c6ef04a7c82980bdfa01a159d" dependencies = [ "bytes", "futures-util", @@ -458,7 +476,7 @@ dependencies = [ [[package]] name = "bdk" version = "0.20.1-dev" -source = "git+https://github.com/terror/bdk.git?branch=dust-limit#eccef3bdadb89dd1b3c0558b7f19bb67b9ae3311" +source = "git+https://github.com/terror/bdk.git?branch=dust-limit#80b5ece9d73faff00e832728fc69fa69402f3806" dependencies = [ "ahash", "async-trait", @@ -588,6 +606,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "boilerplate" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71362005b3b60c66851b461ca10c2d3802df01e4f741e25e481e1390be86aab0" +dependencies = [ + "new_mime_guess", + "proc-macro2", + "syn", +] + [[package]] name = "bumpalo" version = "3.10.0" @@ -626,10 +655,11 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "chrono" -version = "0.4.20" +version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6127248204b9aba09a362f6c930ef6a78f2c1b2215f8a7b398c06e1083f17af0" +checksum = "3f725f340c3854e3cb3ab736dc21f0cca183303acea3b3ffec30f141503ac8eb" dependencies = [ + "iana-time-zone", "js-sys", "num-integer", "num-traits", @@ -890,6 +920,12 @@ dependencies = [ "syn", ] +[[package]] +name = "diff" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" + [[package]] name = "digest" version = "0.9.0" @@ -1430,6 +1466,19 @@ dependencies = [ "tokio-native-tls", ] +[[package]] +name = "iana-time-zone" +version = "0.1.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9512e544c25736b82aebbd2bf739a47c8a1c935dfcc3a6adcde10e35cd3cd468" +dependencies = [ + "android_system_properties", + "core-foundation", + "js-sys", + "wasm-bindgen", + "winapi", +] + [[package]] name = "idna" version = "0.2.1" @@ -1516,9 +1565,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" [[package]] name = "libc" -version = "0.2.127" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "505e71a4706fa491e9b1b55f51b95d4037d0821ee40131190475f692b35b009b" +checksum = "64de3cc433455c14174d42e554d4027ee631c4d046d43e3ecc6efc4636cdc7a7" [[package]] name = "libsqlite3-sys" @@ -1644,6 +1693,16 @@ dependencies = [ "tempfile", ] +[[package]] +name = "new_mime_guess" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d684d1b59e0dc07b37e2203ef576987473288f530082512aff850585c61b1f" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "nix" version = "0.24.2" @@ -1801,6 +1860,7 @@ dependencies = [ "bdk", "bitcoin", "bitcoincore-rpc", + "boilerplate", "chrono", "clap", "ctrlc", @@ -1811,9 +1871,9 @@ dependencies = [ "futures", "hex", "http", - "lazy_static", "log", "nix", + "pretty_assertions", "rayon", "redb", "regex", @@ -1837,6 +1897,15 @@ version = "6.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "648001efe5d5c0102d8cea768e348da85d90af8ba91f0bea908f157951493cd4" +[[package]] +name = "output_vt100" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "628223faebab4e3e40667ee0b2336d34a5b960ff60ea743ddfdbcf7770bcfb66" +dependencies = [ + "winapi", +] + [[package]] name = "parking" version = "2.0.0" @@ -1951,6 +2020,18 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb9f9e6e233e5c4a35559a617bf40a4ec447db2e84c20b55a6f83167b7e57872" +[[package]] +name = "pretty_assertions" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89f989ac94207d048d92db058e4f6ec7342b0971fc58d1271ca148b799b3563" +dependencies = [ + "ansi_term", + "ctor", + "diff", + "output_vt100", +] + [[package]] name = "proc-macro-error" version = "1.0.4" @@ -2218,7 +2299,7 @@ checksum = "6413f3de1edee53342e6138e75b56d32e7bc6e332b3bd62d497b1929d4cfbcdd" dependencies = [ "pem", "ring", - "time 0.3.12", + "time 0.3.13", "yasna", ] @@ -2234,8 +2315,7 @@ dependencies = [ [[package]] name = "redb" version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38549a63ed9b0e9a1e9572e94fe351a9ab9b17a497b113e63bee194dfd3b0896" +source = "git+https://github.com/cberner/redb?branch=master#9dbc1179c82af7f6620d6382cec80a2b192b3f1a" dependencies = [ "libc", "pyo3-build-config", @@ -2548,9 +2628,9 @@ checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" [[package]] name = "serde" -version = "1.0.142" +version = "1.0.143" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e590c437916fb6b221e1d00df6e3294f3fccd70ca7e92541c475d6ed6ef5fee2" +checksum = "53e8e5d5b70924f74ff5c6d64d9a5acd91422117c60f48c4e07855238a254553" dependencies = [ "serde_derive", ] @@ -2567,9 +2647,9 @@ dependencies = [ [[package]] name = "serde_derive" -version = "1.0.142" +version = "1.0.143" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b5b8d809babe02f538c2cfec6f2c1ed10804c0e5a6a041a049a4f5588ccc2e" +checksum = "d3d8e8de557aee63c26b85b947f5e59b690d0454c753f3adeb5cd7835ab88391" dependencies = [ "proc-macro2", "quote", @@ -2933,12 +3013,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74b7cc93fc23ba97fde84f7eea56c55d1ba183f495c6715defdfc7b9cb8c870f" +checksum = "db76ff9fa4b1458b3c7f077f3ff9887394058460d21e634355b273aaf11eea45" dependencies = [ "itoa", - "js-sys", "libc", "num_threads", "time-macros 0.2.4", @@ -3119,6 +3198,15 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dcf81ac59edc17cc8697ff311e8f5ef2d99fcbd9817b34cec66f90b6c3dfd987" +[[package]] +name = "unicase" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.8" @@ -3458,7 +3546,7 @@ dependencies = [ "oid-registry", "rusticata-macros", "thiserror", - "time 0.3.12", + "time 0.3.13", ] [[package]] @@ -3467,5 +3555,5 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "346d34a236c9d3e5f3b9b74563f238f955bbd05fa0b8b4efa53c130c43982f4c" dependencies = [ - "time 0.3.12", + "time 0.3.13", ] diff --git a/Cargo.toml b/Cargo.toml index 049a20e682..721bd60e60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -13,6 +13,7 @@ axum = "0.5.6" axum-server = "0.4.0" bitcoin = "0.28.1" bitcoincore-rpc = "0.15.0" +boilerplate = { version = "0.0.2", features = ["axum"] } chrono = "0.4.19" clap = { version = "3.1.0", features = ["derive"] } ctrlc = "3.2.1" @@ -21,10 +22,9 @@ dirs = "4.0.0" env_logger = "0.9.0" futures = "0.3.21" http = "0.2.6" -lazy_static = "1.4.0" log = "0.4.14" rayon = "1.5.1" -redb = "0.5.0" +redb = { version = "0.5.0", git = "https://github.com/cberner/redb", branch = "master" } rustls-acme = "0.3.0" serde = { version = "1.0.137", features = ["derive"] } serde_cbor = "0.11.2" @@ -46,6 +46,7 @@ executable-path = "1.0.0" hex = "0.4.3" log = "0.4.14" nix = "0.24.1" +pretty_assertions = "1.2.1" regex = "1.6.0" reqwest = { version = "0.11.10", features = ["blocking"] } tempfile = "3.2.0" diff --git a/deploy/ord.service b/deploy/ord.service index f6d9c0d099..5787574a80 100644 --- a/deploy/ord.service +++ b/deploy/ord.service @@ -13,6 +13,7 @@ ExecStart=/usr/local/bin/ord \ --cookie-file /var/lib/bitcoind/signet/.cookie \ --max-index-size 1TiB \ --rpc-url 127.0.0.1:38332 \ + --data-dir /var/lib/ord \ server \ --acme-cache /var/lib/ord/acme-cache \ --acme-contact mailto:casey@rodarmor.com \ diff --git a/src/arguments.rs b/src/arguments.rs index 9938780f36..fe5512aca8 100644 --- a/src/arguments.rs +++ b/src/arguments.rs @@ -4,9 +4,9 @@ use super::*; #[clap(version)] pub(crate) struct Arguments { #[clap(flatten)] - options: Options, + pub(crate) options: Options, #[clap(subcommand)] - subcommand: Subcommand, + pub(crate) subcommand: Subcommand, } impl Arguments { @@ -14,173 +14,3 @@ impl Arguments { self.subcommand.run(self.options) } } - -#[cfg(test)] -mod tests { - use {super::*, std::path::Path}; - - #[test] - fn rpc_url_overrides_network() { - assert_eq!( - Arguments::try_parse_from(&[ - "ord", - "--rpc-url=127.0.0.1:1234", - "--network=signet", - "index" - ]) - .unwrap() - .options - .rpc_url(), - "127.0.0.1:1234" - ); - } - - #[test] - fn cookie_file_overrides_network() { - assert_eq!( - Arguments::try_parse_from(&["ord", "--cookie-file=/foo/bar", "--network=signet", "index"]) - .unwrap() - .options - .cookie_file() - .unwrap(), - Path::new("/foo/bar") - ); - } - - #[test] - fn use_default_network() { - let arguments = Arguments::try_parse_from(&["ord", "index"]).unwrap(); - - assert_eq!(arguments.options.rpc_url(), "127.0.0.1:8333"); - - assert!(arguments - .options - .cookie_file() - .unwrap() - .ends_with(".cookie")); - } - - #[test] - fn uses_network_defaults() { - let arguments = Arguments::try_parse_from(&["ord", "--network=signet", "index"]).unwrap(); - - assert_eq!(arguments.options.rpc_url(), "127.0.0.1:38333"); - - assert!(arguments - .options - .cookie_file() - .unwrap() - .display() - .to_string() - .ends_with("/signet/.cookie")) - } - - #[test] - fn mainnet_cookie_file_path() { - let arguments = Arguments::try_parse_from(&["ord", "index"]).unwrap(); - - let cookie_file = arguments - .options - .cookie_file() - .unwrap() - .display() - .to_string(); - - if cfg!(target_os = "linux") { - assert!(cookie_file.ends_with("/.bitcoin/.cookie")); - } else { - assert!(cookie_file.ends_with("/Bitcoin/.cookie")); - } - } - - #[test] - fn othernet_cookie_file_path() { - let arguments = Arguments::try_parse_from(&["ord", "--network=signet", "index"]).unwrap(); - - let cookie_file = arguments - .options - .cookie_file() - .unwrap() - .display() - .to_string(); - - if cfg!(target_os = "linux") { - assert!(cookie_file.ends_with("/.bitcoin/signet/.cookie")); - } else { - assert!(cookie_file.ends_with("/Bitcoin/signet/.cookie")); - } - } - - #[test] - fn http_or_https_port_is_required() { - let err = Arguments::try_parse_from(&["ord", "server", "--address", "127.0.0.1"]) - .unwrap_err() - .to_string(); - - assert!( - err.starts_with("error: The following required arguments were not provided:\n <--http-port |--https-port >\n"), - "{}", - err - ); - } - - #[test] - fn http_and_https_port_conflict() { - let err = Arguments::try_parse_from(&["ord", "server", "--http-port=0", "--https-port=0"]) - .unwrap_err() - .to_string(); - - assert!( - err.starts_with("error: The argument '--http-port ' cannot be used with '--https-port '\n"), - "{}", - err - ); - } - - #[test] - fn http_port_requires_acme_flags() { - let err = Arguments::try_parse_from(&["ord", "server", "--https-port=0"]) - .unwrap_err() - .to_string(); - - assert!( - err.starts_with("error: The following required arguments were not provided:\n --acme-cache \n --acme-domain \n --acme-contact \n"), - "{}", - err - ); - } - - #[test] - fn acme_contact_accepts_multiple_values() { - assert!(Arguments::try_parse_from(&[ - "ord", - "server", - "--address", - "127.0.0.1", - "--http-port", - "0", - "--acme-contact", - "foo", - "--acme-contact", - "bar" - ]) - .is_ok()); - } - - #[test] - fn acme_domain_accepts_multiple_values() { - assert!(Arguments::try_parse_from(&[ - "ord", - "server", - "--address", - "127.0.0.1", - "--http-port", - "0", - "--acme-domain", - "foo", - "--acme-domain", - "bar" - ]) - .is_ok()); - } -} diff --git a/src/blocktime.rs b/src/blocktime.rs new file mode 100644 index 0000000000..3ac8bf5d4d --- /dev/null +++ b/src/blocktime.rs @@ -0,0 +1,41 @@ +use super::*; + +#[derive(Copy, Clone)] +pub(crate) enum Blocktime { + Confirmed(i64), + Expected(i64), +} + +impl Blocktime { + fn timestamp(self) -> i64 { + match self { + Self::Confirmed(timestamp) | Self::Expected(timestamp) => timestamp, + } + } +} + +impl Display for Blocktime { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!(f, "{}", NaiveDateTime::from_timestamp(self.timestamp(), 0))?; + + if let Self::Expected(_) = self { + write!(f, " (expected)")?; + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn display() { + assert_eq!(Blocktime::Confirmed(0).to_string(), "1970-01-01 00:00:00"); + assert_eq!( + Blocktime::Expected(0).to_string(), + "1970-01-01 00:00:00 (expected)" + ); + } +} diff --git a/src/degree.rs b/src/degree.rs new file mode 100644 index 0000000000..1dd425a072 --- /dev/null +++ b/src/degree.rs @@ -0,0 +1,69 @@ +use super::*; + +#[derive(PartialEq, Debug)] +pub(crate) struct Degree { + pub(crate) hour: u64, + pub(crate) minute: u64, + pub(crate) second: u64, + pub(crate) third: u64, +} + +impl Display for Degree { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + write!( + f, + "{}°{}′{}″{}‴", + self.hour, self.minute, self.second, self.third + ) + } +} + +impl From for Degree { + fn from(ordinal: Ordinal) -> Self { + let height = ordinal.height().n(); + Degree { + hour: height / (CYCLE_EPOCHS * Epoch::BLOCKS), + minute: height % Epoch::BLOCKS, + second: height % PERIOD_BLOCKS, + third: ordinal.third(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn case(ordinal: u64, hour: u64, minute: u64, second: u64, third: u64) { + assert_eq!( + Degree::from(Ordinal(ordinal)), + Degree { + hour, + minute, + second, + third, + } + ); + } + + #[test] + fn from() { + case(0, 0, 0, 0, 0); + case(1, 0, 0, 0, 1); + case(5_000_000_000, 0, 1, 1, 0); + case(5_000_000_000 * 2016, 0, 2016, 0, 0); + case(5_000_000_000 * 210_000, 0, 0, 336, 0); + case( + 5_000_000_000 * 210_000 + + 2_500_000_000 * 210_000 + + 1_250_000_000 * 210_000 + + 625_000_000 * 210_000 + + 312_500_000 * 210_000 + + 156_250_000 * 210_000, + 1, + 0, + 0, + 0, + ); + } +} diff --git a/src/index.rs b/src/index.rs index 42893cb6e7..50e035ef63 100644 --- a/src/index.rs +++ b/src/index.rs @@ -12,19 +12,30 @@ const OUTPOINT_TO_ORDINAL_RANGES: TableDefinition<[u8], [u8]> = pub(crate) struct Index { client: Client, database: Database, + database_path: PathBuf, } impl Index { pub(crate) fn open(options: &Options) -> Result { - let client = Client::new(&options.rpc_url(), Auth::CookieFile(options.cookie_file()?)) + let rpc_url = options.rpc_url(); + let cookie_file = options.cookie_file()?; + + log::info!( + "Connection to Bitcoin Core RPC server at {rpc_url} using credentials from `{}`", + cookie_file.display() + ); + + let client = Client::new(&rpc_url, Auth::CookieFile(cookie_file)) .context("Failed to connect to RPC URL")?; - let database = match unsafe { redb::Database::open("index.redb") } { + let database_path = options.data_dir()?.join("index.redb"); + + let database = match unsafe { redb::Database::open(&database_path) } { Ok(database) => database, Err(redb::Error::Io(error)) if error.kind() == io::ErrorKind::NotFound => unsafe { Database::builder() .set_write_strategy(WriteStrategy::Throughput) - .create("index.redb", options.max_index_size.0)? + .create(&database_path, options.max_index_size.0)? }, Err(error) => return Err(error.into()), }; @@ -36,7 +47,11 @@ impl Index { tx.commit()?; - Ok(Self { client, database }) + Ok(Self { + client, + database, + database_path, + }) } #[allow(clippy::self_named_constructors)] @@ -72,7 +87,7 @@ impl Index { println!("fragmented: {}", Bytes(stats.fragmented_bytes())); println!( "index size: {}", - Bytes(std::fs::metadata("index.redb")?.len().try_into()?) + Bytes(std::fs::metadata(&self.database_path)?.len().try_into()?) ); wtx.abort()?; @@ -226,22 +241,22 @@ impl Index { .range(0..)? .rev() .next() - .map(|(height, _hash)| height + 1) + .map(|(height, _hash)| height) .unwrap_or(0), ) } - pub(crate) fn all(&self) -> Result> { + pub(crate) fn all(&self) -> Result> { let mut blocks = Vec::new(); let tx = self.database.begin_read()?; let height_to_hash = tx.open_table(HEIGHT_TO_HASH)?; - let mut cursor = height_to_hash.range(0..)?; + let mut cursor = height_to_hash.range(0..)?.rev(); while let Some(next) = cursor.next() { - blocks.push(sha256d::Hash::from_slice(next.1)?); + blocks.push((next.0, sha256d::Hash::from_slice(next.1)?)); } Ok(blocks) @@ -343,7 +358,7 @@ impl Index { } pub(crate) fn find(&self, ordinal: Ordinal) -> Result> { - if self.height()? <= ordinal.height().0 { + if self.height()? < ordinal.height().0 { return Ok(None); } @@ -397,4 +412,31 @@ impl Index { None => Ok(None), } } + + pub(crate) fn blocktime(&self, height: Height) -> Result { + let height = height.n(); + + match self.block_at_height(height)? { + Some(block) => Ok(Blocktime::Confirmed(block.header.time.into())), + None => { + let tx = self.database.begin_read()?; + + let current = tx + .open_table(HEIGHT_TO_HASH)? + .range(0..)? + .rev() + .next() + .map(|(height, _hash)| height) + .unwrap_or(0); + + let expected_blocks = height.checked_sub(current).with_context(|| { + format!("Current {current} height is greater than ordinal height {height}") + })?; + + Ok(Blocktime::Expected( + Utc::now().timestamp() + 10 * 60 * expected_blocks as i64, + )) + } + } + } } diff --git a/src/main.rs b/src/main.rs index db41f54dea..348234bdb3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,10 @@ #![allow(clippy::too_many_arguments)] use { - crate::{ - arguments::Arguments, bytes::Bytes, epoch::Epoch, height::Height, index::Index, nft::Nft, - options::Options, ordinal::Ordinal, purse::Purse, sat_point::SatPoint, subcommand::Subcommand, + self::{ + arguments::Arguments, blocktime::Blocktime, bytes::Bytes, degree::Degree, epoch::Epoch, + height::Height, index::Index, nft::Nft, options::Options, ordinal::Ordinal, purse::Purse, + sat_point::SatPoint, subcommand::Subcommand, }, anyhow::{anyhow, bail, Context, Error}, axum::{ @@ -37,8 +38,6 @@ use { chrono::{DateTime, NaiveDateTime, Utc}, clap::Parser, derive_more::{Display, FromStr}, - dirs::data_dir, - lazy_static::lazy_static, redb::{Database, ReadableTable, Table, TableDefinition, WriteTransaction}, serde::{Deserialize, Serialize}, std::{ @@ -68,7 +67,9 @@ const PERIOD_BLOCKS: u64 = 2016; const CYCLE_EPOCHS: u64 = 6; mod arguments; +mod blocktime; mod bytes; +mod degree; mod epoch; mod height; mod index; @@ -83,9 +84,7 @@ type Result = std::result::Result; static INTERRUPTS: AtomicU64 = AtomicU64::new(0); -lazy_static! { - static ref LISTENERS: Mutex> = Mutex::new(Vec::new()); -} +static LISTENERS: Mutex> = Mutex::new(Vec::new()); fn main() { env_logger::init(); diff --git a/src/options.rs b/src/options.rs index b1ec1254f8..7ea01adfe5 100644 --- a/src/options.rs +++ b/src/options.rs @@ -10,6 +10,8 @@ pub(crate) struct Options { rpc_url: Option, #[clap(long, default_value = "bitcoin")] pub(crate) network: Network, + #[clap(long)] + data_dir: Option, } impl Options { @@ -20,9 +22,9 @@ impl Options { .unwrap_or(&format!( "127.0.0.1:{}", match self.network { - Network::Bitcoin => "8333", + Network::Bitcoin => "8332", Network::Regtest => "18443", - Network::Signet => "38333", + Network::Signet => "38332", Network::Testnet => "18332", } )) @@ -50,4 +52,139 @@ impl Options { Ok(path.join(".cookie")) } + + pub(crate) fn data_dir(&self) -> Result { + if let Some(data_dir) = &self.data_dir { + return Ok(data_dir.clone()); + } + + let mut path = dirs::data_dir() + .ok_or_else(|| anyhow!("Failed to retrieve data dir"))? + .join("ord"); + + if !matches!(self.network, Network::Bitcoin) { + path.push(self.network.to_string()) + } + + if let Err(err) = fs::create_dir_all(&path) { + bail!("Failed to create data dir `{}`: {err}", path.display()); + } + + Ok(path) + } +} + +#[cfg(test)] +mod tests { + use {super::*, std::path::Path}; + + #[test] + fn rpc_url_overrides_network() { + assert_eq!( + Arguments::try_parse_from(&[ + "ord", + "--rpc-url=127.0.0.1:1234", + "--network=signet", + "index" + ]) + .unwrap() + .options + .rpc_url(), + "127.0.0.1:1234" + ); + } + + #[test] + fn cookie_file_overrides_network() { + assert_eq!( + Arguments::try_parse_from(&["ord", "--cookie-file=/foo/bar", "--network=signet", "index"]) + .unwrap() + .options + .cookie_file() + .unwrap(), + Path::new("/foo/bar") + ); + } + + #[test] + fn use_default_network() { + let arguments = Arguments::try_parse_from(&["ord", "index"]).unwrap(); + + assert_eq!(arguments.options.rpc_url(), "127.0.0.1:8332"); + + assert!(arguments + .options + .cookie_file() + .unwrap() + .ends_with(".cookie")); + } + + #[test] + fn uses_network_defaults() { + let arguments = Arguments::try_parse_from(&["ord", "--network=signet", "index"]).unwrap(); + + assert_eq!(arguments.options.rpc_url(), "127.0.0.1:38332"); + + assert!(arguments + .options + .cookie_file() + .unwrap() + .display() + .to_string() + .ends_with("/signet/.cookie")) + } + + #[test] + fn mainnet_cookie_file_path() { + let arguments = Arguments::try_parse_from(&["ord", "index"]).unwrap(); + + let cookie_file = arguments + .options + .cookie_file() + .unwrap() + .display() + .to_string(); + + if cfg!(target_os = "linux") { + assert!(cookie_file.ends_with("/.bitcoin/.cookie")); + } else { + assert!(cookie_file.ends_with("/Bitcoin/.cookie")); + } + } + + #[test] + fn othernet_cookie_file_path() { + let arguments = Arguments::try_parse_from(&["ord", "--network=signet", "index"]).unwrap(); + + let cookie_file = arguments + .options + .cookie_file() + .unwrap() + .display() + .to_string(); + + if cfg!(target_os = "linux") { + assert!(cookie_file.ends_with("/.bitcoin/signet/.cookie")); + } else { + assert!(cookie_file.ends_with("/Bitcoin/signet/.cookie")); + } + } + + #[test] + fn mainnet_data_dir() { + let arguments = Arguments::try_parse_from(&["ord", "index"]).unwrap(); + + let data_dir = arguments.options.data_dir().unwrap().display().to_string(); + + assert!(data_dir.ends_with("/ord")); + } + + #[test] + fn othernet_data_dir() { + let arguments = Arguments::try_parse_from(&["ord", "--network=signet", "index"]).unwrap(); + + let data_dir = arguments.options.data_dir().unwrap().display().to_string(); + + assert!(data_dir.ends_with("/ord/signet")); + } } diff --git a/src/ordinal.rs b/src/ordinal.rs index 0d73e2b0b5..83d6ca2583 100644 --- a/src/ordinal.rs +++ b/src/ordinal.rs @@ -12,6 +12,10 @@ impl Ordinal { self.0 } + pub(crate) fn degree(self) -> Degree { + self.into() + } + pub(crate) fn height(self) -> Height { self.epoch().starting_height() + self.epoch_position() / self.epoch().subsidy() } @@ -36,6 +40,33 @@ impl Ordinal { self.0 - self.epoch().starting_ordinal().0 } + pub(crate) fn decimal(self) -> String { + format!("{}.{}", self.height(), self.third()) + } + + pub(crate) fn rarity(self) -> &'static str { + let Degree { + hour, + minute, + second, + third, + } = self.degree(); + + if hour == 0 && minute == 0 && second == 0 && third == 0 { + "mythic" + } else if minute == 0 && second == 0 && third == 0 { + "legendary" + } else if minute == 0 && third == 0 { + "epic" + } else if second == 0 && third == 0 { + "rare" + } else if third == 0 { + "uncommon" + } else { + "common" + } + } + pub(crate) fn name(self) -> String { let mut x = Self::SUPPLY - self.0; let mut name = String::new(); @@ -51,6 +82,22 @@ impl Ordinal { name.chars().rev().collect() } + fn from_name(s: &str) -> Result { + let mut x = 0; + for c in s.chars() { + match c { + 'a'..='z' => { + x = x * 26 + c as u64 - 'a' as u64 + 1; + } + _ => bail!("Invalid character in ordinal name: {c}"), + } + } + if x > Self::SUPPLY { + bail!("Ordinal name out of range"); + } + Ok(Ordinal(Self::SUPPLY - x)) + } + fn from_degree(s: &str) -> Result { let (cycle_number, rest) = s .split_once('°') @@ -142,7 +189,9 @@ impl FromStr for Ordinal { type Err = Error; fn from_str(s: &str) -> Result { - if s.contains('°') { + if s.chars().any(|c| matches!(c, 'a'..='z')) { + Self::from_name(s) + } else if s.contains('°') { Self::from_degree(s) } else if s.contains('.') { Self::from_decimal(s) @@ -174,6 +223,9 @@ mod tests { assert_eq!(Ordinal(Epoch(0).subsidy()).height(), 1); assert_eq!(Ordinal(Epoch(0).subsidy() * 2).height(), 2); assert_eq!(Epoch(2).starting_ordinal().height(), Epoch::BLOCKS * 2); + assert_eq!(Ordinal(50 * 100_000_000).height(), 1); + assert_eq!(Ordinal(2099999997689999).height(), 6929999); + assert_eq!(Ordinal(2099999997689998).height(), 6929998); } #[test] @@ -183,6 +235,67 @@ mod tests { assert_eq!(Ordinal(26).name(), "nvtdijuwxkp"); assert_eq!(Ordinal(27).name(), "nvtdijuwxko"); assert_eq!(Ordinal(2099999997689999).name(), "a"); + assert_eq!(Ordinal(2099999997689999 - 1).name(), "b"); + assert_eq!(Ordinal(2099999997689999 - 25).name(), "z"); + assert_eq!(Ordinal(2099999997689999 - 26).name(), "aa"); + } + + #[test] + fn number() { + assert_eq!(Ordinal(2099999997689999).n(), 2099999997689999); + } + + #[test] + fn decimal() { + assert_eq!(Ordinal(2099999997689999).decimal(), "6929999.0"); + } + + #[test] + fn degree() { + assert_eq!(Ordinal(0).degree().to_string(), "0°0′0″0‴"); + assert_eq!(Ordinal(1).degree().to_string(), "0°0′0″1‴"); + assert_eq!( + Ordinal(50 * 100_000_000 - 1).degree().to_string(), + "0°0′0″4999999999‴" + ); + assert_eq!(Ordinal(50 * 100_000_000).degree().to_string(), "0°1′1″0‴"); + assert_eq!( + Ordinal(50 * 100_000_000 + 1).degree().to_string(), + "0°1′1″1‴" + ); + assert_eq!( + Ordinal(50 * 100_000_000 * 2016 - 1).degree().to_string(), + "0°2015′2015″4999999999‴" + ); + assert_eq!( + Ordinal(50 * 100_000_000 * 2016).degree().to_string(), + "0°2016′0″0‴" + ); + assert_eq!( + Ordinal(50 * 100_000_000 * 2016 + 1).degree().to_string(), + "0°2016′0″1‴" + ); + assert_eq!( + Ordinal(50 * 100_000_000 * 210000 - 1).degree().to_string(), + "0°209999′335″4999999999‴" + ); + assert_eq!( + Ordinal(50 * 100_000_000 * 210000).degree().to_string(), + "0°0′336″0‴" + ); + assert_eq!( + Ordinal(50 * 100_000_000 * 210000 + 1).degree().to_string(), + "0°0′336″1‴" + ); + assert_eq!( + Ordinal(2067187500000000 - 1).degree().to_string(), + "0°209999′2015″156249999‴" + ); + assert_eq!(Ordinal(2067187500000000).degree().to_string(), "1°0′0″0‴"); + assert_eq!( + Ordinal(2067187500000000 + 1).degree().to_string(), + "1°0′0″1‴" + ); } #[test] @@ -190,13 +303,20 @@ mod tests { assert_eq!(Ordinal(0).period(), 0); assert_eq!(Ordinal(10080000000000).period(), 1); assert_eq!(Ordinal(2099999997689999).period(), 3437); + assert_eq!(Ordinal(10075000000000).period(), 0); + assert_eq!(Ordinal(10080000000000 - 1).period(), 0); + assert_eq!(Ordinal(10080000000000).period(), 1); + assert_eq!(Ordinal(10080000000000 + 1).period(), 1); + assert_eq!(Ordinal(10085000000000).period(), 1); + assert_eq!(Ordinal(2099999997689999).period(), 3437); } #[test] fn epoch() { assert_eq!(Ordinal(0).epoch(), 0); assert_eq!(Ordinal(1).epoch(), 0); - assert_eq!(Ordinal(1050000000000000).epoch(), 1); + assert_eq!(Ordinal(50 * 100_000_000 * 210000).epoch(), 1); + assert_eq!(Ordinal(2099999997689999).epoch(), 32); } #[test] @@ -310,7 +430,6 @@ mod tests { #[test] fn from_str_number() { assert_eq!(parse("0").unwrap(), 0); - assert!(parse("foo").is_err()); assert_eq!(parse("2099999997689999").unwrap(), 2099999997689999); assert!(parse("2099999997690000").is_err()); } @@ -354,6 +473,15 @@ mod tests { assert!(parse("5°0′1008″0‴").is_err()); } + #[test] + fn from_str_name() { + assert_eq!(parse("nvtdijuwxlp").unwrap(), 0); + assert_eq!(parse("a").unwrap(), 2099999997689999); + assert!(parse("(").is_err()); + assert!(parse("").is_err()); + assert!(parse("nvtdijuwxlq").is_err()); + } + #[test] fn cycle() { assert_eq!(Epoch::BLOCKS * CYCLE_EPOCHS % PERIOD_BLOCKS, 0); @@ -363,5 +491,40 @@ mod tests { } assert_eq!(CYCLE_EPOCHS * Epoch::BLOCKS % PERIOD_BLOCKS, 0); + + assert_eq!(Ordinal(0).cycle(), 0); + assert_eq!(Ordinal(2067187500000000 - 1).cycle(), 0); + assert_eq!(Ordinal(2067187500000000).cycle(), 1); + assert_eq!(Ordinal(2067187500000000 + 1).cycle(), 1); + } + + #[test] + fn rarity() { + assert_eq!(Ordinal(0).rarity(), "mythic"); + assert_eq!(Ordinal(1).rarity(), "common"); + + assert_eq!(Ordinal(50 * 100_000_000 - 1).rarity(), "common"); + assert_eq!(Ordinal(50 * 100_000_000).rarity(), "uncommon"); + assert_eq!(Ordinal(50 * 100_000_000 + 1).rarity(), "common"); + + assert_eq!(Ordinal(50 * 100_000_000 * 2016 - 1).rarity(), "common"); + assert_eq!(Ordinal(50 * 100_000_000 * 2016).rarity(), "rare"); + assert_eq!(Ordinal(50 * 100_000_000 * 2016 + 1).rarity(), "common"); + + assert_eq!(Ordinal(50 * 100_000_000 * 210000 - 1).rarity(), "common"); + assert_eq!(Ordinal(50 * 100_000_000 * 210000).rarity(), "epic"); + assert_eq!(Ordinal(50 * 100_000_000 * 210000 + 1).rarity(), "common"); + + assert_eq!(Ordinal(2067187500000000 - 1).rarity(), "common"); + assert_eq!(Ordinal(2067187500000000).rarity(), "legendary"); + assert_eq!(Ordinal(2067187500000000 + 1).rarity(), "common"); + } + + #[test] + fn third() { + assert_eq!(Ordinal(0).third(), 0); + assert_eq!(Ordinal(50 * 100_000_000 - 1).third(), 4999999999); + assert_eq!(Ordinal(50 * 100_000_000).third(), 0); + assert_eq!(Ordinal(50 * 100_000_000 + 1).third(), 1); } } diff --git a/src/purse.rs b/src/purse.rs index 4666df8c8c..006e35f74e 100644 --- a/src/purse.rs +++ b/src/purse.rs @@ -8,30 +8,27 @@ pub(crate) struct Purse { impl Purse { pub(crate) fn init(options: &Options) -> Result { - let path = data_dir() - .ok_or_else(|| anyhow!("Failed to retrieve data dir"))? - .join("ord"); + let data_dir = options.data_dir()?; - if path.exists() { + let entropy = data_dir.join("entropy"); + + if entropy.exists() { return Err(anyhow!("Wallet already exists.")); } - fs::create_dir_all(&path)?; - let seed = Mnemonic::generate_in_with(&mut rand::thread_rng(), Language::English, 12)?; - fs::write(path.join("entropy"), seed.to_entropy())?; + fs::write(entropy, seed.to_entropy())?; let wallet = bdk::wallet::Wallet::new( Bip84((seed.clone(), None), KeychainKind::External), None, options.network, SqliteDatabase::new( - path + data_dir .join("wallet.sqlite") .to_str() - .ok_or_else(|| anyhow!("Failed to convert path to str"))? - .to_string(), + .ok_or_else(|| anyhow!("Failed to convert path to str"))?, ), )?; @@ -43,26 +40,25 @@ impl Purse { } pub(crate) fn load(options: &Options) -> Result { - let path = data_dir() - .ok_or_else(|| anyhow!("Failed to retrieve data dir"))? - .join("ord"); + let data_dir = options.data_dir()?; + + let entropy = data_dir.join("entropy"); - if !path.exists() { + if !entropy.exists() { return Err(anyhow!("Wallet doesn't exist.")); } - let seed = Mnemonic::from_entropy(&fs::read(path.join("entropy"))?)?; + let seed = Mnemonic::from_entropy(&fs::read(entropy)?)?; let wallet = bdk::wallet::Wallet::new( Bip84((seed.clone(), None), KeychainKind::External), None, options.network, SqliteDatabase::new( - path + data_dir .join("wallet.sqlite") .to_str() - .ok_or_else(|| anyhow!("Failed to convert path to str"))? - .to_string(), + .ok_or_else(|| anyhow!("Failed to convert path to str"))?, ), )?; @@ -102,7 +98,7 @@ impl Purse { options.network, &Secp256k1::new(), )?, - skip_blocks: None, + sync_params: None, })?) } } diff --git a/src/subcommand/server.rs b/src/subcommand/server.rs index d37f4e2762..fabbc889b3 100644 --- a/src/subcommand/server.rs +++ b/src/subcommand/server.rs @@ -1,7 +1,10 @@ use super::*; use { - self::{deserialize_ordinal_from_str::DeserializeOrdinalFromStr, tls_acceptor::TlsAcceptor}, + self::{ + deserialize_ordinal_from_str::DeserializeOrdinalFromStr, templates::OrdinalHtml, + tls_acceptor::TlsAcceptor, + }, clap::ArgGroup, rustls_acme::{ acme::{ACME_TLS_ALPN_NAME, LETS_ENCRYPT_PRODUCTION_DIRECTORY, LETS_ENCRYPT_STAGING_DIRECTORY}, @@ -13,10 +16,11 @@ use { }; mod deserialize_ordinal_from_str; +mod templates; mod tls_acceptor; #[derive(Debug, Parser)] -#[clap(group = ArgGroup::new("port").multiple(false).required(true))] +#[clap(group = ArgGroup::new("port").multiple(false))] pub(crate) struct Server { #[clap( long, @@ -32,7 +36,7 @@ pub(crate) struct Server { #[clap( long, group = "port", - help = "Listen on for incoming HTTP requests." + help = "Listen on for incoming HTTP requests. Defaults to 80." )] http_port: Option, #[clap( @@ -65,6 +69,7 @@ impl Server { .route("/", get(Self::root)) .route("/api/list/:outpoint", get(Self::api_list)) .route("/block/:hash", get(Self::block)) + .route("/height", get(Self::height)) .route("/ordinal/:ordinal", get(Self::ordinal)) .route("/output/:output", get(Self::output)) .route("/range/:start/:end", get(Self::range)) @@ -77,37 +82,9 @@ impl Server { .allow_origin(Any), ); - let (port, acceptor) = match (self.http_port, self.https_port) { - (Some(http_port), None) => (http_port, None), - (None, Some(https_port)) => { - let config = AcmeConfig::new(self.acme_domain) - .contact(self.acme_contact) - .cache_option(Some(DirCache::new(self.acme_cache.unwrap()))) - .directory(if cfg!(test) { - LETS_ENCRYPT_STAGING_DIRECTORY - } else { - LETS_ENCRYPT_PRODUCTION_DIRECTORY - }); - - let mut state = config.state(); - - let acceptor = state.acceptor(); - - tokio::spawn(async move { - while let Some(result) = state.next().await { - match result { - Ok(ok) => log::info!("ACME event: {:?}", ok), - Err(err) => log::error!("ACME error: {:?}", err), - } - } - }); - - (https_port, Some(acceptor)) - } - (None, None) | (Some(_), Some(_)) => unreachable!(), - }; + let port = self.port(); - let addr = (self.address, port) + let addr = (self.address.as_str(), port) .to_socket_addrs()? .next() .ok_or_else(|| anyhow!("Failed to get socket addrs"))?; @@ -118,10 +95,10 @@ impl Server { let server = axum_server::Server::bind(addr).handle(handle); - match acceptor { + match self.acceptor() { Some(acceptor) => { server - .acceptor(TlsAcceptor(acceptor)) + .acceptor(acceptor) .serve(app.into_make_service()) .await? } @@ -132,10 +109,62 @@ impl Server { }) } + fn port(&self) -> u16 { + self.http_port.or(self.https_port).unwrap_or(80) + } + + fn acceptor(&self) -> Option { + if self.https_port.is_some() { + let config = AcmeConfig::new(&self.acme_domain) + .contact(&self.acme_contact) + .cache_option(Some(DirCache::new( + self.acme_cache.as_ref().unwrap().clone(), + ))) + .directory(if cfg!(test) { + LETS_ENCRYPT_STAGING_DIRECTORY + } else { + LETS_ENCRYPT_PRODUCTION_DIRECTORY + }); + + let mut state = config.state(); + + let acceptor = state.acceptor(); + + tokio::spawn(async move { + while let Some(result) = state.next().await { + match result { + Ok(ok) => log::info!("ACME event: {:?}", ok), + Err(err) => log::error!("ACME error: {:?}", err), + } + } + }); + + Some(TlsAcceptor(acceptor)) + } else { + None + } + } + async fn ordinal( + index: extract::Extension>, extract::Path(DeserializeOrdinalFromStr(ordinal)): extract::Path, ) -> impl IntoResponse { - (StatusCode::OK, Html(format!("{ordinal}"))) + match index.blocktime(ordinal.height()) { + Ok(blocktime) => OrdinalHtml { ordinal, blocktime }.into_response(), + Err(err) => { + eprintln!("Failed to retrieve height from index: {err}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Html( + StatusCode::INTERNAL_SERVER_ERROR + .canonical_reason() + .unwrap_or_default() + .to_string(), + ), + ) + .into_response() + } + } } async fn output( @@ -204,7 +233,6 @@ impl Server { "
    \n{}
", blocks .iter() - .enumerate() .map(|(height, hash)| format!( "
  • {height} - {hash}
  • \n" )) @@ -272,8 +300,8 @@ impl Server { } async fn transaction( - extract::Path(txid): extract::Path, index: extract::Extension>, + extract::Path(txid): extract::Path, ) -> impl IntoResponse { match index.transaction(txid) { Ok(Some(transaction)) => ( @@ -326,7 +354,107 @@ impl Server { } } - async fn status() -> StatusCode { - StatusCode::OK + async fn status() -> impl IntoResponse { + ( + StatusCode::OK, + StatusCode::OK + .canonical_reason() + .unwrap_or_default() + .to_string(), + ) + } + + async fn height(index: extract::Extension>) -> impl IntoResponse { + match index.height() { + Ok(height) => (StatusCode::OK, Html(format!("{}", height))), + Err(err) => { + eprintln!("Failed to retrieve height from index: {err}"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Html( + StatusCode::INTERNAL_SERVER_ERROR + .canonical_reason() + .unwrap_or_default() + .to_string(), + ), + ) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn port_defaults_to_80() { + match Arguments::try_parse_from(&["ord", "server"]) + .unwrap() + .subcommand + { + Subcommand::Server(server) => assert_eq!(server.port(), 80), + subcommand => panic!("Unexpected subcommand: {subcommand:?}"), + } + } + + #[test] + fn http_and_https_port_conflict() { + let err = Arguments::try_parse_from(&["ord", "server", "--http-port=0", "--https-port=0"]) + .unwrap_err() + .to_string(); + + assert!( + err.starts_with("error: The argument '--http-port ' cannot be used with '--https-port '\n"), + "{}", + err + ); + } + + #[test] + fn http_port_requires_acme_flags() { + let err = Arguments::try_parse_from(&["ord", "server", "--https-port=0"]) + .unwrap_err() + .to_string(); + + assert!( + err.starts_with("error: The following required arguments were not provided:\n --acme-cache \n --acme-domain \n --acme-contact \n"), + "{}", + err + ); + } + + #[test] + fn acme_contact_accepts_multiple_values() { + assert!(Arguments::try_parse_from(&[ + "ord", + "server", + "--address", + "127.0.0.1", + "--http-port", + "0", + "--acme-contact", + "foo", + "--acme-contact", + "bar" + ]) + .is_ok()); + } + + #[test] + fn acme_domain_accepts_multiple_values() { + assert!(Arguments::try_parse_from(&[ + "ord", + "server", + "--address", + "127.0.0.1", + "--http-port", + "0", + "--acme-domain", + "foo", + "--acme-domain", + "bar" + ]) + .is_ok()); } } diff --git a/src/subcommand/server/templates.rs b/src/subcommand/server/templates.rs new file mode 100644 index 0000000000..70907c6e84 --- /dev/null +++ b/src/subcommand/server/templates.rs @@ -0,0 +1,46 @@ +use {super::*, boilerplate::Display}; + +#[derive(Display)] +pub(crate) struct OrdinalHtml { + pub(crate) ordinal: Ordinal, + pub(crate) blocktime: Blocktime, +} + +#[cfg(test)] +mod tests { + use {super::*, pretty_assertions::assert_eq, unindent::Unindent}; + + #[test] + fn ordinal_html() { + assert_eq!( + OrdinalHtml { + ordinal: Ordinal(0), + blocktime: Blocktime::Confirmed(0), + } + .to_string(), + " + + + + + 0°0′0″0‴ + + +
    number
    0
    +
    decimal
    0.0
    +
    degree
    0°0′0″0‴
    +
    name
    nvtdijuwxlp
    +
    height
    0
    +
    cycle
    0
    +
    epoch
    0
    +
    period
    0
    +
    offset
    0
    +
    rarity
    mythic
    +
    block time
    1970-01-01 00:00:00
    + + + " + .unindent() + ); + } +} diff --git a/src/subcommand/traits.rs b/src/subcommand/traits.rs index a6e4691a2b..8239d98ee0 100644 --- a/src/subcommand/traits.rs +++ b/src/subcommand/traits.rs @@ -8,48 +8,76 @@ pub(crate) struct Traits { impl Traits { pub(crate) fn run(self) -> Result { if self.ordinal > Ordinal::LAST { - return Err(anyhow!("Invalid ordinal")); + bail!("Invalid ordinal"); } - println!("number: {}", self.ordinal.n()); - println!( - "decimal: {}.{}", - self.ordinal.height(), - self.ordinal.third() - ); + print!("{}", self); + + Ok(()) + } +} + +impl Display for Traits { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + writeln!(f, "number: {}", self.ordinal.n())?; + writeln!(f, "decimal: {}", self.ordinal.decimal())?; + writeln!(f, "degree: {}", self.ordinal.degree())?; + writeln!(f, "name: {}", self.ordinal.name())?; + writeln!(f, "height: {}", self.ordinal.height())?; + writeln!(f, "cycle: {}", self.ordinal.cycle())?; + writeln!(f, "epoch: {}", self.ordinal.epoch())?; + writeln!(f, "period: {}", self.ordinal.period())?; + writeln!(f, "offset: {}", self.ordinal.third())?; + writeln!(f, "rarity: {}", self.ordinal.rarity())?; + Ok(()) + } +} - let height = self.ordinal.height().n(); - let h = height / (CYCLE_EPOCHS * Epoch::BLOCKS); - let m = height % Epoch::BLOCKS; - let s = height % PERIOD_BLOCKS; - let t = self.ordinal.third(); - println!("degree: {h}°{m}′{s}″{t}‴"); - - println!("name: {}", self.ordinal.name()); - - println!("height: {}", self.ordinal.height()); - println!("cycle: {}", self.ordinal.cycle()); - println!("epoch: {}", self.ordinal.epoch()); - println!("period: {}", self.ordinal.period()); - println!("offset: {}", self.ordinal.third()); - - println!( - "rarity: {}", - if h == 0 && m == 0 && s == 0 && t == 0 { - "mythic" - } else if m == 0 && s == 0 && t == 0 { - "legendary" - } else if m == 0 && t == 0 { - "epic" - } else if s == 0 && t == 0 { - "rare" - } else if t == 0 { - "uncommon" - } else { - "common" +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn first() { + assert_eq!( + Traits { + ordinal: Ordinal(0) } + .to_string(), + "\ +number: 0 +decimal: 0.0 +degree: 0°0′0″0‴ +name: nvtdijuwxlp +height: 0 +cycle: 0 +epoch: 0 +period: 0 +offset: 0 +rarity: mythic +", ); + } - Ok(()) + #[test] + fn last() { + assert_eq!( + Traits { + ordinal: Ordinal(2099999997689999) + } + .to_string(), + "\ +number: 2099999997689999 +decimal: 6929999.0 +degree: 5°209999′1007″0‴ +name: a +height: 6929999 +cycle: 5 +epoch: 32 +period: 3437 +offset: 0 +rarity: uncommon +", + ); } } diff --git a/src/subcommand/wallet/balance.rs b/src/subcommand/wallet/balance.rs index 0014576812..4061e68e1a 100644 --- a/src/subcommand/wallet/balance.rs +++ b/src/subcommand/wallet/balance.rs @@ -1,6 +1,6 @@ use super::*; pub(crate) fn run(options: Options) -> Result { - println!("{}", Purse::load(&options)?.wallet.get_balance()?); + println!("{}", Purse::load(&options)?.wallet.get_balance()?.confirmed); Ok(()) } diff --git a/templates/ordinal.html b/templates/ordinal.html new file mode 100644 index 0000000000..33f9656e6c --- /dev/null +++ b/templates/ordinal.html @@ -0,0 +1,20 @@ + + + + + {{ self.ordinal.degree() }} + + +
    number
    {{ self.ordinal.n() }}
    +
    decimal
    {{ self.ordinal.decimal() }}
    +
    degree
    {{ self.ordinal.degree() }}
    +
    name
    {{ self.ordinal.name() }}
    +
    height
    {{ self.ordinal.height() }}
    +
    cycle
    {{ self.ordinal.cycle() }}
    +
    epoch
    {{ self.ordinal.epoch() }}
    +
    period
    {{ self.ordinal.period() }}
    +
    offset
    {{ self.ordinal.third() }}
    +
    rarity
    {{ self.ordinal.rarity() }}
    +
    block time
    {{ self.blocktime }}
    + + diff --git a/tests/expected.rs b/tests/expected.rs new file mode 100644 index 0000000000..0a6b8a1e27 --- /dev/null +++ b/tests/expected.rs @@ -0,0 +1,27 @@ +use super::*; + +#[derive(Debug)] +pub(crate) enum Expected { + String(String), + Regex(Regex), + Ignore, +} + +impl Expected { + pub(crate) fn regex(pattern: &str) -> Self { + Self::Regex(Regex::new(&format!("^(?s){}$", pattern)).unwrap()) + } + + pub(crate) fn assert_match(&self, output: &str) { + match self { + Self::String(string) => pretty_assertions::assert_eq!(output, string), + Self::Regex(regex) => assert!( + regex.is_match(output), + "regex:\n{}\ndid not match output:\n{}", + regex, + output + ), + Self::Ignore => {} + } + } +} diff --git a/tests/index.rs b/tests/index.rs index 83c5a27623..b9d0c5e386 100644 --- a/tests/index.rs +++ b/tests/index.rs @@ -11,8 +11,7 @@ fn custom_index_size() { assert_eq!( state - .tempdir - .path() + .ord_data_dir() .join("index.redb") .metadata() .unwrap() diff --git a/tests/info.rs b/tests/info.rs index 7c886ca95b..bc0321de8c 100644 --- a/tests/info.rs +++ b/tests/info.rs @@ -15,7 +15,6 @@ fn basic() { stored: .* overhead: .* fragmented: .* - index size: .* " .unindent(), ) diff --git a/tests/lib.rs b/tests/lib.rs index 34cbba69a9..32c2027185 100644 --- a/tests/lib.rs +++ b/tests/lib.rs @@ -1,7 +1,7 @@ #![allow(clippy::type_complexity)] use { - self::{state::State, test::Test, transaction_options::TransactionOptions}, + self::{expected::Expected, state::State, test::Test, transaction_options::TransactionOptions}, bdk::{ blockchain::{ rpc::{RpcBlockchain, RpcConfig}, @@ -19,9 +19,9 @@ use { log::LevelFilter, regex::Regex, std::{ - collections::BTreeMap, fs, net::TcpListener, + path::PathBuf, process::{Child, Command, Stdio}, str::{self, FromStr}, sync::Once, @@ -33,6 +33,7 @@ use { }; mod epochs; +mod expected; mod find; mod index; mod info; diff --git a/tests/server.rs b/tests/server.rs index c7d4f25532..f704b316ae 100644 --- a/tests/server.rs +++ b/tests/server.rs @@ -17,7 +17,20 @@ fn list() { #[test] fn status() { - State::new().request("status", 200, ""); + State::new().request("status", 200, "OK"); +} + +#[test] +fn height() { + let mut state = State::new(); + + state.request("height", 200, "0"); + + state.blocks(1); + + sleep(Duration::from_secs(1)); + + state.request("height", 200, "1"); } #[test] @@ -28,7 +41,7 @@ fn range_end_before_range_start_returns_400() { #[test] fn invalid_range_start_returns_400() { State::new().request( - "range/foo/0", + "range/=/0", 400, "Invalid URL: invalid digit found in string", ); @@ -37,7 +50,7 @@ fn invalid_range_start_returns_400() { #[test] fn invalid_range_end_returns_400() { State::new().request( - "range/0/foo", + "range/0/=", 400, "Invalid URL: invalid digit found in string", ); @@ -55,17 +68,21 @@ fn range_links_to_first() { #[test] fn ordinal_number() { - State::new().request("ordinal/0", 200, "0"); + State::new().request_regex("ordinal/0", 200, ".*
    number
    0
    .*"); } #[test] fn ordinal_decimal() { - State::new().request("ordinal/0.0", 200, "0"); + State::new().request_regex("ordinal/0.0", 200, ".*
    number
    0
    .*"); } #[test] fn ordinal_degree() { - State::new().request("ordinal/0°0′0″0‴", 200, "0"); + State::new().request_regex( + "ordinal/0°0′0″0‴", + 200, + ".*
    number
    0
    .*", + ); } #[test] @@ -94,10 +111,10 @@ fn outpoint_returns_ordinal_ranges() { sleep(Duration::from_secs(1)); - state.request( + state.request_regex( "output/4a5e1e4baab89f3a32518a88c31bc87f618f76673e2cc77ab2127b7afdeda33b:0", 200, - "", + ".*.*", ); } @@ -120,17 +137,13 @@ fn root() { state.blocks(1); - sleep(Duration::from_secs(1)); - - state.request( + state.request_regex( "/", 200, - " - - ", + ".*.*", ); } @@ -150,15 +163,13 @@ fn transactions() { let blocks = state.blocks(1); - state.request( + state.request_regex( &format!("block/{}", blocks[0]), 200, - " - - ", + ".*.*", ); } @@ -177,21 +188,39 @@ fn outputs() { state.blocks(101); - sleep(Duration::from_secs(1)); - state.transaction(TransactionOptions { slots: &[(1, 0, 0)], output_count: 1, fee: 0, }); + state.blocks(1); + state.request( "tx/30b037a346d31902f146a53d9ac8fa90541f43ca4a5e321914e86acdbf28394c", 200, - " - - " + "" + ); +} + +#[test] +fn unmined_ordinal() { + let mut state = State::new(); + state.request_regex( + "ordinal/0", + 200, + ".*
    block time
    2011-02-02 23:16:42
    .*", + ); +} + +#[test] +fn mined_ordinal() { + let mut state = State::new(); + state.request_regex( + "ordinal/5000000000", + 200, + ".*
    block time
    .* \\(expected\\)
    .*", ); } diff --git a/tests/state.rs b/tests/state.rs index b71f1e9937..fdbb9c2cbf 100644 --- a/tests/state.rs +++ b/tests/state.rs @@ -35,9 +35,10 @@ impl State { .stdout(if log::max_level() >= LevelFilter::Info { Stdio::inherit() } else { - Stdio::piped() + Stdio::null() }) .args(&[ + "-txindex=1", "-minrelaytxfee=0", "-blockmintxfee=0", "-dustrelayfee=0", @@ -94,7 +95,7 @@ impl State { auth: bdk::blockchain::rpc::Auth::Cookie { file: cookiefile }, network: Network::Regtest, wallet_name: "test".to_string(), - skip_blocks: None, + sync_params: None, }) .unwrap(); @@ -189,8 +190,6 @@ impl State { let tx = psbt.extract_tx(); - eprintln!("YOLO: {}", tx.txid()); - self .client .call::( @@ -201,30 +200,22 @@ impl State { } pub(crate) fn request(&mut self, path: &str, status: u16, expected_response: &str) { - if let Some(ord_http_port) = self.ord_http_port { - let response = - reqwest::blocking::get(&format!("http://127.0.0.1:{}/{}", ord_http_port, path)).unwrap(); - - log::info!("{:?}", response); - - assert_eq!(response.status().as_u16(), status); + self.request_expected(path, status, Expected::String(expected_response.into())); + } - let response_text = response.text().unwrap(); + pub(crate) fn request_regex(&mut self, path: &str, status: u16, expected_response: &str) { + self.request_expected(path, status, Expected::regex(expected_response)); + } - assert!( - Regex::new(expected_response.unindent().trim_end()) - .unwrap() - .is_match(&response_text), - "Response text did not match regex: {:?}", - &response_text - ); - } else { + pub(crate) fn request_expected(&mut self, path: &str, status: u16, expected: Expected) { + if self.ord_http_port.is_none() { let ord_http_port = free_port(); fs::create_dir(self.tempdir.path().join("server")).unwrap(); let ord = Command::new(executable_path("ord")) .current_dir(self.tempdir.path().join("server")) + .env("HOME", self.tempdir.path()) .arg(format!("--rpc-url=localhost:{}", self.bitcoind_rpc_port)) .arg("--cookie-file=../bitcoin/regtest/.cookie") .args([ @@ -251,9 +242,61 @@ impl State { self.ord = Some(ord); self.ord_http_port = Some(ord_http_port); + } - self.request(path, status, expected_response); + for attempt in 0..=300 { + let best_hash = self.client.get_best_block_hash().unwrap(); + let bitcoind_height = self + .client + .get_block_header_info(&best_hash) + .unwrap() + .height as u64; + + let ord_height = reqwest::blocking::get(&format!( + "http://127.0.0.1:{}/height", + self.ord_http_port.unwrap() + )) + .unwrap() + .text() + .unwrap() + .parse::() + .unwrap(); + + if ord_height == bitcoind_height { + break; + } else { + if attempt == 300 { + panic!("Ord height {ord_height} did not catch up to bitcoind height {bitcoind_height}"); + } + + sleep(Duration::from_millis(100)); + } } + + let response = reqwest::blocking::get(&format!( + "http://127.0.0.1:{}/{}", + self.ord_http_port.unwrap(), + path + )) + .unwrap(); + + log::info!("{:?}", response); + + assert_eq!(response.status().as_u16(), status); + + expected.assert_match(&response.text().unwrap()); + } + + pub(crate) fn ord_data_dir(&self) -> PathBuf { + self + .tempdir + .path() + .join(if cfg!(target_os = "macos") { + "Library/Application Support/" + } else { + ".local/share" + }) + .join("ord") } } diff --git a/tests/test.rs b/tests/test.rs index f718043789..4875bc68e7 100644 --- a/tests/test.rs +++ b/tests/test.rs @@ -1,30 +1,5 @@ use super::*; -#[derive(Debug)] -enum Expected { - String(String), - Regex(Regex), - Ignore, -} - -impl Expected { - fn regex(pattern: &str) -> Self { - Self::Regex(Regex::new(&format!("^(?s){}$", pattern)).unwrap()) - } - - fn assert_match(&self, output: &str) { - match self { - Self::String(string) => assert_eq!(output, string), - Self::Regex(regex) => assert!( - regex.is_match(output), - "output did not match regex: {:?}", - output - ), - Self::Ignore => {} - } - } -} - pub(crate) struct Output { pub(crate) stdout: String, pub(crate) state: State, @@ -110,13 +85,6 @@ impl Test { } } - pub(crate) fn ignore_stdout(self) -> Self { - Self { - expected_stdout: Expected::Ignore, - ..self - } - } - pub(crate) fn run(self) { self.output(); } diff --git a/tests/traits.rs b/tests/traits.rs index 213fb96b2d..96f2cb58f3 100644 --- a/tests/traits.rs +++ b/tests/traits.rs @@ -1,24 +1,5 @@ use super::*; -fn case(ordinal: u64, name: &str, value: &str) { - let stdout = Test::new() - .args(&["traits", &ordinal.to_string()]) - .ignore_stdout() - .output() - .stdout; - - let map = stdout - .lines() - .map(|line| line.split_once(": ").unwrap()) - .collect::>(); - - assert_eq!( - map.get(name), - Some(&value), - "Invalid value for {name}({ordinal})" - ); -} - #[test] fn invalid_ordinal() { Test::new() @@ -29,119 +10,22 @@ fn invalid_ordinal() { } #[test] -fn name() { - case(2099999997689999, "name", "a"); - case(2099999997689999 - 1, "name", "b"); - case(2099999997689999 - 25, "name", "z"); - case(2099999997689999 - 26, "name", "aa"); - case(0, "name", "nvtdijuwxlp"); - case(1, "name", "nvtdijuwxlo"); - case(26, "name", "nvtdijuwxkp"); - case(27, "name", "nvtdijuwxko"); -} - -#[test] -fn number() { - case(2099999997689999, "number", "2099999997689999"); -} - -#[test] -fn decimal() { - case(2099999997689999, "decimal", "6929999.0"); -} - -#[test] -fn height() { - case(0, "height", "0"); - case(1, "height", "0"); - case(50 * 100_000_000, "height", "1"); - case(2099999997689999, "height", "6929999"); - case(2099999997689998, "height", "6929998"); -} - -#[test] -fn cycle() { - case(0, "cycle", "0"); - case(2067187500000000 - 1, "cycle", "0"); - case(2067187500000000, "cycle", "1"); - case(2067187500000000 + 1, "cycle", "1"); -} - -#[test] -fn epoch() { - case(0, "epoch", "0"); - case(1, "epoch", "0"); - case(50 * 100_000_000 * 210000, "epoch", "1"); - case(2099999997689999, "epoch", "32"); -} - -#[test] -fn period() { - case(0, "period", "0"); - case(10075000000000, "period", "0"); - case(10080000000000 - 1, "period", "0"); - case(10080000000000, "period", "1"); - case(10080000000000 + 1, "period", "1"); - case(10085000000000, "period", "1"); - case(2099999997689999, "period", "3437"); -} - -#[test] -fn offset() { - case(0, "offset", "0"); - case(50 * 100_000_000 - 1, "offset", "4999999999"); - case(50 * 100_000_000, "offset", "0"); - case(50 * 100_000_000 + 1, "offset", "1"); -} - -#[test] -fn degree() { - case(0, "degree", "0°0′0″0‴"); - case(1, "degree", "0°0′0″1‴"); - - case(50 * 100_000_000 - 1, "degree", "0°0′0″4999999999‴"); - case(50 * 100_000_000, "degree", "0°1′1″0‴"); - case(50 * 100_000_000 + 1, "degree", "0°1′1″1‴"); - - case( - 50 * 100_000_000 * 2016 - 1, - "degree", - "0°2015′2015″4999999999‴", - ); - case(50 * 100_000_000 * 2016, "degree", "0°2016′0″0‴"); - case(50 * 100_000_000 * 2016 + 1, "degree", "0°2016′0″1‴"); - - case( - 50 * 100_000_000 * 210000 - 1, - "degree", - "0°209999′335″4999999999‴", - ); - case(50 * 100_000_000 * 210000, "degree", "0°0′336″0‴"); - case(50 * 100_000_000 * 210000 + 1, "degree", "0°0′336″1‴"); - - case(2067187500000000 - 1, "degree", "0°209999′2015″156249999‴"); - case(2067187500000000, "degree", "1°0′0″0‴"); - case(2067187500000000 + 1, "degree", "1°0′0″1‴"); -} - -#[test] -fn rarity() { - case(0, "rarity", "mythic"); - case(1, "rarity", "common"); - - case(50 * 100_000_000 - 1, "rarity", "common"); - case(50 * 100_000_000, "rarity", "uncommon"); - case(50 * 100_000_000 + 1, "rarity", "common"); - - case(50 * 100_000_000 * 2016 - 1, "rarity", "common"); - case(50 * 100_000_000 * 2016, "rarity", "rare"); - case(50 * 100_000_000 * 2016 + 1, "rarity", "common"); - - case(50 * 100_000_000 * 210000 - 1, "rarity", "common"); - case(50 * 100_000_000 * 210000, "rarity", "epic"); - case(50 * 100_000_000 * 210000 + 1, "rarity", "common"); - - case(2067187500000000 - 1, "rarity", "common"); - case(2067187500000000, "rarity", "legendary"); - case(2067187500000000 + 1, "rarity", "common"); +fn valid_ordinal() { + Test::new() + .args(&["traits", "0"]) + .expected_stdout( + "\ +number: 0 +decimal: 0.0 +degree: 0°0′0″0‴ +name: nvtdijuwxlp +height: 0 +cycle: 0 +epoch: 0 +period: 0 +offset: 0 +rarity: mythic +", + ) + .run(); } diff --git a/tests/wallet.rs b/tests/wallet.rs index 3a3d3dbf55..281132bdab 100644 --- a/tests/wallet.rs +++ b/tests/wallet.rs @@ -20,10 +20,14 @@ fn init_existing_wallet() { assert!(state .tempdir .path() - .join(path("ord/wallet.sqlite")) + .join(path("ord/regtest/wallet.sqlite")) .exists()); - assert!(state.tempdir.path().join(path("ord/entropy")).exists()); + assert!(state + .tempdir + .path() + .join(path("ord/regtest/entropy")) + .exists()); Test::with_state(state) .command("--network regtest wallet init") @@ -44,14 +48,14 @@ fn init_nonexistent_wallet() { .state .tempdir .path() - .join(path("ord/wallet.sqlite")) + .join(path("ord/regtest/wallet.sqlite")) .exists()); assert!(output .state .tempdir .path() - .join(path("ord/entropy")) + .join(path("ord/regtest/entropy")) .exists()); } @@ -64,7 +68,7 @@ fn load_corrupted_entropy() { .output() .state; - let entropy_path = state.tempdir.path().join(path("ord/entropy")); + let entropy_path = state.tempdir.path().join(path("ord/regtest/entropy")); assert!(entropy_path.exists()); @@ -122,7 +126,7 @@ fn utxos() { .state .client .generate_to_address( - 101, + 1, &Address::from_str( output .stdout @@ -211,7 +215,16 @@ fn send_owned_ordinal() { output .state .client - .generate_to_address(101, &from_address) + .generate_to_address(1, &from_address) + .unwrap(); + + output + .state + .client + .generate_to_address( + 100, + &Address::from_str("bcrt1qs758ursh4q9z627kt3pp5yysm78ddny6txaqgw").unwrap(), + ) .unwrap(); let mut output = Test::with_state(output.state) @@ -231,7 +244,7 @@ fn send_owned_ordinal() { .unwrap() ), 200, - "[[5000000000, 10000000000]]", + "[[5000000000,10000000000]]", ); let wallet = Wallet::new( @@ -257,8 +270,7 @@ fn send_owned_ordinal() { )) .expected_status(0) .stdout_regex(format!( - "Sent ordinal 5000000001 to address {to_address}: {}\n", - "[[:xdigit:]]{64}", + "Sent ordinal 5000000001 to address {to_address}: [[:xdigit:]]{{64}}\n" )) .output() .state; @@ -305,7 +317,7 @@ fn send_foreign_ordinal() { output .state .client - .generate_to_address(101, &from_address) + .generate_to_address(1, &from_address) .unwrap(); let mut output = Test::with_state(output.state) @@ -325,7 +337,7 @@ fn send_foreign_ordinal() { .unwrap() ), 200, - "[[5000000000, 10000000000]]", + "[[5000000000,10000000000]]", ); let wallet = Wallet::new(