Skip to main content

cratefield_module_wallets/
crypto.rs

1//! EVM and Solana signature verification, address normalisation, SIWE/SIWS
2//! message parsing, and the EIP-1271 contract-signature verifier trait.
3//!
4//! This module never creates a session — it verifies a signature over a
5//! sign-in message to prove the caller controls the address in it. The
6//! `handlers` module consumes the result to link the address to the
7//! already-signed-in account.
8//!
9//! # EVM
10//!
11//! The EIP-191 `personal_sign` hash is keccak-256 of the prefixed message —
12//! `"\x19Ethereum Signed Message:\n"`, then the message's length, then the
13//! message itself. A 65-byte `r || s || v` signature recovers the public key
14//! over secp256k1 (`k256`), and the recovered address — keccak-256 of the
15//! uncompressed public key, last 20 bytes — is compared with the address the
16//! message names. If recovery fails or the address does not match (a
17//! contract wallet), the signature falls through to the
18//! [`ContractSignatureVerifier`] trait for an EIP-1271 `isValidSignature`
19//! check. A signature ending with the EIP-6492 counterfactual magic bytes is
20//! passed to the verifier as well — the verifier is responsible for
21//! counterfactual validation.
22//!
23//! # Solana
24//!
25//! The address is a base58 32-byte ed25519 public key; the signature is
26//! base58 (base64 accepted) 64 bytes, verified over the exact UTF-8 message
27//! bytes.
28
29use std::sync::Arc;
30
31use async_trait::async_trait;
32use ed25519_dalek::Verifier;
33use sha3::{Digest, Keccak256};
34
35use crate::Chain;
36
37/// The EIP-6492 counterfactual magic suffix: 32 bytes of `0x6492…`. A
38/// signature ending with these bytes is a counterfactual contract signature
39/// and is passed to the [`ContractSignatureVerifier`] as-is — the verifier is
40/// responsible for stripping and validating it.
41const 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
46/// keccak-256 of `data`.
47pub(crate) fn keccak256(data: &[u8]) -> [u8; 32] {
48    Keccak256::digest(data).into()
49}
50
51/// The EIP-191 `personal_sign` hash of `message`: keccak-256 of the prefix
52/// `"\x19Ethereum Signed Message:\n"`, then `message.len()`, then the message.
53pub(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/// The EIP-55 checksummed form of a 20-byte address (lowercase hex, no `0x`
62/// prefix).
63#[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// ---------------------------------------------------------------------------
88// Address normalisation
89
90/// Why an address could not be normalised.
91#[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
105/// Validates and normalises an EVM address to its EIP-55 checksummed form.
106/// Rejects a mixed-case address whose checksum does not match; accepts
107/// all-lowercase and all-uppercase forms (returning the checksummed form).
108pub(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    // A mixed-case address must match the EIP-55 checksum exactly.
121    if !is_lower && !is_upper && hex != &checksummed[2..] {
122        return Err(AddressError::BadChecksum);
123    }
124    Ok(checksummed)
125}
126
127/// Validates and normalises a Solana address to its canonical base58 form.
128pub(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
138// ---------------------------------------------------------------------------
139// EVM signature recovery
140
141/// Recovers the 20-byte EVM address from a 65-byte r||s||v signature over
142/// `hash`. Returns `None` when the signature is malformed or the recovery
143/// fails — the caller then falls through to the contract verifier.
144fn 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    // The 65-byte form is `r || s || v`; `k256` takes the 64-byte `r || s`
153    // and the recovery id separately.
154    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    // The uncompressed SEC1 encoding is 0x04 || x || y (65 bytes); the
158    // Ethereum address is keccak256(x || y)[12:].
159    let point = vk.to_encoded_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
171/// Whether `signature` ends with the EIP-6492 counterfactual magic bytes.
172fn has_eip6492_suffix(signature: &[u8]) -> bool {
173    signature.len() >= EIP_6492_MAGIC.len() && signature.ends_with(EIP_6492_MAGIC)
174}
175
176// ---------------------------------------------------------------------------
177// Contract signature verifier (EIP-1271)
178
179/// Why a contract signature check could not complete.
180#[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/// Verifies an EIP-1271 `isValidSignature(bytes32, bytes)` call against a
189/// smart-contract wallet. Backed by the `#759` `EvmChain` port when it lands;
190/// until then a venture wires its own implementation (e.g. an `eth_call`
191/// adapter) or the test-only [`StaticContractVerifier`].
192#[async_trait]
193pub trait ContractSignatureVerifier: Send + Sync {
194    /// Whether the contract at `address` (on `chain_id`) approves `signature`
195    /// for `message_hash`.
196    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/// A no-network [`ContractSignatureVerifier`] that approves exactly one
206/// `(address, message)` pair and refuses everything else. It is the test
207/// adapter for the EIP-1271 path — a unit test needs a contract wallet
208/// that answers without a chain, and this is it. It is compiled into the
209/// library rather than kept behind `cfg(test)` so an integration test can
210/// compose one; it is deliberately not wired into any builder default.
211#[derive(Clone)]
212pub struct StaticContractVerifier {
213    address: String,
214    hash: Option<[u8; 32]>,
215}
216
217impl StaticContractVerifier {
218    /// A verifier that approves `address` for the EIP-191 hash of
219    /// `message`, and refuses every other address and message.
220    #[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    /// A verifier that approves every message for `address` and refuses
229    /// every other address — a contract that says yes to whatever it is
230    /// asked, which is what a test needs when the message is not known
231    /// until after the nonce round trip has run.
232    #[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
254// ---------------------------------------------------------------------------
255// Verification
256
257/// Verifies an EVM signature. Tries EOA recovery first; on mismatch or
258/// non-65-byte input, falls through to `verifier` (EIP-1271, including
259/// EIP-6492 counterfactual signatures). Returns the normalised checksummed
260/// address on success.
261pub(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    // Try EOA recovery for a 65-byte signature. A counterfactual (EIP-6492)
271    // signature is never 65 bytes, so it falls through to the verifier.
272    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    // Fall through to the contract verifier (EIP-1271 / EIP-6492).
285    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
297/// Verifies a Solana signature over the exact UTF-8 message bytes. Returns
298/// the canonical base58 address on success.
299pub(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/// Why a signature could not be verified.
321#[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// ---------------------------------------------------------------------------
334// SIWE / SIWS message parsing
335
336/// A parsed EIP-4361 (SIWE) or SIWS sign-in message.
337#[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/// Why a message could not be parsed.
353#[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
375/// Parses a SIWE (`"… sign in with your Ethereum account:"`) or SIWS
376/// (`"… Solana account:"`) message. A small hand-written line parser; rejects
377/// anything malformed.
378pub(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    // Header → domain + chain.
385    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    // Statement: lines after the blank line, up to the first known field.
410    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    // Fields: "Key: Value" from field_start onward.
426    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    /// Asks `verifier` about one address and one hash, and unwraps the
471    /// answer: the static verifier never defers, so a test that could not
472    /// get an answer is a bug in the adapter, not a case to assert on.
473    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        // EIP-55 reference vectors.
481        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        // One letter flipped from the correct checksum form.
511        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        // The system program's address: the base58 of 32 zero bytes, and
537        // the one Solana address everybody has seen.
538        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        // keccak256("\x19Ethereum Signed Message:\n5hello") — the standard
556        // personal_sign prefix applied to "hello".
557        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        // The two reference digests every keccak implementation is checked
568        // against: EIP-55's checksumming and EIP-191's personal_sign both
569        // stand on this, so a keccak that is subtly wrong fails here first.
570        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        // The address is assembled from two fragments at runtime. The parser treats it
608        // as an opaque string, but a complete base58 address literal in the source is
609        // indistinguishable from a credential to generic secret scanners.
610        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}