1use std::sync::Arc;
30
31use async_trait::async_trait;
32use ed25519_dalek::Verifier;
33use sha3::{Digest, Keccak256};
34
35use crate::Chain;
36
37const EIP_6492_MAGIC: &[u8] = &[
42 0x64, 0x92, 0x64, 0x92, 0x64, 0x92, 0x64, 0x92, 0x64, 0x92, 0x64, 0x92, 0x64, 0x92, 0x64, 0x92,
43 0x64, 0x92, 0x64, 0x92, 0x64, 0x92, 0x64, 0x92, 0x64, 0x92, 0x64, 0x92, 0x64, 0x92, 0x64, 0x92,
44];
45
46pub(crate) fn keccak256(data: &[u8]) -> [u8; 32] {
48 Keccak256::digest(data).into()
49}
50
51pub(crate) fn eip191_hash(message: &str) -> [u8; 32] {
54 let prefix = format!("\x19Ethereum Signed Message:\n{}", message.len());
55 let mut data = Vec::with_capacity(prefix.len() + message.len());
56 data.extend_from_slice(prefix.as_bytes());
57 data.extend_from_slice(message.as_bytes());
58 keccak256(&data)
59}
60
61#[must_use]
64pub(crate) fn eip55_checksum(lowercase_hex: &str) -> String {
65 let hash = keccak256(lowercase_hex.as_bytes());
66 let mut out = String::with_capacity(42);
67 out.push_str("0x");
68 for (i, ch) in lowercase_hex.chars().enumerate() {
69 if ch.is_ascii_alphabetic() {
70 let nibble = if i % 2 == 0 {
71 hash[i / 2] >> 4
72 } else {
73 hash[i / 2] & 0x0f
74 };
75 if nibble >= 8 {
76 out.push(ch.to_ascii_uppercase());
77 } else {
78 out.push(ch);
79 }
80 } else {
81 out.push(ch);
82 }
83 }
84 out
85}
86
87#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
92pub enum AddressError {
93 #[error("address must start with 0x")]
94 MissingPrefix,
95 #[error("address must be 40 hex characters after 0x")]
96 InvalidEvmHex,
97 #[error("the mixed-case address has a bad EIP-55 checksum")]
98 BadChecksum,
99 #[error("address is not valid base58")]
100 InvalidBase58,
101 #[error("Solana address must decode to 32 bytes")]
102 InvalidSolanaLength,
103}
104
105pub(crate) fn normalize_evm_address(raw: &str) -> Result<String, AddressError> {
109 let hex = raw
110 .strip_prefix("0x")
111 .or_else(|| raw.strip_prefix("0X"))
112 .ok_or(AddressError::MissingPrefix)?;
113 if hex.len() != 40 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
114 return Err(AddressError::InvalidEvmHex);
115 }
116 let lower = hex.to_ascii_lowercase();
117 let is_lower = hex == lower;
118 let is_upper = hex == hex.to_ascii_uppercase();
119 let checksummed = eip55_checksum(&lower);
120 if !is_lower && !is_upper && hex != &checksummed[2..] {
122 return Err(AddressError::BadChecksum);
123 }
124 Ok(checksummed)
125}
126
127pub(crate) fn normalize_solana_address(raw: &str) -> Result<String, AddressError> {
129 let bytes = bs58::decode(raw)
130 .into_vec()
131 .map_err(|_| AddressError::InvalidBase58)?;
132 if bytes.len() != 32 {
133 return Err(AddressError::InvalidSolanaLength);
134 }
135 Ok(bs58::encode(&bytes).into_string())
136}
137
138fn recover_evm_address(hash: &[u8; 32], signature: &[u8; 65]) -> Option<[u8; 20]> {
145 let v = signature[64];
146 let recovery_byte = match v {
147 0 | 1 => v,
148 27 | 28 => v - 27,
149 _ => return None,
150 };
151 let recovery_id = k256::ecdsa::RecoveryId::from_byte(recovery_byte)?;
152 let sig = k256::ecdsa::Signature::from_slice(&signature[..64]).ok()?;
155 let vk = k256::ecdsa::VerifyingKey::recover_from_prehash(hash, &sig, recovery_id).ok()?;
156
157 let point = vk.to_sec1_point(false);
160 let bytes = point.as_bytes();
161 if bytes.len() != 65 || bytes[0] != 0x04 {
162 return None;
163 }
164 let pubkey = &bytes[1..];
165 let addr_hash = keccak256(pubkey);
166 let mut addr = [0u8; 20];
167 addr.copy_from_slice(&addr_hash[12..]);
168 Some(addr)
169}
170
171fn has_eip6492_suffix(signature: &[u8]) -> bool {
173 signature.len() >= EIP_6492_MAGIC.len() && signature.ends_with(EIP_6492_MAGIC)
174}
175
176#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
181pub enum ContractVerifierError {
182 #[error("the contract verifier is not configured")]
183 NotConfigured,
184 #[error("the contract verifier could not answer: {0}")]
185 Upstream(String),
186}
187
188#[async_trait]
193pub trait ContractSignatureVerifier: Send + Sync {
194 async fn is_valid_signature(
197 &self,
198 chain_id: &str,
199 address: &str,
200 message_hash: [u8; 32],
201 signature: &[u8],
202 ) -> Result<bool, ContractVerifierError>;
203}
204
205#[derive(Clone)]
212pub struct StaticContractVerifier {
213 address: String,
214 hash: Option<[u8; 32]>,
215}
216
217impl StaticContractVerifier {
218 #[must_use]
221 pub fn new(address: String, message: &str) -> Self {
222 Self {
223 address,
224 hash: Some(eip191_hash(message)),
225 }
226 }
227
228 #[must_use]
233 pub fn approving(address: String) -> Self {
234 Self {
235 address,
236 hash: None,
237 }
238 }
239}
240
241#[async_trait]
242impl ContractSignatureVerifier for StaticContractVerifier {
243 async fn is_valid_signature(
244 &self,
245 _chain_id: &str,
246 address: &str,
247 message_hash: [u8; 32],
248 _signature: &[u8],
249 ) -> Result<bool, ContractVerifierError> {
250 Ok(address == self.address && self.hash.is_none_or(|hash| hash == message_hash))
251 }
252}
253
254pub(crate) async fn verify_evm(
262 message_address_hex: &str,
263 message: &str,
264 signature: &[u8],
265 chain_id: &str,
266 verifier: Option<&Arc<dyn ContractSignatureVerifier>>,
267) -> Result<String, VerifyError> {
268 let hash = eip191_hash(message);
269
270 if signature.len() == 65 && !has_eip6492_suffix(signature) {
273 let fixed: [u8; 65] = signature.try_into().expect("checked length");
274 if let Some(recovered) = recover_evm_address(&hash, &fixed) {
275 let recovered_hex = format!("0x{}", hex::encode(recovered));
276 let normalised = normalize_evm_address(&recovered_hex)?;
277 let expected = normalize_evm_address(message_address_hex)?;
278 if normalised.eq_ignore_ascii_case(&expected) {
279 return Ok(normalised);
280 }
281 }
282 }
283
284 let verifier = verifier.ok_or(VerifyError::NoVerifier)?;
286 let address = normalize_evm_address(message_address_hex)?;
287 match verifier
288 .is_valid_signature(chain_id, &address, hash, signature)
289 .await
290 {
291 Ok(true) => Ok(address),
292 Ok(false) => Err(VerifyError::SignatureInvalid),
293 Err(err) => Err(VerifyError::Verifier(err.to_string())),
294 }
295}
296
297pub(crate) fn verify_solana(
300 message_address: &str,
301 message: &str,
302 signature: &[u8],
303) -> Result<String, VerifyError> {
304 let address_bytes = bs58::decode(message_address)
305 .into_vec()
306 .map_err(|_| VerifyError::SignatureInvalid)?;
307 if address_bytes.len() != 32 {
308 return Err(VerifyError::SignatureInvalid);
309 }
310 let key_bytes: [u8; 32] = address_bytes.try_into().expect("checked length");
311 let vk = ed25519_dalek::VerifyingKey::from_bytes(&key_bytes)
312 .map_err(|_| VerifyError::SignatureInvalid)?;
313 let sig = ed25519_dalek::Signature::from_slice(signature)
314 .map_err(|_| VerifyError::SignatureInvalid)?;
315 vk.verify(message.as_bytes(), &sig)
316 .map_err(|_| VerifyError::SignatureInvalid)?;
317 normalize_solana_address(message_address).map_err(|_| VerifyError::SignatureInvalid)
318}
319
320#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
322pub enum VerifyError {
323 #[error("the signature does not prove ownership of the address")]
324 SignatureInvalid,
325 #[error("no contract verifier is configured for this chain")]
326 NoVerifier,
327 #[error("the contract verifier could not answer: {0}")]
328 Verifier(String),
329 #[error("the address is not valid: {0}")]
330 Address(#[from] AddressError),
331}
332
333#[derive(Debug, Clone, PartialEq, Eq)]
338pub(crate) struct ParsedMessage {
339 pub(crate) domain: String,
340 pub(crate) chain: Chain,
341 pub(crate) address: String,
342 pub(crate) statement: Option<String>,
343 pub(crate) uri: String,
344 pub(crate) version: String,
345 pub(crate) chain_id: String,
346 pub(crate) nonce: String,
347 pub(crate) issued_at: String,
348 pub(crate) expiration_time: Option<String>,
349 pub(crate) not_before: Option<String>,
350}
351
352#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
354pub enum MessageError {
355 #[error("the message is too short to be a sign-in message")]
356 TooShort,
357 #[error("the message header is not a SIWE or SIWS header")]
358 UnknownHeader,
359 #[error("the message is missing a required field: {0}")]
360 MissingField(&'static str),
361 #[error("the message is missing the blank line between address and fields")]
362 MissingBlankLine,
363}
364
365const KNOWN_FIELDS: &[&str] = &[
366 "URI:",
367 "Version:",
368 "Chain ID:",
369 "Nonce:",
370 "Issued At:",
371 "Expiration Time:",
372 "Not Before:",
373];
374
375pub(crate) fn parse_sign_in_message(message: &str) -> Result<ParsedMessage, MessageError> {
379 let lines: Vec<&str> = message.lines().collect();
380 if lines.len() < 7 {
381 return Err(MessageError::TooShort);
382 }
383
384 let header = lines[0];
386 let (domain, chain) = if let Some(rest) =
387 header.strip_suffix(" wants you to sign in with your Ethereum account:")
388 {
389 (rest.to_owned(), Chain::Evm)
390 } else if let Some(rest) =
391 header.strip_suffix(" wants you to sign in with your Solana account:")
392 {
393 (rest.to_owned(), Chain::Solana)
394 } else {
395 return Err(MessageError::UnknownHeader);
396 };
397 if domain.is_empty() {
398 return Err(MessageError::MissingField("domain"));
399 }
400
401 let address = lines[1].to_owned();
402 if address.is_empty() {
403 return Err(MessageError::MissingField("address"));
404 }
405 if !lines[2].is_empty() {
406 return Err(MessageError::MissingBlankLine);
407 }
408
409 let mut statement_lines: Vec<&str> = Vec::new();
411 let mut field_start = lines.len();
412 for (i, line) in lines.iter().enumerate().skip(3) {
413 if KNOWN_FIELDS.iter().any(|f| line.starts_with(f)) {
414 field_start = i;
415 break;
416 }
417 statement_lines.push(line);
418 }
419 let statement = if statement_lines.is_empty() {
420 None
421 } else {
422 Some(statement_lines.join("\n"))
423 };
424
425 let mut fields: std::collections::HashMap<&str, String> = std::collections::HashMap::new();
427 for line in &lines[field_start..] {
428 for key in KNOWN_FIELDS {
429 let key_name = key.trim_end_matches(':');
430 if let Some(value) = line.strip_prefix(key) {
431 fields.insert(key_name, value.trim().to_owned());
432 break;
433 }
434 }
435 }
436
437 let get = |key: &str| {
438 fields
439 .get(key)
440 .cloned()
441 .ok_or(MessageError::MissingField(match key {
442 "URI" => "URI",
443 "Version" => "Version",
444 "Chain ID" => "Chain ID",
445 "Nonce" => "Nonce",
446 "Issued At" => "Issued At",
447 _ => "unknown",
448 }))
449 };
450
451 Ok(ParsedMessage {
452 domain,
453 chain,
454 address,
455 statement,
456 uri: get("URI")?,
457 version: get("Version")?,
458 chain_id: get("Chain ID")?,
459 nonce: get("Nonce")?,
460 issued_at: get("Issued At")?,
461 expiration_time: fields.get("Expiration Time").cloned(),
462 not_before: fields.get("Not Before").cloned(),
463 })
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 fn approves(verifier: &StaticContractVerifier, address: &str, hash: [u8; 32]) -> bool {
474 pollster::block_on(verifier.is_valid_signature("1", address, hash, &[]))
475 .expect("the static verifier answers")
476 }
477
478 #[test]
479 fn eip55_checksum_known_vectors() {
480 assert_eq!(
482 eip55_checksum("52908400098527886e0f7030069857d2e4169ee7"),
483 "0x52908400098527886E0F7030069857D2E4169EE7"
484 );
485 assert_eq!(
486 eip55_checksum("de709f2102306220921060314715629080e2fb77"),
487 "0xde709f2102306220921060314715629080e2fb77"
488 );
489 assert_eq!(
490 eip55_checksum("fb6916095ca1df60bb79ce92ce3ea74c37c5d359"),
491 "0xfB6916095ca1df60bB79Ce92cE3Ea74c37c5d359"
492 );
493 assert_eq!(
494 eip55_checksum("5a3198c8da7a0e9f1b1be33de686ac1f3c8d762d"),
495 "0x5A3198c8dA7a0E9f1B1BE33DE686ac1f3C8d762d"
496 );
497 }
498
499 #[test]
500 fn evm_address_normalisation_accepts_lowercase_and_uppercase() {
501 let lower = "0x52908400098527886e0f7030069857d2e4169ee7";
502 let upper = "0x52908400098527886E0F7030069857D2E4169EE7";
503 let expected = "0x52908400098527886E0F7030069857D2E4169EE7";
504 assert_eq!(normalize_evm_address(lower).unwrap(), expected);
505 assert_eq!(normalize_evm_address(upper).unwrap(), expected);
506 }
507
508 #[test]
509 fn evm_address_rejects_bad_checksum() {
510 let bad = "0x52908400098527886E0f7030069857D2E4169EE7";
512 assert_eq!(
513 normalize_evm_address(bad).unwrap_err(),
514 AddressError::BadChecksum
515 );
516 }
517
518 #[test]
519 fn evm_address_rejects_bad_length_and_hex() {
520 assert_eq!(
521 normalize_evm_address("0x1234").unwrap_err(),
522 AddressError::InvalidEvmHex
523 );
524 assert_eq!(
525 normalize_evm_address("12345678901234567890123456789012345678901").unwrap_err(),
526 AddressError::MissingPrefix
527 );
528 assert_eq!(
529 normalize_evm_address("0xZZZ908400098527886e0f7030069857d2e4169ee").unwrap_err(),
530 AddressError::InvalidEvmHex
531 );
532 }
533
534 #[test]
535 fn solana_address_round_trips() {
536 let bytes = [0u8; 32];
539 let encoded = bs58::encode(&bytes).into_string();
540 assert_eq!(normalize_solana_address(&encoded).unwrap(), encoded);
541 assert_eq!(encoded, "11111111111111111111111111111111");
542 }
543
544 #[test]
545 fn solana_address_rejects_wrong_length() {
546 let encoded = bs58::encode([0u8; 31]).into_string();
547 assert_eq!(
548 normalize_solana_address(&encoded).unwrap_err(),
549 AddressError::InvalidSolanaLength
550 );
551 }
552
553 #[test]
554 fn eip191_hash_matches_known_vector() {
555 let hash = eip191_hash("hello");
558 let hex = hex::encode(hash);
559 assert_eq!(
560 hex,
561 "50b2c43fd39106bafbba0da34fc430e1f91e3c96ea2acee2bc34119f92b37750"
562 );
563 }
564
565 #[test]
566 fn keccak256_matches_known_vectors() {
567 assert_eq!(
571 hex::encode(keccak256(b"")),
572 "c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470"
573 );
574 assert_eq!(
575 hex::encode(keccak256(b"abc")),
576 "4e03657aea45a94fc7d47ba826c8d667c0d1e6e33a64a036ec44f58fa12d6c45"
577 );
578 }
579
580 #[test]
581 fn siwe_message_parses() {
582 let message = "example.com wants you to sign in with your Ethereum account:\n\
583 0x5A3198c8Da7A0E9F1b1bE33DE686AC1f3c8D762D\n\
584 \n\
585 Welcome to Example!\n\
586 URI: https://example.com\n\
587 Version: 1\n\
588 Chain ID: 1\n\
589 Nonce: abc123XY\n\
590 Issued At: 2026-01-01T00:00:00Z";
591 let parsed = parse_sign_in_message(message).unwrap();
592 assert_eq!(parsed.domain, "example.com");
593 assert_eq!(parsed.chain, Chain::Evm);
594 assert_eq!(parsed.address, "0x5A3198c8Da7A0E9F1b1bE33DE686AC1f3c8D762D");
595 assert_eq!(parsed.statement.as_deref(), Some("Welcome to Example!"));
596 assert_eq!(parsed.uri, "https://example.com");
597 assert_eq!(parsed.version, "1");
598 assert_eq!(parsed.chain_id, "1");
599 assert_eq!(parsed.nonce, "abc123XY");
600 assert_eq!(parsed.issued_at, "2026-01-01T00:00:00Z");
601 assert!(parsed.expiration_time.is_none());
602 assert!(parsed.not_before.is_none());
603 }
604
605 #[test]
606 fn siws_message_parses() {
607 let address = concat!("5eykt4UsFv8P8NJdTREpY1", "vzqKqZKvdpKbXbQqQqQqQq");
611 let message = format!(
612 "example.com wants you to sign in with your Solana account:\n\
613 {address}\n\
614 \n\
615 URI: https://example.com\n\
616 Version: 1\n\
617 Chain ID: mainnet-beta\n\
618 Nonce: def456GH\n\
619 Issued At: 2026-01-01T00:00:00Z\n\
620 Expiration Time: 2026-01-01T00:10:00Z"
621 );
622 let parsed = parse_sign_in_message(&message).unwrap();
623 assert_eq!(parsed.chain, Chain::Solana);
624 assert_eq!(parsed.nonce, "def456GH");
625 assert_eq!(parsed.chain_id, "mainnet-beta");
626 assert_eq!(
627 parsed.expiration_time.as_deref(),
628 Some("2026-01-01T00:10:00Z")
629 );
630 }
631
632 #[test]
633 fn bad_header_is_rejected() {
634 let message =
635 "hello world\naddr\n\nURI: x\nVersion: 1\nChain ID: 1\nNonce: n\nIssued At: t";
636 assert_eq!(
637 parse_sign_in_message(message).unwrap_err(),
638 MessageError::UnknownHeader
639 );
640 }
641
642 #[test]
643 fn the_static_verifier_answers_for_one_address_and_one_message() {
644 let address = "0x52908400098527886E0F7030069857D2E4169EE7";
645 let other = "0xde709f2102306220921060314715629080e2fb77";
646 let exact = StaticContractVerifier::new(address.to_owned(), "the message");
647 let other_message = StaticContractVerifier::new(address.to_owned(), "a different message");
648 let any_message = StaticContractVerifier::approving(address.to_owned());
649
650 let hash = eip191_hash("the message");
651 assert!(approves(&exact, address, hash));
652 assert!(!approves(
653 &exact,
654 address,
655 eip191_hash("a different message")
656 ));
657 assert!(!approves(&other_message, address, hash));
658 assert!(!approves(&any_message, other, hash));
659 assert!(approves(&any_message, address, hash));
660 }
661}