Skip to main content

siwx_evm/
lib.rs

1//! # siwx-evm — Ethereum verification for Sign-In with X
2//!
3//! Implements CAIP-122 namespace profile for EIP-155 chains:
4//! - **EIP-191** (`personal_sign`) — ECDSA recovery-based verification
5//! - **EIP-1271** — smart-contract `isValidSignature` verification (requires RPC)
6//!
7//! # Quick start
8//!
9//! ```rust,no_run
10//! use siwx::SiwxMessage;
11//! use siwx_evm::{Eip191Verifier, CHAIN_NAME};
12//! use siwx::Verifier;
13//!
14//! # async fn run() -> Result<(), Box<dyn std::error::Error>> {
15//! let message = SiwxMessage::new(
16//!     "example.com",
17//!     "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
18//!     "https://example.com/login",
19//!     "1",
20//!     "1",
21//! )?;
22//! let text = siwx_evm::format_message(&message);
23//! // let signature_bytes: [u8; 65] = ...; // from wallet
24//! // Eip191Verifier.verify(&message, &signature_bytes).await?;
25//! # Ok(())
26//! # }
27//! ```
28
29mod eip1271;
30mod eip191;
31
32use alloy::primitives::Address;
33pub use eip191::Eip191Verifier;
34pub use eip1271::Eip1271Verifier;
35use siwx::{SiwxError, SiwxMessage};
36
37/// Human-readable chain name for the EIP-155 namespace, used in the CAIP-122
38/// preamble line.
39pub const CHAIN_NAME: &str = "Ethereum";
40
41/// CAIP-122 signature type for EIP-191 `personal_sign`.
42pub const SIG_TYPE_EIP191: &str = "eip191";
43
44/// CAIP-122 signature type for EIP-1271 contract signatures.
45pub const SIG_TYPE_EIP1271: &str = "eip1271";
46
47/// Convenience: format a [`SiwxMessage`] into the EIP-4361 signing string.
48#[must_use]
49pub fn format_message(message: &SiwxMessage) -> String {
50    message.to_sign_string(CHAIN_NAME)
51}
52
53/// Validate that `address` is a well-formed 0x-prefixed, 40-hex-char Ethereum
54/// address.
55///
56/// # Errors
57///
58/// Returns [`SiwxError::InvalidAddress`] if the format is wrong.
59pub fn validate_address(address: &str) -> Result<(), SiwxError> {
60    if !address.starts_with("0x") {
61        return Err(SiwxError::InvalidAddress("must start with 0x".into()));
62    }
63    parse_address(address)?;
64    Ok(())
65}
66
67/// Parse an Ethereum address string into an [`alloy::primitives::Address`].
68///
69/// # Errors
70///
71/// Returns [`SiwxError::InvalidAddress`] on invalid format.
72pub(crate) fn parse_address(s: &str) -> Result<Address, SiwxError> {
73    s.parse::<Address>()
74        .map_err(|e| SiwxError::InvalidAddress(e.to_string()))
75}
76
77/// Auto-detecting verifier that tries EIP-191 first; if the recovered address
78/// does not match `message.address`, falls back to EIP-1271.
79///
80/// Requires an RPC URL for the EIP-1271 fallback path.
81#[derive(Debug)]
82pub struct EvmVerifier {
83    rpc_url: Option<String>,
84}
85
86impl EvmVerifier {
87    /// Create a verifier without RPC (EIP-191 only).
88    #[must_use]
89    pub const fn new() -> Self {
90        Self { rpc_url: None }
91    }
92
93    /// Create a verifier with RPC for EIP-1271 fallback.
94    #[must_use]
95    pub fn with_rpc(url: impl Into<String>) -> Self {
96        Self {
97            rpc_url: Some(url.into()),
98        }
99    }
100}
101
102impl Default for EvmVerifier {
103    fn default() -> Self {
104        Self::new()
105    }
106}
107
108impl siwx::Verifier for EvmVerifier {
109    async fn verify(&self, message: &SiwxMessage, signature: &[u8]) -> Result<(), SiwxError> {
110        let eip191_err = match Eip191Verifier::verify_inner(message, signature) {
111            Ok(()) => return Ok(()),
112            Err(e) => e,
113        };
114
115        let Some(rpc_url) = self.rpc_url.as_deref() else {
116            return Err(eip191_err);
117        };
118
119        Eip1271Verifier::new(rpc_url)
120            .verify_inner(message, signature)
121            .await
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn validate_address_valid() {
131        assert!(validate_address("0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045").is_ok());
132        assert!(validate_address("0x0000000000000000000000000000000000000000").is_ok());
133    }
134
135    #[test]
136    fn validate_address_invalid() {
137        assert!(validate_address("not-an-address").is_err());
138        assert!(validate_address("0x123").is_err());
139        assert!(validate_address("d8dA6BF26964aF9D7eEd9e03E53415D37aA96045").is_err());
140    }
141
142    #[test]
143    fn format_message_preamble() {
144        let msg = SiwxMessage::new(
145            "example.com",
146            "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
147            "https://example.com",
148            "1",
149            "1",
150        )
151        .unwrap();
152        let text = format_message(&msg);
153        assert!(text.starts_with("example.com wants you to sign in with your Ethereum account:"));
154    }
155}