use alloy_primitives::B256;
use alloy_signer::Signer;
use alloy_signer_local::PrivateKeySigner;
use eyre::{eyre, Result};
#[cfg(feature = "solana")]
use solana_sdk::signature::{Keypair, Signer as SolanaSigner};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CurveType {
Secp256k1,
Ed25519,
}
pub enum Wallet {
Evm(PrivateKeySigner),
#[cfg(feature = "solana")]
Solana(Box<Keypair>),
}
impl Wallet {
pub fn from_evm_hex(hex_key: &str) -> Result<Self> {
let signer: PrivateKeySigner = hex_key
.parse()
.map_err(|e| eyre!("invalid EVM private key: {}", e))?;
Ok(Wallet::Evm(signer))
}
#[cfg(feature = "solana")]
pub fn from_solana_base58(b58: &str) -> Result<Self> {
let bytes = bs58::decode(b58.trim())
.into_vec()
.map_err(|e| eyre!("invalid base58 keypair: {}", e))?;
if bytes.len() != 64 {
return Err(eyre!(
"Solana keypair must be 64 bytes, got {}",
bytes.len()
));
}
let keypair = Keypair::try_from(bytes.as_slice())
.map_err(|e| eyre!("invalid Solana keypair bytes: {}", e))?;
Ok(Wallet::Solana(Box::new(keypair)))
}
#[cfg(feature = "solana")]
pub fn from_solana_json(json: &str) -> Result<Self> {
let bytes: Vec<u8> =
serde_json::from_str(json).map_err(|e| eyre!("invalid Solana keypair JSON: {}", e))?;
if bytes.len() != 64 {
return Err(eyre!(
"Solana keypair must be 64 bytes, got {}",
bytes.len()
));
}
let keypair = Keypair::try_from(bytes.as_slice())
.map_err(|e| eyre!("invalid Solana keypair bytes: {}", e))?;
Ok(Wallet::Solana(Box::new(keypair)))
}
pub fn curve(&self) -> CurveType {
match self {
Wallet::Evm(_) => CurveType::Secp256k1,
#[cfg(feature = "solana")]
Wallet::Solana(_) => CurveType::Ed25519,
}
}
pub fn address(&self) -> String {
match self {
Wallet::Evm(s) => s.address().to_checksum(None),
#[cfg(feature = "solana")]
Wallet::Solana(kp) => kp.pubkey().to_string(),
}
}
pub async fn sign_message(&self, msg: &[u8]) -> Result<Vec<u8>> {
match self {
Wallet::Evm(s) => {
let sig = s.sign_message(msg).await?;
Ok(sig.as_bytes().to_vec())
}
#[cfg(feature = "solana")]
Wallet::Solana(kp) => {
let sig = kp.sign_message(msg);
Ok(sig.as_ref().to_vec())
}
}
}
pub async fn sign_eip712_digest(&self, digest: B256) -> Result<Vec<u8>> {
match self {
Wallet::Evm(s) => {
let sig = s.sign_hash(&digest).await?;
Ok(sig.as_bytes().to_vec())
}
#[cfg(feature = "solana")]
Wallet::Solana(_) => Err(eyre!(
"EIP-712 digest signing is not supported for Ed25519 wallets"
)),
}
}
pub fn as_evm(&self) -> Option<&PrivateKeySigner> {
match self {
Wallet::Evm(s) => Some(s),
#[cfg(feature = "solana")]
Wallet::Solana(_) => None,
}
}
#[cfg(feature = "solana")]
pub fn as_solana(&self) -> Option<&Keypair> {
match self {
Wallet::Evm(_) => None,
Wallet::Solana(kp) => Some(kp),
}
}
}
pub fn load_trader_wallet(curve: CurveType) -> Result<Wallet> {
match curve {
CurveType::Secp256k1 => {
let key = std::env::var("TRADER_PRIVKEY")
.map_err(|_| eyre!("TRADER_PRIVKEY not set in environment"))?;
Wallet::from_evm_hex(&key)
}
CurveType::Ed25519 => {
#[cfg(feature = "solana")]
{
let key = std::env::var("TRADER_PRIVKEY_SOLANA")
.map_err(|_| eyre!("TRADER_PRIVKEY_SOLANA not set in environment"))?;
Wallet::from_solana_base58(&key).or_else(|_| Wallet::from_solana_json(&key))
}
#[cfg(not(feature = "solana"))]
{
Err(eyre!(
"Ed25519/Solana wallets require the `solana` feature to be enabled"
))
}
}
}
}
pub fn load_admin_wallet(curve: CurveType) -> Result<Wallet> {
match curve {
CurveType::Secp256k1 => {
let key = std::env::var("ADMIN_PRIVKEY")
.map_err(|_| eyre!("ADMIN_PRIVKEY not set in environment"))?;
Wallet::from_evm_hex(&key)
}
CurveType::Ed25519 => {
#[cfg(feature = "solana")]
{
let key = std::env::var("ADMIN_PRIVKEY_SOLANA")
.map_err(|_| eyre!("ADMIN_PRIVKEY_SOLANA not set in environment"))?;
Wallet::from_solana_base58(&key).or_else(|_| Wallet::from_solana_json(&key))
}
#[cfg(not(feature = "solana"))]
{
Err(eyre!(
"Ed25519/Solana wallets require the `solana` feature to be enabled"
))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_EVM_KEY: &str = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
#[cfg(feature = "solana")]
fn fresh_solana_keypair_b58() -> String {
let kp = Keypair::new();
bs58::encode(kp.to_bytes()).into_string()
}
#[test]
fn evm_wallet_address_is_hex() {
let w = Wallet::from_evm_hex(TEST_EVM_KEY).unwrap();
let addr = w.address();
assert!(addr.starts_with("0x"));
assert_eq!(addr.len(), 42);
assert_eq!(w.curve(), CurveType::Secp256k1);
}
#[cfg(feature = "solana")]
#[test]
fn solana_wallet_address_is_base58() {
let b58 = fresh_solana_keypair_b58();
let w = Wallet::from_solana_base58(&b58).unwrap();
let addr = w.address();
assert!(!addr.is_empty());
assert!(!addr.starts_with("0x"));
assert!(addr.len() >= 32 && addr.len() <= 44);
for c in addr.chars() {
assert!(c.is_ascii_alphanumeric());
assert!(c != '0' && c != 'O' && c != 'I' && c != 'l');
}
assert_eq!(w.curve(), CurveType::Ed25519);
}
#[tokio::test]
async fn evm_sign_message_is_65_bytes() {
let w = Wallet::from_evm_hex(TEST_EVM_KEY).unwrap();
let sig = w.sign_message(b"hello").await.unwrap();
assert_eq!(sig.len(), 65, "EVM signature should be 65 bytes");
}
#[cfg(feature = "solana")]
#[tokio::test]
async fn solana_sign_message_is_64_bytes() {
let w = Wallet::from_solana_base58(&fresh_solana_keypair_b58()).unwrap();
let sig = w.sign_message(b"hello").await.unwrap();
assert_eq!(sig.len(), 64, "Ed25519 signature should be 64 bytes");
}
#[cfg(feature = "solana")]
#[tokio::test]
async fn solana_eip712_returns_error() {
let w = Wallet::from_solana_base58(&fresh_solana_keypair_b58()).unwrap();
let digest = B256::ZERO;
assert!(w.sign_eip712_digest(digest).await.is_err());
}
#[cfg(feature = "solana")]
#[test]
fn solana_wallet_rejects_short_key() {
let short = bs58::encode(vec![0u8; 32]).into_string();
assert!(Wallet::from_solana_base58(&short).is_err());
}
#[test]
fn evm_wallet_rejects_invalid_hex() {
assert!(Wallet::from_evm_hex("not-hex").is_err());
}
#[cfg(not(feature = "solana"))]
#[test]
fn ed25519_load_errors_without_feature() {
assert!(load_trader_wallet(CurveType::Ed25519).is_err());
}
}