ord 0.27.1

◉ Ordinal wallet and block explorer
Documentation
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<u8> },
  #[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<Self, Error> {
    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::<u8>().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<Address, Error> {
    use bitcoin::{
      CompressedPublicKey,
      key::PublicKey,
      secp256k1::{
        Message,
        ecdsa::{RecoverableSignature, RecoveryId},
        hashes::sha256::Hash,
      },
    };

    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 ORDINALS_URL: &str = concat!(
    "https://ordinals.com/satscard",
    "?u=S",
    "&o=0",
    "&r=4w9rytlk",
    "&n=b37ccaa62aa849e7",
    "&s=",
    "f1aab250cc6130e2937802a76bca3b03e8bebc33ba9cb77d72d5d9a0f0029fc7",
    "0f32f39b6ec52e2c275833aec27986257f18811487a54047d953b412a7836fb1",
  );

  pub(crate) fn ordinals_query() -> &'static str {
    ORDINALS_URL.split_once('?').unwrap().1
  }

  pub(crate) fn ordinals_satscard() -> Satscard {
    Satscard::from_query_parameters(Chain::Mainnet, ordinals_query()).unwrap()
  }

  pub(crate) fn ordinals_address() -> Address {
    "bc1q97v7v0jfe3wm82sm40nxguyj09rjv34w9rytlk"
      .parse::<Address<NetworkUnchecked>>()
      .unwrap()
      .require_network(Network::Bitcoin)
      .unwrap()
  }

  pub(crate) const COINKITE_URL: &str = concat!(
    "https://satscard.com/start",
    "#u=S",
    "&o=0",
    "&r=a5x2tplf",
    "&n=7664168a4ef7b8e8",
    "&s=",
    "42b209c86ab90be6418d36b0accc3a53c11901861b55be95b763799842d403dc",
    "17cd1b74695a7ffe2d78965535d6fe7f6aafc77f6143912a163cb65862e8fb53",
  );

  #[test]
  fn query_from_ordinals_url() {
    assert_eq!(
      ordinals_satscard(),
      Satscard {
        address: ordinals_address(),
        nonce: [0xb3, 0x7c, 0xca, 0xa6, 0x2a, 0xa8, 0x49, 0xe7],
        slot: 0,
        state: State::Sealed,
        query_parameters: ordinals_query().into(),
      }
    );
  }

  pub(crate) fn coinkite_fragment() -> &'static str {
    COINKITE_URL.split_once('#').unwrap().1
  }

  pub(crate) fn coinkite_satscard() -> Satscard {
    Satscard::from_query_parameters(Chain::Mainnet, coinkite_fragment()).unwrap()
  }

  pub(crate) fn coinkite_address() -> Address {
    "bc1ql86vqdwylsgmgkkrae5nrafte8yp43a5x2tplf"
      .parse::<Address<NetworkUnchecked>>()
      .unwrap()
      .require_network(Network::Bitcoin)
      .unwrap()
  }

  #[test]
  fn query_from_coinkite_url() {
    assert_eq!(
      coinkite_satscard(),
      Satscard {
        address: coinkite_address(),
        nonce: [0x76, 0x64, 0x16, 0x8a, 0x4e, 0xf7, 0xb8, 0xe8],
        slot: 0,
        state: State::Sealed,
        query_parameters: coinkite_fragment().into(),
      }
    );
  }
}