use std::sync::Arc;
use async_trait::async_trait;
use ed25519_dalek::Verifier;
use sha3::{Digest, Keccak256};
use crate::Chain;
const EIP_6492_MAGIC: &[u8] = &[
0x64, 0x92, 0x64, 0x92, 0x64, 0x92, 0x64, 0x92, 0x64, 0x92, 0x64, 0x92, 0x64, 0x92, 0x64, 0x92,
0x64, 0x92, 0x64, 0x92, 0x64, 0x92, 0x64, 0x92, 0x64, 0x92, 0x64, 0x92, 0x64, 0x92, 0x64, 0x92,
];
pub(crate) fn keccak256(data: &[u8]) -> [u8; 32] {
Keccak256::digest(data).into()
}
pub(crate) fn eip191_hash(message: &str) -> [u8; 32] {
let prefix = format!("\x19Ethereum Signed Message:\n{}", message.len());
let mut data = Vec::with_capacity(prefix.len() + message.len());
data.extend_from_slice(prefix.as_bytes());
data.extend_from_slice(message.as_bytes());
keccak256(&data)
}
#[must_use]
pub(crate) fn eip55_checksum(lowercase_hex: &str) -> String {
let hash = keccak256(lowercase_hex.as_bytes());
let mut out = String::with_capacity(42);
out.push_str("0x");
for (i, ch) in lowercase_hex.chars().enumerate() {
if ch.is_ascii_alphabetic() {
let nibble = if i % 2 == 0 {
hash[i / 2] >> 4
} else {
hash[i / 2] & 0x0f
};
if nibble >= 8 {
out.push(ch.to_ascii_uppercase());
} else {
out.push(ch);
}
} else {
out.push(ch);
}
}
out
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum AddressError {
#[error("address must start with 0x")]
MissingPrefix,
#[error("address must be 40 hex characters after 0x")]
InvalidEvmHex,
#[error("the mixed-case address has a bad EIP-55 checksum")]
BadChecksum,
#[error("address is not valid base58")]
InvalidBase58,
#[error("Solana address must decode to 32 bytes")]
InvalidSolanaLength,
}
pub(crate) fn normalize_evm_address(raw: &str) -> Result<String, AddressError> {
let hex = raw
.strip_prefix("0x")
.or_else(|| raw.strip_prefix("0X"))
.ok_or(AddressError::MissingPrefix)?;
if hex.len() != 40 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(AddressError::InvalidEvmHex);
}
let lower = hex.to_ascii_lowercase();
let is_lower = hex == lower;
let is_upper = hex == hex.to_ascii_uppercase();
let checksummed = eip55_checksum(&lower);
if !is_lower && !is_upper && hex != &checksummed[2..] {
return Err(AddressError::BadChecksum);
}
Ok(checksummed)
}
pub(crate) fn normalize_solana_address(raw: &str) -> Result<String, AddressError> {
let bytes = bs58::decode(raw)
.into_vec()
.map_err(|_| AddressError::InvalidBase58)?;
if bytes.len() != 32 {
return Err(AddressError::InvalidSolanaLength);
}
Ok(bs58::encode(&bytes).into_string())
}
fn recover_evm_address(hash: &[u8; 32], signature: &[u8; 65]) -> Option<[u8; 20]> {
let v = signature[64];
let recovery_byte = match v {
0 | 1 => v,
27 | 28 => v - 27,
_ => return None,
};
let recovery_id = k256::ecdsa::RecoveryId::from_byte(recovery_byte)?;
let sig = k256::ecdsa::Signature::from_slice(&signature[..64]).ok()?;
let vk = k256::ecdsa::VerifyingKey::recover_from_prehash(hash, &sig, recovery_id).ok()?;
let point = vk.to_sec1_point(false);
let bytes = point.as_bytes();
if bytes.len() != 65 || bytes[0] != 0x04 {
return None;
}
let pubkey = &bytes[1..];
let addr_hash = keccak256(pubkey);
let mut addr = [0u8; 20];
addr.copy_from_slice(&addr_hash[12..]);
Some(addr)
}
fn has_eip6492_suffix(signature: &[u8]) -> bool {
signature.len() >= EIP_6492_MAGIC.len() && signature.ends_with(EIP_6492_MAGIC)
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum ContractVerifierError {
#[error("the contract verifier is not configured")]
NotConfigured,
#[error("the contract verifier could not answer: {0}")]
Upstream(String),
}
#[async_trait]
pub trait ContractSignatureVerifier: Send + Sync {
async fn is_valid_signature(
&self,
chain_id: &str,
address: &str,
message_hash: [u8; 32],
signature: &[u8],
) -> Result<bool, ContractVerifierError>;
}
#[derive(Clone)]
pub struct StaticContractVerifier {
address: String,
hash: Option<[u8; 32]>,
}
impl StaticContractVerifier {
#[must_use]
pub fn new(address: String, message: &str) -> Self {
Self {
address,
hash: Some(eip191_hash(message)),
}
}
#[must_use]
pub fn approving(address: String) -> Self {
Self {
address,
hash: None,
}
}
}
#[async_trait]
impl ContractSignatureVerifier for StaticContractVerifier {
async fn is_valid_signature(
&self,
_chain_id: &str,
address: &str,
message_hash: [u8; 32],
_signature: &[u8],
) -> Result<bool, ContractVerifierError> {
Ok(address == self.address && self.hash.is_none_or(|hash| hash == message_hash))
}
}
pub(crate) async fn verify_evm(
message_address_hex: &str,
message: &str,
signature: &[u8],
chain_id: &str,
verifier: Option<&Arc<dyn ContractSignatureVerifier>>,
) -> Result<String, VerifyError> {
let hash = eip191_hash(message);
if signature.len() == 65 && !has_eip6492_suffix(signature) {
let fixed: [u8; 65] = signature.try_into().expect("checked length");
if let Some(recovered) = recover_evm_address(&hash, &fixed) {
let recovered_hex = format!("0x{}", hex::encode(recovered));
let normalised = normalize_evm_address(&recovered_hex)?;
let expected = normalize_evm_address(message_address_hex)?;
if normalised.eq_ignore_ascii_case(&expected) {
return Ok(normalised);
}
}
}
let verifier = verifier.ok_or(VerifyError::NoVerifier)?;
let address = normalize_evm_address(message_address_hex)?;
match verifier
.is_valid_signature(chain_id, &address, hash, signature)
.await
{
Ok(true) => Ok(address),
Ok(false) => Err(VerifyError::SignatureInvalid),
Err(err) => Err(VerifyError::Verifier(err.to_string())),
}
}
pub(crate) fn verify_solana(
message_address: &str,
message: &str,
signature: &[u8],
) -> Result<String, VerifyError> {
let address_bytes = bs58::decode(message_address)
.into_vec()
.map_err(|_| VerifyError::SignatureInvalid)?;
if address_bytes.len() != 32 {
return Err(VerifyError::SignatureInvalid);
}
let key_bytes: [u8; 32] = address_bytes.try_into().expect("checked length");
let vk = ed25519_dalek::VerifyingKey::from_bytes(&key_bytes)
.map_err(|_| VerifyError::SignatureInvalid)?;
let sig = ed25519_dalek::Signature::from_slice(signature)
.map_err(|_| VerifyError::SignatureInvalid)?;
vk.verify(message.as_bytes(), &sig)
.map_err(|_| VerifyError::SignatureInvalid)?;
normalize_solana_address(message_address).map_err(|_| VerifyError::SignatureInvalid)
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum VerifyError {
#[error("the signature does not prove ownership of the address")]
SignatureInvalid,
#[error("no contract verifier is configured for this chain")]
NoVerifier,
#[error("the contract verifier could not answer: {0}")]
Verifier(String),
#[error("the address is not valid: {0}")]
Address(#[from] AddressError),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ParsedMessage {
pub(crate) domain: String,
pub(crate) chain: Chain,
pub(crate) address: String,
pub(crate) statement: Option<String>,
pub(crate) uri: String,
pub(crate) version: String,
pub(crate) chain_id: String,
pub(crate) nonce: String,
pub(crate) issued_at: String,
pub(crate) expiration_time: Option<String>,
pub(crate) not_before: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum MessageError {
#[error("the message is too short to be a sign-in message")]
TooShort,
#[error("the message header is not a SIWE or SIWS header")]
UnknownHeader,
#[error("the message is missing a required field: {0}")]
MissingField(&'static str),
#[error("the message is missing the blank line between address and fields")]
MissingBlankLine,
}
const KNOWN_FIELDS: &[&str] = &[
"URI:",
"Version:",
"Chain ID:",
"Nonce:",
"Issued At:",
"Expiration Time:",
"Not Before:",
];
pub(crate) fn parse_sign_in_message(message: &str) -> Result<ParsedMessage, MessageError> {
let lines: Vec<&str> = message.lines().collect();
if lines.len() < 7 {
return Err(MessageError::TooShort);
}
let header = lines[0];
let (domain, chain) = if let Some(rest) =
header.strip_suffix(" wants you to sign in with your Ethereum account:")
{
(rest.to_owned(), Chain::Evm)
} else if let Some(rest) =
header.strip_suffix(" wants you to sign in with your Solana account:")
{
(rest.to_owned(), Chain::Solana)
} else {
return Err(MessageError::UnknownHeader);
};
if domain.is_empty() {
return Err(MessageError::MissingField("domain"));
}
let address = lines[1].to_owned();
if address.is_empty() {
return Err(MessageError::MissingField("address"));
}
if !lines[2].is_empty() {
return Err(MessageError::MissingBlankLine);
}
let mut statement_lines: Vec<&str> = Vec::new();
let mut field_start = lines.len();
for (i, line) in lines.iter().enumerate().skip(3) {
if KNOWN_FIELDS.iter().any(|f| line.starts_with(f)) {
field_start = i;
break;
}
statement_lines.push(line);
}
let statement = if statement_lines.is_empty() {
None
} else {
Some(statement_lines.join("\n"))
};
let mut fields: std::collections::HashMap<&str, String> = std::collections::HashMap::new();
for line in &lines[field_start..] {
for key in KNOWN_FIELDS {
let key_name = key.trim_end_matches(':');
if let Some(value) = line.strip_prefix(key) {
fields.insert(key_name, value.trim().to_owned());
break;
}
}
}
let get = |key: &str| {
fields
.get(key)
.cloned()
.ok_or(MessageError::MissingField(match key {
"URI" => "URI",
"Version" => "Version",
"Chain ID" => "Chain ID",
"Nonce" => "Nonce",
"Issued At" => "Issued At",
_ => "unknown",
}))
};
Ok(ParsedMessage {
domain,
chain,
address,
statement,
uri: get("URI")?,
version: get("Version")?,
chain_id: get("Chain ID")?,
nonce: get("Nonce")?,
issued_at: get("Issued At")?,
expiration_time: fields.get("Expiration Time").cloned(),
not_before: fields.get("Not Before").cloned(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn approves(verifier: &StaticContractVerifier, address: &str, hash: [u8; 32]) -> bool {
pollster::block_on(verifier.is_valid_signature("1", address, hash, &[]))
.expect("the static verifier answers")
}
#[test]
fn eip55_checksum_known_vectors() {
assert_eq!(
eip55_checksum("52908400098527886e0f7030069857d2e4169ee7"),
"0x52908400098527886E0F7030069857D2E4169EE7"
);
assert_eq!(
eip55_checksum("de709f2102306220921060314715629080e2fb77"),
"0xde709f2102306220921060314715629080e2fb77"
);
assert_eq!(
eip55_checksum("fb6916095ca1df60bb79ce92ce3ea74c37c5d359"),
"0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359"
);
assert_eq!(
eip55_checksum("5a3198c8da7a0e9f1b1be33de686ac1f3c8d762d"),
"0x5A3198c8dA7a0E9f1B1BE33DE686ac1f3C8d762d"
);
}
#[test]
fn evm_address_normalisation_accepts_lowercase_and_uppercase() {
let lower = "0x52908400098527886e0f7030069857d2e4169ee7";
let upper = "0x52908400098527886E0F7030069857D2E4169EE7";
let expected = "0x52908400098527886E0F7030069857D2E4169EE7";
assert_eq!(normalize_evm_address(lower).unwrap(), expected);
assert_eq!(normalize_evm_address(upper).unwrap(), expected);
}
#[test]
fn evm_address_rejects_bad_checksum() {
let bad = "0x52908400098527886E0f7030069857D2E4169EE7";
assert_eq!(
normalize_evm_address(bad).unwrap_err(),
AddressError::BadChecksum
);
}
#[test]
fn evm_address_rejects_bad_length_and_hex() {
assert_eq!(
normalize_evm_address("0x1234").unwrap_err(),
AddressError::InvalidEvmHex
);
assert_eq!(
normalize_evm_address("12345678901234567890123456789012345678901").unwrap_err(),
AddressError::MissingPrefix
);
assert_eq!(
normalize_evm_address("0xZZZ908400098527886e0f7030069857d2e4169ee").unwrap_err(),
AddressError::InvalidEvmHex
);
}
#[test]
fn solana_address_round_trips() {
let bytes = [0u8; 32];
let encoded = bs58::encode(&bytes).into_string();
assert_eq!(normalize_solana_address(&encoded).unwrap(), encoded);
assert_eq!(encoded, "11111111111111111111111111111111");
}
#[test]
fn solana_address_rejects_wrong_length() {
let encoded = bs58::encode([0u8; 31]).into_string();
assert_eq!(
normalize_solana_address(&encoded).unwrap_err(),
AddressError::InvalidSolanaLength
);
}
#[test]
fn eip191_hash_matches_known_vector() {
let hash = eip191_hash("hello");
let hex = hex::encode(hash);
assert_eq!(
hex,
"50b2c43fd39106bafbba0da34fc430e1f91e3c96ea2acee2bc34119f92b37750"
);
}
#[test]
fn keccak256_matches_known_vectors() {
assert_eq!(
hex::encode(keccak256(b"")),
"c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470"
);
assert_eq!(
hex::encode(keccak256(b"abc")),
"4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45"
);
}
#[test]
fn siwe_message_parses() {
let message = "example.com wants you to sign in with your Ethereum account:\n\
0x5A3198c8Da7A0E9F1b1bE33DE686AC1f3c8D762D\n\
\n\
Welcome to Example!\n\
URI: https://example.com\n\
Version: 1\n\
Chain ID: 1\n\
Nonce: abc123XY\n\
Issued At: 2026-01-01T00:00:00Z";
let parsed = parse_sign_in_message(message).unwrap();
assert_eq!(parsed.domain, "example.com");
assert_eq!(parsed.chain, Chain::Evm);
assert_eq!(parsed.address, "0x5A3198c8Da7A0E9F1b1bE33DE686AC1f3c8D762D");
assert_eq!(parsed.statement.as_deref(), Some("Welcome to Example!"));
assert_eq!(parsed.uri, "https://example.com");
assert_eq!(parsed.version, "1");
assert_eq!(parsed.chain_id, "1");
assert_eq!(parsed.nonce, "abc123XY");
assert_eq!(parsed.issued_at, "2026-01-01T00:00:00Z");
assert!(parsed.expiration_time.is_none());
assert!(parsed.not_before.is_none());
}
#[test]
fn siws_message_parses() {
let address = concat!("5eykt4UsFv8P8NJdTREpY1", "vzqKqZKvdpKbXbQqQqQqQq");
let message = format!(
"example.com wants you to sign in with your Solana account:\n\
{address}\n\
\n\
URI: https://example.com\n\
Version: 1\n\
Chain ID: mainnet-beta\n\
Nonce: def456GH\n\
Issued At: 2026-01-01T00:00:00Z\n\
Expiration Time: 2026-01-01T00:10:00Z"
);
let parsed = parse_sign_in_message(&message).unwrap();
assert_eq!(parsed.chain, Chain::Solana);
assert_eq!(parsed.nonce, "def456GH");
assert_eq!(parsed.chain_id, "mainnet-beta");
assert_eq!(
parsed.expiration_time.as_deref(),
Some("2026-01-01T00:10:00Z")
);
}
#[test]
fn bad_header_is_rejected() {
let message =
"hello world\naddr\n\nURI: x\nVersion: 1\nChain ID: 1\nNonce: n\nIssued At: t";
assert_eq!(
parse_sign_in_message(message).unwrap_err(),
MessageError::UnknownHeader
);
}
#[test]
fn the_static_verifier_answers_for_one_address_and_one_message() {
let address = "0x52908400098527886E0F7030069857D2E4169EE7";
let other = "0xde709f2102306220921060314715629080e2fb77";
let exact = StaticContractVerifier::new(address.to_owned(), "the message");
let other_message = StaticContractVerifier::new(address.to_owned(), "a different message");
let any_message = StaticContractVerifier::approving(address.to_owned());
let hash = eip191_hash("the message");
assert!(approves(&exact, address, hash));
assert!(!approves(
&exact,
address,
eip191_hash("a different message")
));
assert!(!approves(&other_message, address, hash));
assert!(!approves(&any_message, other, hash));
assert!(approves(&any_message, address, hash));
}
}