Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow viewing contents of satscard #4176

Merged
merged 30 commits into from
Jan 26, 2025
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions src/chain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions src/deserialize_from_str.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use super::*;

#[derive(Debug)]
pub struct DeserializeFromStr<T: FromStr>(pub T);

impl<'de, T: FromStr> Deserialize<'de> for DeserializeFromStr<T>
Expand Down
2 changes: 1 addition & 1 deletion src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ impl From<Error> 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<T, E>: Sized {
fn snafu_context<C, E2>(self, context: C) -> Result<T, E2>
Expand Down
5 changes: 5 additions & 0 deletions src/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
10 changes: 7 additions & 3 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand All @@ -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},
Expand Down Expand Up @@ -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;
Expand Down
39 changes: 39 additions & 0 deletions src/option_ext.rs
Original file line number Diff line number Diff line change
@@ -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<T>: Sized {
fn snafu_context<C, E>(self, context: C) -> Result<T, E>
where
C: snafu::IntoError<E, Source = snafu::NoneError>,
E: std::error::Error + snafu::ErrorCompat;

#[allow(unused)]
fn with_snafu_context<F, C, E>(self, context: F) -> Result<T, E>
where
F: FnOnce() -> C,
C: snafu::IntoError<E, Source = snafu::NoneError>,
E: std::error::Error + snafu::ErrorCompat;
}

impl<T> OptionExt<T> for Option<T> {
fn snafu_context<C, E>(self, context: C) -> Result<T, E>
where
C: snafu::IntoError<E, Source = snafu::NoneError>,
E: std::error::Error + snafu::ErrorCompat,
{
snafu::OptionExt::context(self, context)
}

fn with_snafu_context<F, C, E>(self, context: F) -> Result<T, E>
where
F: FnOnce() -> C,
C: snafu::IntoError<E, Source = snafu::NoneError>,
E: std::error::Error + snafu::ErrorCompat,
{
snafu::OptionExt::with_context(self, context)
}
}
2 changes: 2 additions & 0 deletions src/re.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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#(?<parameters>.*)");
pub(crate) static ref SAT_NAME: Regex = re(r"[a-z]{1,11}");
pub(crate) static ref SPACED_RUNE: Regex = re(r"[A-Z•.]+");
}
Expand Down
236 changes: 236 additions & 0 deletions src/satscard.rs
Original file line number Diff line number Diff line change
@@ -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<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::{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::<Address<NetworkUnchecked>>()
.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(),
}
);
}
}
Loading
Loading