use serde::{Deserialize, Serialize};
use crate::DocumentId;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EthereumTimestamp {
pub transaction_hash: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub block_number: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub block_hash: Option<String>,
pub document_hash: DocumentId,
pub network: EthereumNetwork,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub confirmations: Option<u64>,
pub method: EthereumTimestampMethod,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub contract_address: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub block_timestamp: Option<u64>,
}
impl EthereumTimestamp {
#[must_use]
pub fn new(
transaction_hash: String,
document_hash: DocumentId,
network: EthereumNetwork,
) -> Self {
Self {
transaction_hash,
block_number: None,
block_hash: None,
document_hash,
network,
confirmations: None,
method: EthereumTimestampMethod::TransactionData,
contract_address: None,
block_timestamp: None,
}
}
#[must_use]
pub fn with_block_number(mut self, block_number: u64) -> Self {
self.block_number = Some(block_number);
self
}
#[must_use]
pub fn with_block_hash(mut self, block_hash: String) -> Self {
self.block_hash = Some(block_hash);
self
}
#[must_use]
pub fn with_confirmations(mut self, confirmations: u64) -> Self {
self.confirmations = Some(confirmations);
self
}
#[must_use]
pub fn with_method(mut self, method: EthereumTimestampMethod) -> Self {
self.method = method;
self
}
#[must_use]
pub fn with_contract(mut self, address: String) -> Self {
self.contract_address = Some(address);
self.method = EthereumTimestampMethod::SmartContract;
self
}
#[must_use]
pub fn with_block_timestamp(mut self, timestamp: u64) -> Self {
self.block_timestamp = Some(timestamp);
self
}
#[must_use]
pub fn is_confirmed(&self, min_confirmations: u64) -> bool {
self.confirmations.is_some_and(|c| c >= min_confirmations)
}
#[must_use]
pub fn is_valid_tx_hash(&self) -> bool {
self.transaction_hash.len() == 66
&& self.transaction_hash.starts_with("0x")
&& self.transaction_hash[2..]
.chars()
.all(|c| c.is_ascii_hexdigit())
}
#[must_use]
pub fn unix_timestamp(&self) -> Option<u64> {
self.block_timestamp
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EthereumNetwork {
Mainnet,
Sepolia,
Holesky,
Polygon,
Arbitrum,
Optimism,
Base,
Custom(u64),
}
impl EthereumNetwork {
#[must_use]
pub const fn chain_id(&self) -> u64 {
match self {
Self::Mainnet => 1,
Self::Sepolia => 11_155_111,
Self::Holesky => 17000,
Self::Polygon => 137,
Self::Arbitrum => 42161,
Self::Optimism => 10,
Self::Base => 8453,
Self::Custom(id) => *id,
}
}
#[must_use]
pub const fn name(&self) -> &'static str {
match self {
Self::Mainnet => "Ethereum Mainnet",
Self::Sepolia => "Sepolia Testnet",
Self::Holesky => "Holesky Testnet",
Self::Polygon => "Polygon Mainnet",
Self::Arbitrum => "Arbitrum One",
Self::Optimism => "Optimism",
Self::Base => "Base",
Self::Custom(_) => "Custom Network",
}
}
#[must_use]
pub const fn is_production(&self) -> bool {
matches!(
self,
Self::Mainnet | Self::Polygon | Self::Arbitrum | Self::Optimism | Self::Base
)
}
#[must_use]
pub fn explorer_url(&self, tx_hash: &str) -> Option<String> {
match self {
Self::Mainnet => Some(format!("https://etherscan.io/tx/{tx_hash}")),
Self::Sepolia => Some(format!("https://sepolia.etherscan.io/tx/{tx_hash}")),
Self::Holesky => Some(format!("https://holesky.etherscan.io/tx/{tx_hash}")),
Self::Polygon => Some(format!("https://polygonscan.com/tx/{tx_hash}")),
Self::Arbitrum => Some(format!("https://arbiscan.io/tx/{tx_hash}")),
Self::Optimism => Some(format!("https://optimistic.etherscan.io/tx/{tx_hash}")),
Self::Base => Some(format!("https://basescan.org/tx/{tx_hash}")),
Self::Custom(_) => None,
}
}
}
impl std::fmt::Display for EthereumNetwork {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.name())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::Display)]
#[serde(rename_all = "camelCase")]
pub enum EthereumTimestampMethod {
#[strum(serialize = "Transaction Data")]
TransactionData,
#[strum(serialize = "Smart Contract Event")]
SmartContract,
#[strum(serialize = "Contract Storage")]
ContractStorage,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EthereumVerification {
pub verified: bool,
pub block_number: Option<u64>,
pub confirmations: u64,
pub block_timestamp: Option<u64>,
pub hash_matches: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl EthereumVerification {
#[must_use]
pub fn success(block_number: u64, confirmations: u64, block_timestamp: u64) -> Self {
Self {
verified: true,
block_number: Some(block_number),
confirmations,
block_timestamp: Some(block_timestamp),
hash_matches: true,
error: None,
}
}
#[must_use]
pub fn failure(error: impl Into<String>) -> Self {
Self {
verified: false,
block_number: None,
confirmations: 0,
block_timestamp: None,
hash_matches: false,
error: Some(error.into()),
}
}
#[must_use]
pub fn pending() -> Self {
Self {
verified: false,
block_number: None,
confirmations: 0,
block_timestamp: None,
hash_matches: false,
error: Some("Transaction not yet confirmed".to_string()),
}
}
}
#[derive(Debug, Clone)]
pub struct EthereumConfig {
pub min_confirmations: u64,
pub rpc_url: Option<String>,
pub use_etherscan: bool,
pub etherscan_api_key: Option<String>,
}
impl Default for EthereumConfig {
fn default() -> Self {
Self {
min_confirmations: 12, rpc_url: None,
use_etherscan: false,
etherscan_api_key: None,
}
}
}
impl EthereumConfig {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_min_confirmations(mut self, confirmations: u64) -> Self {
self.min_confirmations = confirmations;
self
}
#[must_use]
pub fn with_rpc_url(mut self, url: impl Into<String>) -> Self {
self.rpc_url = Some(url.into());
self
}
#[must_use]
pub fn with_etherscan(mut self, api_key: impl Into<String>) -> Self {
self.use_etherscan = true;
self.etherscan_api_key = Some(api_key.into());
self
}
}
#[must_use]
pub fn verify_offline(
timestamp: &EthereumTimestamp,
config: &EthereumConfig,
) -> EthereumVerification {
if !timestamp.is_valid_tx_hash() {
return EthereumVerification::failure("Invalid transaction hash format");
}
if let Some(confirmations) = timestamp.confirmations {
if confirmations >= config.min_confirmations {
if let (Some(block_num), Some(block_ts)) =
(timestamp.block_number, timestamp.block_timestamp)
{
let mut result = EthereumVerification::success(block_num, confirmations, block_ts);
result.hash_matches = false;
return result;
}
} else {
return EthereumVerification::failure(format!(
"Insufficient confirmations: {} < {}",
confirmations, config.min_confirmations
));
}
}
EthereumVerification::failure("Cannot verify offline without confirmation data")
}
#[cfg(test)]
mod tests {
use super::*;
fn test_hash() -> DocumentId {
"sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
.parse()
.unwrap()
}
fn test_tx_hash() -> String {
"0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string()
}
#[test]
fn test_ethereum_timestamp_creation() {
let timestamp =
EthereumTimestamp::new(test_tx_hash(), test_hash(), EthereumNetwork::Mainnet);
assert_eq!(timestamp.network, EthereumNetwork::Mainnet);
assert!(timestamp.is_valid_tx_hash());
}
#[test]
fn test_ethereum_timestamp_builder() {
let timestamp =
EthereumTimestamp::new(test_tx_hash(), test_hash(), EthereumNetwork::Mainnet)
.with_block_number(12_345_678)
.with_confirmations(100)
.with_block_timestamp(1_700_000_000);
assert_eq!(timestamp.block_number, Some(12_345_678));
assert_eq!(timestamp.confirmations, Some(100));
assert!(timestamp.is_confirmed(50));
}
#[test]
fn test_invalid_tx_hash() {
let timestamp =
EthereumTimestamp::new("invalid".to_string(), test_hash(), EthereumNetwork::Mainnet);
assert!(!timestamp.is_valid_tx_hash());
}
#[test]
fn test_ethereum_network_chain_id() {
assert_eq!(EthereumNetwork::Mainnet.chain_id(), 1);
assert_eq!(EthereumNetwork::Polygon.chain_id(), 137);
assert_eq!(EthereumNetwork::Custom(99999).chain_id(), 99999);
}
#[test]
fn test_ethereum_network_is_production() {
assert!(EthereumNetwork::Mainnet.is_production());
assert!(EthereumNetwork::Polygon.is_production());
assert!(!EthereumNetwork::Sepolia.is_production());
}
#[test]
fn test_ethereum_network_explorer_url() {
let url = EthereumNetwork::Mainnet.explorer_url("0x1234");
assert_eq!(url, Some("https://etherscan.io/tx/0x1234".to_string()));
let custom_url = EthereumNetwork::Custom(12345).explorer_url("0x1234");
assert!(custom_url.is_none());
}
#[test]
fn test_ethereum_verification_success() {
let result = EthereumVerification::success(12_345_678, 100, 1_700_000_000);
assert!(result.verified);
assert!(result.hash_matches);
assert_eq!(result.confirmations, 100);
}
#[test]
fn test_ethereum_verification_failure() {
let result = EthereumVerification::failure("Test error");
assert!(!result.verified);
assert_eq!(result.error, Some("Test error".to_string()));
}
#[test]
fn test_verify_offline_valid() {
let timestamp =
EthereumTimestamp::new(test_tx_hash(), test_hash(), EthereumNetwork::Mainnet)
.with_block_number(12_345_678)
.with_confirmations(100)
.with_block_timestamp(1_700_000_000);
let config = EthereumConfig::new().with_min_confirmations(12);
let result = verify_offline(×tamp, &config);
assert!(result.verified);
assert!(!result.hash_matches);
}
#[test]
fn test_verify_offline_insufficient_confirmations() {
let timestamp =
EthereumTimestamp::new(test_tx_hash(), test_hash(), EthereumNetwork::Mainnet)
.with_confirmations(5);
let config = EthereumConfig::new().with_min_confirmations(12);
let result = verify_offline(×tamp, &config);
assert!(!result.verified);
assert!(result.error.unwrap().contains("Insufficient"));
}
#[test]
fn test_verify_offline_invalid_hash() {
let timestamp =
EthereumTimestamp::new("invalid".to_string(), test_hash(), EthereumNetwork::Mainnet);
let config = EthereumConfig::default();
let result = verify_offline(×tamp, &config);
assert!(!result.verified);
assert!(result.error.unwrap().contains("Invalid transaction hash"));
}
#[test]
fn test_ethereum_config_builder() {
let config = EthereumConfig::new()
.with_min_confirmations(6)
.with_rpc_url("https://eth.example.com")
.with_etherscan("myapikey");
assert_eq!(config.min_confirmations, 6);
assert!(config.use_etherscan);
assert_eq!(config.etherscan_api_key, Some("myapikey".to_string()));
}
#[test]
fn test_timestamp_method_display() {
assert_eq!(
EthereumTimestampMethod::TransactionData.to_string(),
"Transaction Data"
);
assert_eq!(
EthereumTimestampMethod::SmartContract.to_string(),
"Smart Contract Event"
);
}
#[test]
fn test_ethereum_timestamp_serialization() {
let timestamp =
EthereumTimestamp::new(test_tx_hash(), test_hash(), EthereumNetwork::Mainnet)
.with_block_number(12_345_678);
let json = serde_json::to_string(×tamp).unwrap();
assert!(json.contains("\"network\":\"mainnet\""));
assert!(json.contains("\"blockNumber\":12345678"));
let deserialized: EthereumTimestamp = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.block_number, Some(12_345_678));
}
}