use chrono::{Duration, Utc};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use rand::{rngs::OsRng, Rng};
use crate::config::SolanaConfig;
use crate::errors::AppError;
use crate::models::ChallengeResponse;
#[derive(Clone)]
pub struct SolanaService {
challenge_expiry_seconds: u64,
}
impl SolanaService {
const NONCE_MARKER: &'static str = ". Nonce: ";
const MESSAGE_PREFIX: &'static str = "Sign in with wallet ";
pub fn new(config: &SolanaConfig) -> Self {
Self {
challenge_expiry_seconds: config.challenge_expiry_seconds,
}
}
pub fn generate_challenge(
&self,
public_key: &str,
challenge_expiry_seconds: u64,
domain: Option<&str>,
) -> Result<ChallengeResponse, AppError> {
let nonce: String = OsRng
.sample_iter(&rand::distributions::Alphanumeric)
.take(32)
.map(char::from)
.collect();
let now = Utc::now();
let expires_at = now + Duration::seconds(challenge_expiry_seconds as i64);
let pk_display = if public_key.len() > 12 {
format!(
"{}...{}",
&public_key[..6],
&public_key[public_key.len() - 6..]
)
} else {
public_key.to_string()
};
let domain_clause = domain.map(|d| format!(" on {d}")).unwrap_or_default();
let message = format!(
"Sign in with wallet {pk_display}{domain_clause}. This message confirms ownership of your wallet and costs nothing to sign. Expires: {}. Nonce: {nonce}.",
expires_at.to_rfc3339(),
);
Ok(ChallengeResponse {
nonce,
message,
expires_at,
})
}
pub fn verify_signature(
&self,
public_key_base58: &str,
signature_base64: &str,
message: &str,
) -> Result<bool, AppError> {
let public_key_bytes = bs58::decode(public_key_base58)
.into_vec()
.map_err(|_| AppError::Validation("Invalid public key format".into()))?;
if public_key_bytes.len() != 32 {
return Err(AppError::Validation("Invalid public key length".into()));
}
let public_key_array: [u8; 32] = public_key_bytes
.try_into()
.map_err(|_| AppError::Validation("Invalid public key length".into()))?;
let verifying_key = VerifyingKey::from_bytes(&public_key_array)
.map_err(|_| AppError::Validation("Invalid public key".into()))?;
let signature_bytes =
base64::Engine::decode(&base64::engine::general_purpose::STANDARD, signature_base64)
.map_err(|_| AppError::Validation("Invalid signature format".into()))?;
if signature_bytes.len() != 64 {
return Err(AppError::Validation("Invalid signature length".into()));
}
let signature_array: [u8; 64] = signature_bytes
.try_into()
.map_err(|_| AppError::Validation("Invalid signature length".into()))?;
let signature = Signature::from_bytes(&signature_array);
Ok(verifying_key.verify(message.as_bytes(), &signature).is_ok())
}
pub fn extract_nonce(message: &str) -> Option<String> {
if !message.starts_with(Self::MESSAGE_PREFIX) {
return None;
}
if !message.ends_with('.') {
return None;
}
if message.matches(Self::NONCE_MARKER).count() != 1 {
return None;
}
let nonce_start = message.find(Self::NONCE_MARKER)?;
let after_marker = &message[nonce_start + Self::NONCE_MARKER.len()..];
let nonce = after_marker.strip_suffix('.')?;
if nonce.len() != 32 || !nonce.chars().all(|c| c.is_ascii_alphanumeric()) {
return None;
}
Some(nonce.to_string())
}
pub fn challenge_expiry_seconds(&self) -> u64 {
self.challenge_expiry_seconds
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> SolanaConfig {
SolanaConfig {
enabled: true,
challenge_expiry_seconds: 300,
}
}
#[test]
fn test_generate_challenge_without_domain() {
let service = SolanaService::new(&test_config());
let public_key = "ABCDEFghijklmnopqrstuvwxyz123456789abcdef1234";
let challenge = service.generate_challenge(public_key, 300, None).unwrap();
assert!(!challenge.nonce.is_empty());
assert!(challenge.message.starts_with("Sign in with wallet "));
assert!(challenge.message.contains(&challenge.nonce));
assert!(challenge.message.contains("ABCDEF...ef1234"));
assert!(challenge.message.contains("costs nothing to sign"));
assert!(!challenge.message.contains(" on "));
}
#[test]
fn test_generate_challenge_with_domain() {
let service = SolanaService::new(&test_config());
let public_key = "ABCDEFghijklmnopqrstuvwxyz123456789abcdef1234";
let challenge = service
.generate_challenge(public_key, 300, Some("https://example.com"))
.unwrap();
assert!(challenge.message.contains("on https://example.com"));
assert!(challenge.message.starts_with("Sign in with wallet "));
assert!(challenge.message.contains(&challenge.nonce));
}
#[test]
fn test_extract_nonce_from_generated_message() {
let service = SolanaService::new(&test_config());
let challenge = service
.generate_challenge("test_pubkey", 300, None)
.unwrap();
let extracted = SolanaService::extract_nonce(&challenge.message);
assert_eq!(extracted, Some(challenge.nonce));
let challenge = service
.generate_challenge("test_pubkey", 300, Some("https://example.com"))
.unwrap();
let extracted = SolanaService::extract_nonce(&challenge.message);
assert_eq!(extracted, Some(challenge.nonce));
}
#[test]
fn test_extract_nonce_rejects_invalid_length() {
let message = "Sign in with wallet ABCDEF...f1234. This message confirms ownership of your wallet and costs nothing to sign. Expires: 2024-01-01T00:05:00+00:00. Nonce: tooshort.";
assert!(SolanaService::extract_nonce(message).is_none());
}
#[test]
fn test_extract_nonce_rejects_invalid_chars() {
let message = "Sign in with wallet ABCDEF...f1234. This message confirms ownership of your wallet and costs nothing to sign. Expires: 2024-01-01T00:05:00+00:00. Nonce: abc123!@#$%^&*()_+def456ghi012.";
assert!(SolanaService::extract_nonce(message).is_none());
}
#[test]
fn test_invalid_public_key() {
let service = SolanaService::new(&test_config());
let result = service.verify_signature("invalid", "sig", "message");
assert!(result.is_err());
}
#[test]
fn test_extract_nonce_rejects_wrong_prefix() {
let message = "Evil with wallet ABCDEF...f1234. This message confirms ownership of your wallet and costs nothing to sign. Expires: 2024-01-01T00:05:00+00:00. Nonce: 12345678901234567890123456789012.";
assert!(SolanaService::extract_nonce(message).is_none());
}
#[test]
fn test_extract_nonce_rejects_missing_suffix() {
let message = "Sign in with wallet ABCDEF...f1234. This message confirms ownership of your wallet and costs nothing to sign. Expires: 2024-01-01T00:05:00+00:00. Nonce: 12345678901234567890123456789012";
assert!(SolanaService::extract_nonce(message).is_none());
}
#[test]
fn test_extract_nonce_rejects_multiple_markers() {
let message = "Sign in with wallet ABCDEF...f1234. This message confirms ownership of your wallet and costs nothing to sign. Nonce: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA. Expires: 2024-01-01T00:05:00+00:00. Nonce: 12345678901234567890123456789012.";
assert!(SolanaService::extract_nonce(message).is_none());
}
}