From e5163a37c9a2eda1cfa624de94bccab6413c668a Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sun, 26 Jan 2025 14:03:23 -0800 Subject: [PATCH 1/2] Update install script (#4149) --- install.sh | 116 ++++++++++++++++++++++++++++++++++++----------------- 1 file changed, 80 insertions(+), 36 deletions(-) diff --git a/install.sh b/install.sh index a711ca2e89..74b2cb685d 100755 --- a/install.sh +++ b/install.sh @@ -1,11 +1,15 @@ -#!/usr/bin/env bash +#!/usr/bin/env sh -set -euo pipefail +set -eu -if [ ! -z ${GITHUB_ACTIONS-} ]; then +if [ -n "${GITHUB_ACTIONS-}" ]; then set -x fi +# Check pipefail support in a subshell, ignore if unsupported +# shellcheck disable=SC3040 +(set -o pipefail 2> /dev/null) && set -o pipefail + help() { cat <<'EOF' Install a binary release of ord hosted on GitHub @@ -18,7 +22,7 @@ FLAGS: -f, --force Force overwriting an existing binary OPTIONS: - --tag TAG Tag (version) of the crate to install, defaults to latest release + --tag TAG Tag (version) to install, defaults to latest release --to LOCATION Where to install the binary [default: ~/bin] --target TARGET EOF @@ -29,12 +33,12 @@ url=https://github.com/ordinals/ord releases=$url/releases say() { - echo "install.sh: $*" >&2 + echo "install: $*" >&2 } err() { - if [ ! -z ${tempdir-} ]; then - rm -rf $tempdir + if [ -n "${td-}" ]; then + rm -rf "$td" fi say "error: $*" @@ -42,11 +46,22 @@ err() { } need() { - if ! command -v $1 > /dev/null 2>&1; then + if ! command -v "$1" > /dev/null 2>&1; then err "need $1 (command not found)" fi } +download() { + url="$1" + output="$2" + + if command -v curl > /dev/null; then + curl --proto =https --tlsv1.2 -sSfL "$url" "-o$output" + else + wget --https-only --secure-protocol=TLSv1_2 --quiet "$url" "-O$output" + fi +} + force=false while test $# -gt 0; do case $1 in @@ -70,46 +85,74 @@ while test $# -gt 0; do shift ;; *) + say "error: unrecognized argument '$1'. Usage:" + help + exit 1 ;; esac shift done -# Dependencies -need curl -need install +command -v curl > /dev/null 2>&1 || + command -v wget > /dev/null 2>&1 || + err "need wget or curl (command not found)" + need mkdir need mktemp -need tar -dest=${dest-"$HOME/bin"} +if [ -z "${tag-}" ]; then + need grep + need cut +fi -if [ -z ${tag-} ]; then +if [ -z "${target-}" ]; then need cut +fi - tag=$(curl --proto =https --tlsv1.2 -sSf https://api.github.com/repos/ordinals/ord/releases/latest | +if [ -z "${dest-}" ]; then + dest="$HOME/bin" +fi + +if [ -z "${tag-}" ]; then + tag=$( + download https://api.github.com/repos/ordinals/ord/releases/latest - | grep tag_name | cut -d'"' -f4 ) fi -if [ -z ${target-} ]; then - uname_target=`uname -m`-`uname -s` +if [ -z "${target-}" ]; then + # bash compiled with MINGW (e.g. git-bash, used in github windows runners), + # unhelpfully includes a version suffix in `uname -s` output, so handle that. + # e.g. MINGW64_NT-10-0.19044 + kernel=$(uname -s | cut -d- -f1) + uname_target="$(uname -m)-$kernel" case $uname_target in arm64-Darwin) target=aarch64-apple-darwin;; x86_64-Darwin) target=x86_64-apple-darwin;; x86_64-Linux) target=x86_64-unknown-linux-gnu;; + x86_64-MINGW64_NT) target=x86_64-pc-windows-msvc;; + x86_64-Windows_NT) target=x86_64-pc-windows-msvc;; *) - say 'Could not determine target from output of `uname -m`-`uname -s`, please use `--target`:' $uname_target - say 'Target architecture is not supported by this install script.' - say 'Consider opening an issue or building from source: https://github.com/ordinals/ord' - exit 1 + # shellcheck disable=SC2016 + err 'Could not determine target from output of `uname -m`-`uname -s`, please use `--target`:' "$uname_target" ;; esac fi -archive="$releases/download/$tag/$crate-$tag-$target.tar.gz" +case $target in + x86_64-pc-windows-msvc) + extension=zip + need unzip + ;; + *) + extension=tar.gz + need tar + ;; +esac + +archive="$releases/download/$tag/$crate-$tag-$target.$extension" say "Repository: $url" say "Crate: $crate" @@ -118,20 +161,21 @@ say "Target: $target" say "Destination: $dest" say "Archive: $archive" -tempdir=`mktemp -d || mktemp -d -t tmp` +td=$(mktemp -d || mktemp -d -t tmp) -curl --proto =https --tlsv1.2 -sSfL $archive | tar --directory $tempdir --strip-components 1 -xz - -for name in `ls $tempdir`; do - file="$tempdir/$name" - test -x $file || continue +if [ "$extension" = "zip" ]; then + download "$archive" "$td/ord.zip" + unzip -jd "$td" "$td/ord.zip" +else + download "$archive" - | tar --directory "$td" --strip-components 1 -xz +fi - if [ -e "$dest/$name" ] && [ $force = false ]; then - err "$name already exists in $dest" - else - mkdir -p $dest - install -m 755 $file $dest - fi -done +if [ -e "$dest/ord" ] && [ "$force" = false ]; then + err "\`$dest/ord\` already exists" +else + mkdir -p "$dest" + cp "$td/ord" "$dest/ord" + chmod 755 "$dest/ord" +fi -rm -rf $tempdir +rm -rf "$td" From 604145736d043321dc075df893858181b2551361 Mon Sep 17 00:00:00 2001 From: Casey Rodarmor Date: Sun, 26 Jan 2025 15:28:04 -0800 Subject: [PATCH 2/2] Add /satscard page (#4176) --- Cargo.lock | 1 + Cargo.toml | 1 + docs/src/SUMMARY.md | 1 + docs/src/satscard.md | 65 +++++++++ src/chain.rs | 8 ++ src/deserialize_from_str.rs | 1 + src/error.rs | 2 +- src/index.rs | 5 + src/lib.rs | 10 +- src/option_ext.rs | 39 ++++++ src/re.rs | 2 + src/satscard.rs | 236 +++++++++++++++++++++++++++++++++ src/subcommand/server.rs | 254 +++++++++++++++++++++++++++++------- src/templates.rs | 2 + src/templates/address.rs | 6 +- src/templates/satscard.rs | 201 ++++++++++++++++++++++++++++ static/index.css | 12 ++ templates/address.html | 2 + templates/satscard.html | 35 +++++ 19 files changed, 833 insertions(+), 50 deletions(-) create mode 100644 docs/src/satscard.md create mode 100644 src/option_ext.rs create mode 100644 src/satscard.rs create mode 100644 src/templates/satscard.rs create mode 100644 templates/satscard.html diff --git a/Cargo.lock b/Cargo.lock index 71221677c2..2426a6115b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2567,6 +2567,7 @@ dependencies = [ "rust-embed", "rustls", "rustls-acme", + "secp256k1", "serde", "serde-hex", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 5560f01900..e6e615c640 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,6 +83,7 @@ rss = "2.0.1" rust-embed = "8.0.0" rustls = { version = "0.23.20", features = ["ring"] } rustls-acme = { version = "0.12.1", features = ["axum"] } +secp256k1 = { version = "*", features = ["global-context"] } serde-hex = "0.1.0" serde.workspace = true serde_json.workspace = true diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 86c1b6296b..bfd651d861 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -16,6 +16,7 @@ Summary - [Examples](inscriptions/examples.md) - [Runes](runes.md) - [Specification](runes/specification.md) +- [Satscard](satscard.md) - [FAQ](faq.md) - [Contributing](contributing.md) - [Donate](donate.md) diff --git a/docs/src/satscard.md b/docs/src/satscard.md new file mode 100644 index 0000000000..d56b86d67e --- /dev/null +++ b/docs/src/satscard.md @@ -0,0 +1,65 @@ +Satscard +======== + +[Satscards](https://satscard.com/) are cards which can be used to store +bitcoin, inscriptions, and runes. + +Slots +----- + +Each satscard has ten slots containing private keys with corresponding bitcoin +addresses. + +Initially, all slots are sealed and the private keys are stored only the +satscard. + +Slots can be unsealed, which allows the corresponding private key to be +extracted. + +Unsealing is permanent. If a satscard is sealed, you can have some confidence +that private key is not known to anyone. That taking physical ownership of a +satscard makes you the sole owner of assets in any sealed slots. + +Lifespan +-------- + +Satscards are expected to have a usable lifetime of ten years. Do not use +satscards for long-term storage of valuable assets. + + +Viewing +------- + +When placed on a smartphone, the satscard transmits a URL, beginning with +`https://satscard.com/start` or `https://getsatscard.com/start`, depending on +when it was manufactured. + +This URL contains a signature which can be used to recover the address of the +current slot. This signature is made over a random nonce, so it changes every +time the satscard is tapped, and provides some confidence that the satscard +contains the private key. + +`ord` supports viewing the contents of a satscard by entering the full URL into +the `ord` explorer search bar, or the input field on the `/satscard` page. + +For `ordinals.com`, this is +[ordinals.com/satscard](https://ordinals.com/satscard). + +Unsealing +--------- + +Satscard slots can be unsealed and the private keys extracted using the `cktap` +binary, available in the +[coinkite-tap-proto](https://github.com/coinkite/coinkite-tap-proto) +repository. + +Sweeping +-------- + +After a satscard slot is unsealed, all assets should be swept from that slot to +another wallet, as the private key can now be read via NFC. + +`ord` does not yet support sweeping assets from other wallets, so assets will +need to be transferred manually. + +Be careful, and good luck! diff --git a/src/chain.rs b/src/chain.rs index 68a520073a..bae81b584f 100644 --- a/src/chain.rs +++ b/src/chain.rs @@ -18,6 +18,14 @@ impl Chain { self.into() } + pub(crate) fn bech32_hrp(self) -> KnownHrp { + match self { + Self::Mainnet => KnownHrp::Mainnet, + Self::Regtest => KnownHrp::Regtest, + Self::Signet | Self::Testnet | Self::Testnet4 => KnownHrp::Testnets, + } + } + pub(crate) fn default_rpc_port(self) -> u16 { match self { Self::Mainnet => 8332, diff --git a/src/deserialize_from_str.rs b/src/deserialize_from_str.rs index edd90d9b76..f6cc4f7916 100644 --- a/src/deserialize_from_str.rs +++ b/src/deserialize_from_str.rs @@ -1,5 +1,6 @@ use super::*; +#[derive(Debug)] pub struct DeserializeFromStr(pub T); impl<'de, T: FromStr> Deserialize<'de> for DeserializeFromStr diff --git a/src/error.rs b/src/error.rs index 6a30013ab2..b676619a40 100644 --- a/src/error.rs +++ b/src/error.rs @@ -87,7 +87,7 @@ impl From for SnafuError { /// We currently use `anyhow` for error handling but are migrating to typed /// errors using `snafu`. This trait exists to provide access to /// `snafu::ResultExt::{context, with_context}`, which are otherwise shadowed -/// by `anhow::Context::{context, with_context}`. Once the migration is +/// by `anyhow::Context::{context, with_context}`. Once the migration is /// complete, this trait can be deleted, and `snafu::ResultExt` used directly. pub(crate) trait ResultExt: Sized { fn snafu_context(self, context: C) -> Result diff --git a/src/index.rs b/src/index.rs index ddcf9ddbcb..390a0ad43d 100644 --- a/src/index.rs +++ b/src/index.rs @@ -462,6 +462,11 @@ impl Index { }) } + #[cfg(test)] + pub(crate) fn chain(&self) -> Chain { + self.settings.chain() + } + pub fn have_full_utxo_index(&self) -> bool { self.first_index_height == 0 } diff --git a/src/lib.rs b/src/lib.rs index f9fb393fde..0efa7ffcdc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,8 +24,10 @@ use { teleburn, ParsedEnvelope, }, into_usize::IntoUsize, + option_ext::OptionExt, outgoing::Outgoing, representation::Representation, + satscard::Satscard, settings::Settings, signer::Signer, subcommand::{OutputFormat, Subcommand, SubcommandResult}, @@ -43,10 +45,10 @@ use { hash_types::{BlockHash, TxMerkleNode}, hashes::Hash, policy::MAX_STANDARD_TX_WEIGHT, - script, + script, secp256k1, transaction::Version, - Amount, Block, Network, OutPoint, Script, ScriptBuf, Sequence, SignedAmount, Transaction, TxIn, - TxOut, Txid, Witness, + Amount, Block, KnownHrp, Network, OutPoint, Script, ScriptBuf, Sequence, SignedAmount, + Transaction, TxIn, TxOut, Txid, Witness, }, bitcoincore_rpc::{Client, RpcApi}, chrono::{DateTime, TimeZone, Utc}, @@ -119,11 +121,13 @@ mod inscriptions; mod into_usize; mod macros; mod object; +mod option_ext; pub mod options; pub mod outgoing; mod re; mod representation; pub mod runes; +mod satscard; pub mod settings; mod signer; pub mod subcommand; diff --git a/src/option_ext.rs b/src/option_ext.rs new file mode 100644 index 0000000000..b101eeeede --- /dev/null +++ b/src/option_ext.rs @@ -0,0 +1,39 @@ +use super::*; + +/// We currently use `anyhow` for error handling but are migrating to typed +/// errors using `snafu`. This trait exists to provide access to +/// `snafu::OptionExt::{context, with_context}`, which are otherwise shadowed +/// by `anyhow::Context::{context, with_context}`. Once the migration is +/// complete, this trait can be deleted, and `snafu::OptionExt` used directly. +pub trait OptionExt: Sized { + fn snafu_context(self, context: C) -> Result + where + C: snafu::IntoError, + E: std::error::Error + snafu::ErrorCompat; + + #[allow(unused)] + fn with_snafu_context(self, context: F) -> Result + where + F: FnOnce() -> C, + C: snafu::IntoError, + E: std::error::Error + snafu::ErrorCompat; +} + +impl OptionExt for Option { + fn snafu_context(self, context: C) -> Result + where + C: snafu::IntoError, + E: std::error::Error + snafu::ErrorCompat, + { + snafu::OptionExt::context(self, context) + } + + fn with_snafu_context(self, context: F) -> Result + where + F: FnOnce() -> C, + C: snafu::IntoError, + E: std::error::Error + snafu::ErrorCompat, + { + snafu::OptionExt::with_context(self, context) + } +} diff --git a/src/re.rs b/src/re.rs index 7a82fac15d..d47dedc4d4 100644 --- a/src/re.rs +++ b/src/re.rs @@ -15,6 +15,8 @@ lazy_static! { pub(crate) static ref RUNE_ID: Regex = re(r"[0-9]+:[0-9]+"); pub(crate) static ref RUNE_NUMBER: Regex = re(r"-?[0-9]+"); pub(crate) static ref SATPOINT: Regex = re(r"[[:xdigit:]]{64}:\d+:\d+"); + pub(crate) static ref SATSCARD_URL: Regex = + re(r"https://(get)?satscard.com/start#(?.*)"); pub(crate) static ref SAT_NAME: Regex = re(r"[a-z]{1,11}"); pub(crate) static ref SPACED_RUNE: Regex = re(r"[A-Z•.]+"); } diff --git a/src/satscard.rs b/src/satscard.rs new file mode 100644 index 0000000000..2b0c1b9210 --- /dev/null +++ b/src/satscard.rs @@ -0,0 +1,236 @@ +use super::*; + +#[derive(Debug, PartialEq)] +pub(crate) enum State { + Error, + Sealed, + Unsealed, +} + +impl Display for State { + fn fmt(&self, f: &mut Formatter) -> fmt::Result { + match self { + Self::Error => write!(f, "error"), + Self::Sealed => write!(f, "sealed"), + Self::Unsealed => write!(f, "unsealed"), + } + } +} + +#[derive(Debug, Snafu)] +#[snafu(context(suffix(Error)))] +pub(crate) enum Error { + #[snafu(display("address recovery failed"))] + AddressRecovery, + #[snafu(display("duplicate key `{key}`"))] + DuplicateKey { key: String }, + #[snafu(display("parameter {parameter} has no value"))] + ParameterValueMissing { parameter: String }, + #[snafu(display("unrecognized state {value}"))] + State { value: String }, + #[snafu(display("invalid slot `{value}`: {source}"))] + Slot { + value: String, + source: std::num::ParseIntError, + }, + #[snafu(display("missing address suffix"))] + MissingAddressSuffix, + #[snafu(display("missing nonce"))] + MissingNonce, + #[snafu(display("missing signature"))] + MissingSignature, + #[snafu(display("missing slot"))] + MissingSlot, + #[snafu(display("missing state"))] + MissingState, + #[snafu(display("invalid nonce `{value}`: {source}"))] + Nonce { + value: String, + source: hex::FromHexError, + }, + #[snafu(display("invalid nonce length {}, expected 16 hex digits", nonce.len()))] + NonceLength { nonce: Vec }, + #[snafu(display("hex decoding signature `{value}` failed: {source}"))] + SignatureHex { + value: String, + source: hex::FromHexError, + }, + #[snafu(display("decoding signature failed: {source}"))] + SignatureDecode { source: secp256k1::Error }, + #[snafu(display("unknown key `{key}`"))] + UnknownKey { key: String }, +} + +#[derive(Debug, PartialEq)] +pub(crate) struct Satscard { + pub(crate) address: Address, + pub(crate) nonce: [u8; 8], + pub(crate) query_parameters: String, + pub(crate) slot: u8, + pub(crate) state: State, +} + +impl Satscard { + pub(crate) fn from_query_parameters(chain: Chain, query_parameters: &str) -> Result { + let mut address_suffix = None; + let mut nonce = Option::<[u8; 8]>::None; + let mut signature = None; + let mut slot = None; + let mut state = None; + + let mut keys = BTreeSet::new(); + for parameter in query_parameters.split('&') { + let (key, value) = parameter + .split_once('=') + .snafu_context(ParameterValueMissingError { parameter })?; + + if !keys.insert(key) { + return Err(DuplicateKeyError { key }.build()); + } + + match key { + "u" => { + state = Some(match value { + "S" => State::Sealed, + "E" => State::Error, + "U" => State::Unsealed, + _ => { + return Err(StateError { value }.build()); + } + }) + } + "o" => slot = Some(value.parse::().snafu_context(SlotError { value })?), + "r" => address_suffix = Some(value), + "n" => { + nonce = Some({ + let nonce = hex::decode(value).snafu_context(NonceError { value })?; + nonce + .as_slice() + .try_into() + .ok() + .snafu_context(NonceLengthError { nonce })? + }) + } + "s" => { + signature = Some({ + let signature = hex::decode(value).snafu_context(SignatureHexError { value })?; + secp256k1::ecdsa::Signature::from_compact(&signature) + .snafu_context(SignatureDecodeError)? + }); + } + _ => return Err(UnknownKeyError { key }.build()), + } + } + + let address_suffix = address_suffix.snafu_context(MissingAddressSuffixError)?; + let nonce = nonce.snafu_context(MissingNonceError)?; + let signature = signature.snafu_context(MissingSignatureError)?; + let slot = slot.snafu_context(MissingSlotError)?; + let state = state.snafu_context(MissingStateError)?; + + let message = &query_parameters[0..query_parameters.rfind('=').unwrap() + 1]; + + let address = Self::recover_address(address_suffix, chain, message, &signature)?; + + Ok(Self { + address, + nonce, + query_parameters: query_parameters.into(), + slot, + state, + }) + } + + fn recover_address( + address_suffix: &str, + chain: Chain, + message: &str, + signature: &secp256k1::ecdsa::Signature, + ) -> Result { + use { + bitcoin::{key::PublicKey, CompressedPublicKey}, + secp256k1::{ + ecdsa::{RecoverableSignature, RecoveryId}, + hashes::sha256::Hash, + Message, + }, + }; + + let signature_compact = signature.serialize_compact(); + + let message = Message::from_digest(*Hash::hash(message.as_bytes()).as_ref()); + + for i in 0.. { + let Ok(id) = RecoveryId::from_i32(i) else { + break; + }; + + let recoverable_signature = + RecoverableSignature::from_compact(&signature_compact, id).unwrap(); + + let Ok(public_key) = recoverable_signature.recover(&message) else { + continue; + }; + + signature.verify(&message, &public_key).unwrap(); + + let public_key = PublicKey::new(public_key); + + let public_key = CompressedPublicKey::try_from(public_key).unwrap(); + + let address = Address::p2wpkh(&public_key, chain.bech32_hrp()); + + if address.to_string().ends_with(&address_suffix) { + return Ok(address); + } + } + + Err(Error::AddressRecovery) + } +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + pub(crate) const URL: &str = concat!( + "https://satscard.com/start", + "#u=S", + "&o=0", + "&r=a5x2tplf", + "&n=7664168a4ef7b8e8", + "&s=", + "42b209c86ab90be6418d36b0accc3a53c11901861b55be95b763799842d403dc", + "17cd1b74695a7ffe2d78965535d6fe7f6aafc77f6143912a163cb65862e8fb53", + ); + + pub(crate) fn query_parameters() -> &'static str { + URL.split_once('#').unwrap().1 + } + + pub(crate) fn satscard() -> Satscard { + Satscard::from_query_parameters(Chain::Mainnet, query_parameters()).unwrap() + } + + pub(crate) fn address() -> Address { + "bc1ql86vqdwylsgmgkkrae5nrafte8yp43a5x2tplf" + .parse::>() + .unwrap() + .require_network(Network::Bitcoin) + .unwrap() + } + + #[test] + fn query_from_coinkite_url() { + assert_eq!( + satscard(), + Satscard { + address: address(), + nonce: [0x76, 0x64, 0x16, 0x8a, 0x4e, 0xf7, 0xb8, 0xe8], + slot: 0, + state: State::Sealed, + query_parameters: query_parameters().into(), + } + ); + } +} diff --git a/src/subcommand/server.rs b/src/subcommand/server.rs index 1d410f336f..4514a529bc 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, RareTxt, RuneHtml, RuneNotFoundHtml, RunesHtml, SatHtml, TransactionHtml, + PreviewVideoHtml, RareTxt, RuneHtml, RuneNotFoundHtml, RunesHtml, SatHtml, SatscardHtml, + TransactionHtml, }, axum::{ extract::{DefaultBodyLimit, Extension, Json, Path, Query}, @@ -196,6 +197,7 @@ impl Server { .route("/collections", get(Self::collections)) .route("/collections/{page}", get(Self::collections_paginated)) .route("/content/{inscription_id}", get(Self::content)) + .route("/decode/{txid}", get(Self::decode)) .route("/faq", get(Self::faq)) .route("/favicon.ico", get(Self::favicon)) .route("/feed.xml", get(Self::feed)) @@ -207,7 +209,6 @@ impl Server { ) .route("/inscriptions", get(Self::inscriptions)) .route("/inscriptions", post(Self::inscriptions_json)) - .route("/inscriptions/{page}", get(Self::inscriptions_paginated)) .route( "/inscriptions/block/{height}", get(Self::inscriptions_in_block), @@ -216,6 +217,7 @@ impl Server { "/inscriptions/block/{height}/{page}", get(Self::inscriptions_in_block_paginated), ) + .route("/inscriptions/{page}", get(Self::inscriptions_paginated)) .route("/install.sh", get(Self::install_script)) .route("/ordinal/{sat}", get(Self::ordinal)) .route("/output/{output}", get(Self::output)) @@ -233,20 +235,12 @@ impl Server { get(Self::block_hash_from_height_json), ) .route("/r/blockheight", get(Self::block_height)) - .route("/r/blocktime", get(Self::block_time)) .route("/r/blockinfo/{query}", get(Self::block_info)) - .route( - "/r/inscription/{inscription_id}", - get(Self::inscription_recursive), - ) + .route("/r/blocktime", get(Self::block_time)) .route( "/r/children/{inscription_id}", get(Self::children_recursive), ) - .route( - "/r/children/{inscription_id}/{page}", - get(Self::children_recursive_paginated), - ) .route( "/r/children/{inscription_id}/inscriptions", get(Self::child_inscriptions_recursive), @@ -256,8 +250,12 @@ impl Server { get(Self::child_inscriptions_recursive_paginated), ) .route( - "/r/undelegated-content/{inscription_id}", - get(Self::undelegated_content), + "/r/children/{inscription_id}/{page}", + get(Self::children_recursive_paginated), + ) + .route( + "/r/inscription/{inscription_id}", + get(Self::inscription_recursive), ) .route("/r/metadata/{inscription_id}", get(Self::metadata)) .route("/r/parents/{inscription_id}", get(Self::parents_recursive)) @@ -266,10 +264,6 @@ impl Server { get(Self::parents_recursive_paginated), ) .route("/r/sat/{sat_number}", get(Self::sat_inscriptions)) - .route( - "/r/sat/{sat_number}/{page}", - get(Self::sat_inscriptions_paginated), - ) .route( "/r/sat/{sat_number}/at/{index}", get(Self::sat_inscription_at_index), @@ -278,6 +272,14 @@ impl Server { "/r/sat/{sat_number}/at/{index}/content", get(Self::sat_inscription_at_index_content), ) + .route( + "/r/sat/{sat_number}/{page}", + get(Self::sat_inscriptions_paginated), + ) + .route( + "/r/undelegated-content/{inscription_id}", + get(Self::undelegated_content), + ) .route("/r/utxo/{outpoint}", get(Self::utxo_recursive)) .route("/rare.txt", get(Self::rare_txt)) .route("/rune/{rune}", get(Self::rune)) @@ -285,12 +287,12 @@ impl Server { .route("/runes/{page}", get(Self::runes_paginated)) .route("/sat/{sat}", get(Self::sat)) .route("/satpoint/{satpoint}", get(Self::satpoint)) + .route("/satscard", get(Self::satscard)) .route("/search", get(Self::search_by_query)) .route("/search/{*query}", get(Self::search_by_path)) .route("/static/{*path}", get(Self::static_asset)) .route("/status", get(Self::status)) .route("/tx/{txid}", get(Self::transaction)) - .route("/decode/{txid}", get(Self::decode)) .route("/update", get(Self::update)) .fallback(Self::fallback) .layer(Extension(index)) @@ -568,6 +570,60 @@ impl Server { }) } + async fn satscard( + Extension(settings): Extension>, + Extension(server_config): Extension>, + Extension(index): Extension>, + uri: Uri, + ) -> ServerResult { + #[derive(Debug, Deserialize)] + struct Form { + url: DeserializeFromStr, + } + + if let Ok(form) = Query::
::try_from_uri(&uri) { + return if let Some(fragment) = form.url.0.fragment() { + Ok(Redirect::to(&format!("/satscard?{}", fragment)).into_response()) + } else { + Err(ServerError::BadRequest( + "satscard URL missing fragment".into(), + )) + }; + } + + let satscard = if let Some(query) = uri.query() { + let satscard = Satscard::from_query_parameters(settings.chain(), query).map_err(|err| { + ServerError::BadRequest(format!("invalid satscard query parameters: {err}")) + })?; + + let address_info = Self::address_info(&index, &satscard.address)?.map( + |api::AddressInfo { + outputs, + inscriptions, + sat_balance, + runes_balances, + }| AddressHtml { + address: satscard.address.clone(), + header: false, + inscriptions, + outputs, + runes_balances, + sat_balance, + }, + ); + + Some((satscard, address_info)) + } else { + None + }; + + Ok( + SatscardHtml { satscard } + .page(server_config) + .into_response(), + ) + } + async fn sat( Extension(server_config): Extension>, Extension(index): Extension>, @@ -990,41 +1046,33 @@ impl Server { AcceptJson(accept_json): AcceptJson, ) -> ServerResult { task::block_in_place(|| { - if !index.has_address_index() { - return Err(ServerError::NotFound( - "this server has no address index".to_string(), - )); - } - let address = address .require_network(server_config.chain.network()) .map_err(|err| ServerError::BadRequest(err.to_string()))?; - let mut outputs = index.get_address_info(&address)?; - - outputs.sort(); - - let sat_balance = index.get_sat_balances_for_outputs(&outputs)?; - - let inscriptions = index.get_inscriptions_for_outputs(&outputs)?; - - let runes_balances = index.get_aggregated_rune_balances_for_outputs(&outputs)?; + let Some(info) = Self::address_info(&index, &address)? else { + return Err(ServerError::NotFound( + "this server has no address index".to_string(), + )); + }; Ok(if accept_json { - Json(api::AddressInfo { + Json(info).into_response() + } else { + let api::AddressInfo { sat_balance, outputs, inscriptions, runes_balances, - }) - .into_response() - } else { + } = info; + AddressHtml { address, - outputs, + header: true, inscriptions, - sat_balance, + outputs, runes_balances, + sat_balance, } .page(server_config) .into_response() @@ -1032,6 +1080,29 @@ impl Server { }) } + fn address_info(index: &Index, address: &Address) -> ServerResult> { + if !index.has_address_index() { + return Ok(None); + } + + let mut outputs = index.get_address_info(address)?; + + outputs.sort(); + + let sat_balance = index.get_sat_balances_for_outputs(&outputs)?; + + let inscriptions = index.get_inscriptions_for_outputs(&outputs)?; + + let runes_balances = index.get_aggregated_rune_balances_for_outputs(&outputs)?; + + Ok(Some(api::AddressInfo { + sat_balance, + outputs, + inscriptions, + runes_balances, + })) + } + async fn block( Extension(server_config): Extension>, Extension(index): Extension>, @@ -1294,10 +1365,6 @@ impl Server { } async fn search(index: Arc, query: String) -> ServerResult { - Self::search_inner(index, query).await - } - - async fn search_inner(index: Arc, query: String) -> ServerResult { task::block_in_place(|| { let query = query.trim(); @@ -1311,6 +1378,11 @@ impl Server { Ok(Redirect::to(&format!("/output/{query}"))) } else if re::INSCRIPTION_ID.is_match(query) || re::INSCRIPTION_NUMBER.is_match(query) { Ok(Redirect::to(&format!("/inscription/{query}"))) + } else if let Some(captures) = re::SATSCARD_URL.captures(query) { + Ok(Redirect::to(&format!( + "/satscard?{}", + &captures["parameters"] + ))) } else if re::SPACED_RUNE.is_match(query) { Ok(Redirect::to(&format!("/rune/{query}"))) } else if re::RUNE_ID.is_match(query) { @@ -2542,6 +2614,10 @@ mod tests { self.server_flag("--https") } + fn index_addresses(self) -> Self { + self.ord_flag("--index-addresses") + } + fn index_runes(self) -> Self { self.ord_flag("--index-runes") } @@ -2704,7 +2780,7 @@ mod tests { let expected_response = PageHtml::new( content, Arc::new(ServerConfig { - chain: Chain::Regtest, + chain: self.index.chain(), domain: Some(System::host_name().unwrap()), ..Default::default() }), @@ -2984,6 +3060,18 @@ mod tests { TestServer::new().assert_redirect("/search?query=AB•CD", "/rune/AB•CD"); } + #[test] + fn search_by_query_returns_satscard() { + TestServer::new().assert_redirect( + "/search?query=https://satscard.com/start%23foo", + "/satscard?foo", + ); + TestServer::new().assert_redirect( + "/search?query=https://getsatscard.com/start%23foo", + "/satscard?foo", + ); + } + #[test] fn search_by_query_returns_inscription() { TestServer::new().assert_redirect( @@ -7631,6 +7719,84 @@ next ); } + #[test] + fn satscard_form_redirects_to_query() { + TestServer::new().assert_redirect( + &format!( + "/satscard?url={}", + urlencoding::encode(satscard::tests::URL) + ), + &format!("/satscard?{}", satscard::tests::query_parameters()), + ); + } + + #[test] + fn satscard_missing_form_query_is_error() { + TestServer::new().assert_response( + "/satscard?url=https://foo.com", + StatusCode::BAD_REQUEST, + "satscard URL missing fragment", + ); + } + + #[test] + fn satscard_invalid_query_parameters() { + TestServer::new().assert_response( + "/satscard?foo=bar", + StatusCode::BAD_REQUEST, + "invalid satscard query parameters: unknown key `foo`", + ); + } + + #[test] + fn satscard_display_without_address_index() { + TestServer::builder() + .chain(Chain::Mainnet) + .build() + .assert_html( + format!("/satscard?{}", satscard::tests::query_parameters()), + SatscardHtml { + satscard: Some((satscard::tests::satscard(), None)), + }, + ); + } + + #[test] + fn satscard_display_with_address_index_empty() { + TestServer::builder() + .chain(Chain::Mainnet) + .index_addresses() + .build() + .assert_html( + format!("/satscard?{}", satscard::tests::query_parameters()), + SatscardHtml { + satscard: Some(( + satscard::tests::satscard(), + Some(AddressHtml { + address: satscard::tests::address(), + header: false, + inscriptions: Some(Vec::new()), + outputs: Vec::new(), + runes_balances: None, + sat_balance: 0, + }), + )), + }, + ); + } + + #[test] + fn satscard_address_recovery_fails_on_wrong_chain() { + TestServer::builder() + .chain(Chain::Testnet) + .build() + .assert_response( + format!("/satscard?{}", satscard::tests::query_parameters()), + StatusCode::BAD_REQUEST, + "invalid satscard query parameters: address recovery failed", + ); + } + #[test] fn sat_inscription_at_index_content_endpoint() { let server = TestServer::builder() diff --git a/src/templates.rs b/src/templates.rs index fa54b01725..e8d603601f 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -22,6 +22,7 @@ pub(crate) use { rare::RareTxt, rune_not_found::RuneNotFoundHtml, sat::SatHtml, + satscard::SatscardHtml, }; pub use { @@ -50,6 +51,7 @@ pub mod rune; pub mod rune_not_found; pub mod runes; pub mod sat; +mod satscard; pub mod status; pub mod transaction; diff --git a/src/templates/address.rs b/src/templates/address.rs index 685835bb7a..00d20f1393 100644 --- a/src/templates/address.rs +++ b/src/templates/address.rs @@ -3,10 +3,11 @@ use super::*; #[derive(Boilerplate)] pub(crate) struct AddressHtml { pub(crate) address: Address, - pub(crate) outputs: Vec, + pub(crate) header: bool, pub(crate) inscriptions: Option>, - pub(crate) sat_balance: u64, + pub(crate) outputs: Vec, pub(crate) runes_balances: Option)>>, + pub(crate) sat_balance: u64, } impl PageContent for AddressHtml { @@ -25,6 +26,7 @@ mod tests { .unwrap() .require_network(Network::Bitcoin) .unwrap(), + header: true, outputs: vec![outpoint(1), outpoint(2)], inscriptions: Some(vec![inscription_id(1)]), sat_balance: 99, diff --git a/src/templates/satscard.rs b/src/templates/satscard.rs new file mode 100644 index 0000000000..9874f254dd --- /dev/null +++ b/src/templates/satscard.rs @@ -0,0 +1,201 @@ +use super::*; + +#[derive(Boilerplate)] +pub(crate) struct SatscardHtml { + pub(crate) satscard: Option<(Satscard, Option)>, +} + +impl SatscardHtml { + fn form_value(&self) -> Option { + self.satscard.as_ref().map(|(satscard, _address_info)| { + format!("https://satscard.com/start#{}", satscard.query_parameters) + }) + } +} + +impl PageContent for SatscardHtml { + fn title(&self) -> String { + if let Some((satscard, _address_info)) = &self.satscard { + format!("Satscard {}", satscard.address) + } else { + "Satscard".into() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn form_value() { + assert_eq!( + SatscardHtml { + satscard: Some((crate::satscard::tests::satscard(), None)), + } + .form_value(), + Some(crate::satscard::tests::URL.into()), + ); + + assert_eq!(SatscardHtml { satscard: None }.form_value(), None); + } + + #[test] + fn title() { + assert_eq!( + SatscardHtml { + satscard: Some((crate::satscard::tests::satscard(), None)), + } + .title(), + format!("Satscard {}", crate::satscard::tests::address()) + ); + + assert_eq!(SatscardHtml { satscard: None }.title(), "Satscard"); + } + + #[test] + fn no_address_info() { + pretty_assert_eq!( + SatscardHtml { + satscard: Some((crate::satscard::tests::satscard(), None)), + } + .to_string(), + r#"

Satscard bc1ql86vqdwylsgmgkkrae5nrafte8yp43a5x2tplf

+ + + + + +
+
slot
+
1
+
state
+
sealed
+
address
+
bc1ql86vqdwylsgmgkkrae5nrafte8yp43a5x2tplf
+
nonce
+
7664168a4ef7b8e8
+
+"#, + ); + } + + #[test] + fn with_address_info() { + pretty_assert_eq!( + SatscardHtml { + satscard: Some(( + crate::satscard::tests::satscard(), + Some(AddressHtml { + address: crate::satscard::tests::address(), + header: false, + inscriptions: Some(Vec::new()), + outputs: Vec::new(), + runes_balances: None, + sat_balance: 0, + }) + )), + } + .to_string(), + r#"

Satscard bc1ql86vqdwylsgmgkkrae5nrafte8yp43a5x2tplf

+
+ + + +
+
+
slot
+
1
+
state
+
sealed
+
address
+
bc1ql86vqdwylsgmgkkrae5nrafte8yp43a5x2tplf
+
nonce
+
7664168a4ef7b8e8
+
+
+
sat balance
+
0
+
outputs
+
+
    +
+
+
+ +"#, + ); + } + + #[test] + fn state_error() { + assert_regex_match! { + SatscardHtml { + satscard: Some(( + Satscard { + state: crate::satscard::State::Error, + ..crate::satscard::tests::satscard() + }, + Some(AddressHtml { + address: crate::satscard::tests::address(), + header: false, + inscriptions: Some(Vec::new()), + outputs: Vec::new(), + runes_balances: None, + sat_balance: 0, + }) + )), + } + .to_string(), + r#".* +
state
+
error
+.* +"#, + } + } + + #[test] + fn state_unsealed() { + assert_regex_match! { + SatscardHtml { + satscard: Some(( + Satscard { + state: crate::satscard::State::Unsealed, + ..crate::satscard::tests::satscard() + }, + Some(AddressHtml { + address: crate::satscard::tests::address(), + header: false, + inscriptions: Some(Vec::new()), + outputs: Vec::new(), + runes_balances: None, + sat_balance: 0, + }) + )), + } + .to_string(), + r#".* +
state
+
unsealed
+.* +"#, + } + } +} diff --git a/static/index.css b/static/index.css index bee2f4e6e0..1969da3443 100644 --- a/static/index.css +++ b/static/index.css @@ -250,6 +250,18 @@ a.mythic { width: 100%; } +.satscard-error { + color: red; +} + +.satscard-sealed { + color: green; +} + +.satscard-unsealed { + color: yellow; +} + .tabs { align-items: center; display: flex; diff --git a/templates/address.html b/templates/address.html index 677290a0c2..bf7263a344 100644 --- a/templates/address.html +++ b/templates/address.html @@ -1,4 +1,6 @@ +%% if self.header {

Address {{ self.address }}

+%% }
sat balance
{{ self.sat_balance }}
diff --git a/templates/satscard.html b/templates/satscard.html new file mode 100644 index 0000000000..ae14871311 --- /dev/null +++ b/templates/satscard.html @@ -0,0 +1,35 @@ +%% if let Some((Satscard { address, .. }, _address_info)) = &self.satscard { +

Satscard {{ address }}

+%% } else { +

Satscard

+%% } +
+ + + +
+%% if let Some((Satscard { address, nonce, slot, state, .. }, address_info)) = &self.satscard { +
+
slot
+
{{ slot + 1 }}
+
state
+
{{ state }}
+
address
+
{{ address }}
+
nonce
+
{{ hex::encode(nonce) }}
+
+%% if let Some(address_info) = &address_info { +{{ Trusted(address_info) }} +%% } +%% }