use std::fmt;
use std::str::FromStr;
use std::sync::Arc;
use std::time::Duration;
use alloy::network::TxSigner;
use alloy::primitives::{Address, Signature};
use alloy::signers::local::PrivateKeySigner;
use crate::error::EvmError;
use crate::tempo::{is_tempo_chain_id, is_tip20_address};
#[derive(Clone)]
pub struct EvmSigner {
inner: Arc<dyn TxSigner<Signature> + Send + Sync>,
address: Address,
}
impl EvmSigner {
pub fn new<S: TxSigner<Signature> + Send + Sync + 'static>(signer: S) -> Self {
let address = signer.address();
Self { inner: Arc::new(signer), address }
}
pub fn from_private_key(hex_key: &str) -> Result<Self, EvmError> {
let signer = PrivateKeySigner::from_str(hex_key.trim())
.map_err(|_| EvmError::Config("invalid private key (expected 32 bytes of hex)".to_owned()))?;
Ok(Self::new(signer))
}
pub fn address(&self) -> Address {
self.address
}
pub fn as_tx_signer(&self) -> Arc<dyn TxSigner<Signature> + Send + Sync> {
Arc::clone(&self.inner)
}
}
impl From<PrivateKeySigner> for EvmSigner {
fn from(signer: PrivateKeySigner) -> Self {
Self::new(signer)
}
}
impl fmt::Debug for EvmSigner {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("EvmSigner").field("address", &self.address).finish_non_exhaustive()
}
}
#[derive(Clone)]
#[non_exhaustive]
pub struct EvmConfig {
pub rpc_url: String,
pub chain_id: u64,
pub signer: Option<EvmSigner>,
pub fee_token: Option<Address>,
pub tempo: Option<bool>,
pub polling_interval: Duration,
pub rpc_timeout: Duration,
pub rpc_retries: u32,
pub rpc_retry_backoff: Duration,
pub rpc_max_backoff: Duration,
pub receipt_timeout: Duration,
pub gas_limit_buffer_percent: u64,
pub max_fee_per_gas: Option<u128>,
pub max_lock_seconds: u64,
}
impl EvmConfig {
pub const DEFAULT_RECEIPT_TIMEOUT: Duration = Duration::from_secs(60);
pub fn new(rpc_url: impl Into<String>, chain_id: u64) -> Self {
Self {
rpc_url: rpc_url.into(),
chain_id,
signer: None,
fee_token: None,
tempo: None,
polling_interval: Duration::from_millis(500),
rpc_timeout: Duration::from_secs(10),
rpc_retries: 3,
rpc_retry_backoff: Duration::from_millis(250),
rpc_max_backoff: Duration::from_secs(5),
receipt_timeout: Self::DEFAULT_RECEIPT_TIMEOUT,
gas_limit_buffer_percent: 20,
max_fee_per_gas: None,
max_lock_seconds: 10_000_000,
}
}
pub fn with_signer(mut self, signer: impl Into<EvmSigner>) -> Self {
self.signer = Some(signer.into());
self
}
pub fn with_private_key(self, hex_key: &str) -> Result<Self, EvmError> {
Ok(self.with_signer(EvmSigner::from_private_key(hex_key)?))
}
pub fn with_fee_token(mut self, token: Address) -> Self {
self.fee_token = Some(token);
self
}
pub fn with_tempo(mut self, tempo: bool) -> Self {
self.tempo = Some(tempo);
self
}
pub fn with_polling_interval(mut self, interval: Duration) -> Self {
self.polling_interval = interval;
self
}
pub fn with_receipt_timeout(mut self, timeout: Duration) -> Self {
self.receipt_timeout = timeout;
self
}
pub fn with_rpc_policy(mut self, timeout: Duration, retries: u32, backoff: Duration) -> Self {
self.rpc_timeout = timeout;
self.rpc_retries = retries;
self.rpc_retry_backoff = backoff;
self
}
pub fn with_max_fee_per_gas(mut self, cap: u128) -> Self {
self.max_fee_per_gas = Some(cap);
self
}
pub fn is_tempo(&self) -> bool {
self.tempo.unwrap_or(self.fee_token.is_some() || is_tempo_chain_id(self.chain_id))
}
pub fn validate(&self) -> Result<(), EvmError> {
if self.chain_id == 0 {
return Err(EvmError::Config("chain id 0 is not valid".to_owned()));
}
if let Some(fee_token) = self.fee_token {
let known_non_tempo = crate::networks::known_network(self.chain_id)
.is_some_and(|n| n.chain != crate::networks::EvmChainName::Tempo);
if !self.is_tempo() || known_non_tempo {
return Err(EvmError::Config("fee_token is only supported on Tempo chains".to_owned()));
}
if !is_tip20_address(fee_token) {
return Err(EvmError::Config(format!("fee token {fee_token} is not a TIP-20 address (0x20c0…)")));
}
if !cfg!(feature = "tempo") {
return Err(EvmError::Config(
"fee_token needs paralock-evm's `tempo` feature (Tempo transactions)".to_owned(),
));
}
}
if self.polling_interval.is_zero() {
return Err(EvmError::Config("polling_interval must be positive".to_owned()));
}
if self.rpc_timeout.is_zero() || self.receipt_timeout.is_zero() {
return Err(EvmError::Config("timeouts must be positive".to_owned()));
}
if self.gas_limit_buffer_percent > 1_000 {
return Err(EvmError::Config("gas_limit_buffer_percent above 1000 is not sensible".to_owned()));
}
if self.max_lock_seconds == 0 {
return Err(EvmError::Config("max_lock_seconds must be positive".to_owned()));
}
Ok(())
}
}
impl fmt::Debug for EvmConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("EvmConfig")
.field("rpc_url", &"<redacted>")
.field("chain_id", &self.chain_id)
.field("signer", &self.signer)
.field("fee_token", &self.fee_token)
.field("tempo", &self.tempo)
.field("polling_interval", &self.polling_interval)
.field("rpc_timeout", &self.rpc_timeout)
.field("rpc_retries", &self.rpc_retries)
.field("receipt_timeout", &self.receipt_timeout)
.field("gas_limit_buffer_percent", &self.gas_limit_buffer_percent)
.field("max_fee_per_gas", &self.max_fee_per_gas)
.field("max_lock_seconds", &self.max_lock_seconds)
.finish_non_exhaustive()
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use crate::tempo::TEMPO_TOKENS;
const KEY: &str = "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80";
#[test]
fn debug_never_shows_secrets() {
let config = EvmConfig::new("https://rpc.example/v2/SECRETKEY", 8453).with_private_key(KEY).unwrap();
let debug = format!("{config:?}");
assert!(!debug.contains("SECRETKEY"), "{debug}");
assert!(!debug.to_lowercase().contains("ac0974bec39a17e3"), "{debug}");
assert!(debug.to_lowercase().contains("0xf39fd6e51aad88f6f4ce6ab8827279cfffb92266"), "{debug}");
}
#[test]
fn invalid_keys_are_not_echoed() {
let err = EvmSigner::from_private_key("0xdeadbeefSECRET").unwrap_err().to_string();
assert!(!err.contains("SECRET"), "{err}");
}
#[test]
fn tempo_detection_and_fee_token_rules() {
assert!(EvmConfig::new("http://x", 31337).is_tempo());
assert!(EvmConfig::new("http://x", 42431).is_tempo());
assert!(!EvmConfig::new("http://x", 31338).is_tempo());
assert!(!EvmConfig::new("http://x", 31337).with_tempo(false).is_tempo());
assert!(EvmConfig::new("http://x", 8453).with_fee_token(TEMPO_TOKENS.path_usd).validate().is_err());
assert!(EvmConfig::new("http://x", 42431).with_fee_token(Address::repeat_byte(1)).validate().is_err());
let tempo = EvmConfig::new("http://x", 42431).with_fee_token(TEMPO_TOKENS.path_usd).validate();
assert_eq!(tempo.is_ok(), cfg!(feature = "tempo"));
assert!(EvmConfig::new("http://x", 0).validate().is_err());
}
#[test]
fn receipt_timeout_default_and_validation() {
let config = EvmConfig::new("http://x", 8453);
assert_eq!(config.receipt_timeout, Duration::from_secs(60));
assert_eq!(config.receipt_timeout, EvmConfig::DEFAULT_RECEIPT_TIMEOUT);
assert!(config.clone().with_receipt_timeout(Duration::ZERO).validate().is_err());
assert!(config.with_receipt_timeout(Duration::from_secs(5)).validate().is_ok());
}
}