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(),
}
);
}
}