1use serde::{Deserialize, Serialize};
38
39use crate::DocumentId;
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct EthereumTimestamp {
45 pub transaction_hash: String,
47
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub block_number: Option<u64>,
51
52 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub block_hash: Option<String>,
55
56 pub document_hash: DocumentId,
58
59 pub network: EthereumNetwork,
61
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub confirmations: Option<u64>,
65
66 pub method: EthereumTimestampMethod,
68
69 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub contract_address: Option<String>,
72
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub block_timestamp: Option<u64>,
76}
77
78impl EthereumTimestamp {
79 #[must_use]
81 pub fn new(
82 transaction_hash: String,
83 document_hash: DocumentId,
84 network: EthereumNetwork,
85 ) -> Self {
86 Self {
87 transaction_hash,
88 block_number: None,
89 block_hash: None,
90 document_hash,
91 network,
92 confirmations: None,
93 method: EthereumTimestampMethod::TransactionData,
94 contract_address: None,
95 block_timestamp: None,
96 }
97 }
98
99 #[must_use]
101 pub fn with_block_number(mut self, block_number: u64) -> Self {
102 self.block_number = Some(block_number);
103 self
104 }
105
106 #[must_use]
108 pub fn with_block_hash(mut self, block_hash: String) -> Self {
109 self.block_hash = Some(block_hash);
110 self
111 }
112
113 #[must_use]
115 pub fn with_confirmations(mut self, confirmations: u64) -> Self {
116 self.confirmations = Some(confirmations);
117 self
118 }
119
120 #[must_use]
122 pub fn with_method(mut self, method: EthereumTimestampMethod) -> Self {
123 self.method = method;
124 self
125 }
126
127 #[must_use]
129 pub fn with_contract(mut self, address: String) -> Self {
130 self.contract_address = Some(address);
131 self.method = EthereumTimestampMethod::SmartContract;
132 self
133 }
134
135 #[must_use]
137 pub fn with_block_timestamp(mut self, timestamp: u64) -> Self {
138 self.block_timestamp = Some(timestamp);
139 self
140 }
141
142 #[must_use]
144 pub fn is_confirmed(&self, min_confirmations: u64) -> bool {
145 self.confirmations.is_some_and(|c| c >= min_confirmations)
146 }
147
148 #[must_use]
150 pub fn is_valid_tx_hash(&self) -> bool {
151 self.transaction_hash.len() == 66
153 && self.transaction_hash.starts_with("0x")
154 && self.transaction_hash[2..]
155 .chars()
156 .all(|c| c.is_ascii_hexdigit())
157 }
158
159 #[must_use]
161 pub fn unix_timestamp(&self) -> Option<u64> {
162 self.block_timestamp
163 }
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
168#[serde(rename_all = "lowercase")]
169pub enum EthereumNetwork {
170 Mainnet,
172 Sepolia,
174 Holesky,
176 Polygon,
178 Arbitrum,
180 Optimism,
182 Base,
184 Custom(u64),
186}
187
188impl EthereumNetwork {
189 #[must_use]
191 pub const fn chain_id(&self) -> u64 {
192 match self {
193 Self::Mainnet => 1,
194 Self::Sepolia => 11_155_111,
195 Self::Holesky => 17000,
196 Self::Polygon => 137,
197 Self::Arbitrum => 42161,
198 Self::Optimism => 10,
199 Self::Base => 8453,
200 Self::Custom(id) => *id,
201 }
202 }
203
204 #[must_use]
206 pub const fn name(&self) -> &'static str {
207 match self {
208 Self::Mainnet => "Ethereum Mainnet",
209 Self::Sepolia => "Sepolia Testnet",
210 Self::Holesky => "Holesky Testnet",
211 Self::Polygon => "Polygon Mainnet",
212 Self::Arbitrum => "Arbitrum One",
213 Self::Optimism => "Optimism",
214 Self::Base => "Base",
215 Self::Custom(_) => "Custom Network",
216 }
217 }
218
219 #[must_use]
221 pub const fn is_production(&self) -> bool {
222 matches!(
223 self,
224 Self::Mainnet | Self::Polygon | Self::Arbitrum | Self::Optimism | Self::Base
225 )
226 }
227
228 #[must_use]
230 pub fn explorer_url(&self, tx_hash: &str) -> Option<String> {
231 match self {
232 Self::Mainnet => Some(format!("https://etherscan.io/tx/{tx_hash}")),
233 Self::Sepolia => Some(format!("https://sepolia.etherscan.io/tx/{tx_hash}")),
234 Self::Holesky => Some(format!("https://holesky.etherscan.io/tx/{tx_hash}")),
235 Self::Polygon => Some(format!("https://polygonscan.com/tx/{tx_hash}")),
236 Self::Arbitrum => Some(format!("https://arbiscan.io/tx/{tx_hash}")),
237 Self::Optimism => Some(format!("https://optimistic.etherscan.io/tx/{tx_hash}")),
238 Self::Base => Some(format!("https://basescan.org/tx/{tx_hash}")),
239 Self::Custom(_) => None,
240 }
241 }
242}
243
244impl std::fmt::Display for EthereumNetwork {
245 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246 write!(f, "{}", self.name())
247 }
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::Display)]
252#[serde(rename_all = "camelCase")]
253pub enum EthereumTimestampMethod {
254 #[strum(serialize = "Transaction Data")]
256 TransactionData,
257 #[strum(serialize = "Smart Contract Event")]
259 SmartContract,
260 #[strum(serialize = "Contract Storage")]
262 ContractStorage,
263}
264
265#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267#[serde(rename_all = "camelCase")]
268pub struct EthereumVerification {
269 pub verified: bool,
271
272 pub block_number: Option<u64>,
274
275 pub confirmations: u64,
277
278 pub block_timestamp: Option<u64>,
280
281 pub hash_matches: bool,
283
284 #[serde(default, skip_serializing_if = "Option::is_none")]
286 pub error: Option<String>,
287}
288
289impl EthereumVerification {
290 #[must_use]
292 pub fn success(block_number: u64, confirmations: u64, block_timestamp: u64) -> Self {
293 Self {
294 verified: true,
295 block_number: Some(block_number),
296 confirmations,
297 block_timestamp: Some(block_timestamp),
298 hash_matches: true,
299 error: None,
300 }
301 }
302
303 #[must_use]
305 pub fn failure(error: impl Into<String>) -> Self {
306 Self {
307 verified: false,
308 block_number: None,
309 confirmations: 0,
310 block_timestamp: None,
311 hash_matches: false,
312 error: Some(error.into()),
313 }
314 }
315
316 #[must_use]
318 pub fn pending() -> Self {
319 Self {
320 verified: false,
321 block_number: None,
322 confirmations: 0,
323 block_timestamp: None,
324 hash_matches: false,
325 error: Some("Transaction not yet confirmed".to_string()),
326 }
327 }
328}
329
330#[derive(Debug, Clone)]
332pub struct EthereumConfig {
333 pub min_confirmations: u64,
335
336 pub rpc_url: Option<String>,
338
339 pub use_etherscan: bool,
341
342 pub etherscan_api_key: Option<String>,
344}
345
346impl Default for EthereumConfig {
347 fn default() -> Self {
348 Self {
349 min_confirmations: 12, rpc_url: None,
351 use_etherscan: false,
352 etherscan_api_key: None,
353 }
354 }
355}
356
357impl EthereumConfig {
358 #[must_use]
360 pub fn new() -> Self {
361 Self::default()
362 }
363
364 #[must_use]
366 pub fn with_min_confirmations(mut self, confirmations: u64) -> Self {
367 self.min_confirmations = confirmations;
368 self
369 }
370
371 #[must_use]
373 pub fn with_rpc_url(mut self, url: impl Into<String>) -> Self {
374 self.rpc_url = Some(url.into());
375 self
376 }
377
378 #[must_use]
380 pub fn with_etherscan(mut self, api_key: impl Into<String>) -> Self {
381 self.use_etherscan = true;
382 self.etherscan_api_key = Some(api_key.into());
383 self
384 }
385}
386
387#[must_use]
396pub fn verify_offline(
397 timestamp: &EthereumTimestamp,
398 config: &EthereumConfig,
399) -> EthereumVerification {
400 if !timestamp.is_valid_tx_hash() {
402 return EthereumVerification::failure("Invalid transaction hash format");
403 }
404
405 if let Some(confirmations) = timestamp.confirmations {
407 if confirmations >= config.min_confirmations {
408 if let (Some(block_num), Some(block_ts)) =
409 (timestamp.block_number, timestamp.block_timestamp)
410 {
411 let mut result = EthereumVerification::success(block_num, confirmations, block_ts);
412 result.hash_matches = false;
414 return result;
415 }
416 } else {
417 return EthereumVerification::failure(format!(
418 "Insufficient confirmations: {} < {}",
419 confirmations, config.min_confirmations
420 ));
421 }
422 }
423
424 EthereumVerification::failure("Cannot verify offline without confirmation data")
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431
432 fn test_hash() -> DocumentId {
433 "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
434 .parse()
435 .unwrap()
436 }
437
438 fn test_tx_hash() -> String {
439 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string()
440 }
441
442 #[test]
443 fn test_ethereum_timestamp_creation() {
444 let timestamp =
445 EthereumTimestamp::new(test_tx_hash(), test_hash(), EthereumNetwork::Mainnet);
446
447 assert_eq!(timestamp.network, EthereumNetwork::Mainnet);
448 assert!(timestamp.is_valid_tx_hash());
449 }
450
451 #[test]
452 fn test_ethereum_timestamp_builder() {
453 let timestamp =
454 EthereumTimestamp::new(test_tx_hash(), test_hash(), EthereumNetwork::Mainnet)
455 .with_block_number(12_345_678)
456 .with_confirmations(100)
457 .with_block_timestamp(1_700_000_000);
458
459 assert_eq!(timestamp.block_number, Some(12_345_678));
460 assert_eq!(timestamp.confirmations, Some(100));
461 assert!(timestamp.is_confirmed(50));
462 }
463
464 #[test]
465 fn test_invalid_tx_hash() {
466 let timestamp =
467 EthereumTimestamp::new("invalid".to_string(), test_hash(), EthereumNetwork::Mainnet);
468
469 assert!(!timestamp.is_valid_tx_hash());
470 }
471
472 #[test]
473 fn test_ethereum_network_chain_id() {
474 assert_eq!(EthereumNetwork::Mainnet.chain_id(), 1);
475 assert_eq!(EthereumNetwork::Polygon.chain_id(), 137);
476 assert_eq!(EthereumNetwork::Custom(99999).chain_id(), 99999);
477 }
478
479 #[test]
480 fn test_ethereum_network_is_production() {
481 assert!(EthereumNetwork::Mainnet.is_production());
482 assert!(EthereumNetwork::Polygon.is_production());
483 assert!(!EthereumNetwork::Sepolia.is_production());
484 }
485
486 #[test]
487 fn test_ethereum_network_explorer_url() {
488 let url = EthereumNetwork::Mainnet.explorer_url("0x1234");
489 assert_eq!(url, Some("https://etherscan.io/tx/0x1234".to_string()));
490
491 let custom_url = EthereumNetwork::Custom(12345).explorer_url("0x1234");
492 assert!(custom_url.is_none());
493 }
494
495 #[test]
496 fn test_ethereum_verification_success() {
497 let result = EthereumVerification::success(12_345_678, 100, 1_700_000_000);
498 assert!(result.verified);
499 assert!(result.hash_matches);
500 assert_eq!(result.confirmations, 100);
501 }
502
503 #[test]
504 fn test_ethereum_verification_failure() {
505 let result = EthereumVerification::failure("Test error");
506 assert!(!result.verified);
507 assert_eq!(result.error, Some("Test error".to_string()));
508 }
509
510 #[test]
511 fn test_verify_offline_valid() {
512 let timestamp =
513 EthereumTimestamp::new(test_tx_hash(), test_hash(), EthereumNetwork::Mainnet)
514 .with_block_number(12_345_678)
515 .with_confirmations(100)
516 .with_block_timestamp(1_700_000_000);
517
518 let config = EthereumConfig::new().with_min_confirmations(12);
519 let result = verify_offline(×tamp, &config);
520
521 assert!(result.verified);
522 assert!(!result.hash_matches);
524 }
525
526 #[test]
527 fn test_verify_offline_insufficient_confirmations() {
528 let timestamp =
529 EthereumTimestamp::new(test_tx_hash(), test_hash(), EthereumNetwork::Mainnet)
530 .with_confirmations(5);
531
532 let config = EthereumConfig::new().with_min_confirmations(12);
533 let result = verify_offline(×tamp, &config);
534
535 assert!(!result.verified);
536 assert!(result.error.unwrap().contains("Insufficient"));
537 }
538
539 #[test]
540 fn test_verify_offline_invalid_hash() {
541 let timestamp =
542 EthereumTimestamp::new("invalid".to_string(), test_hash(), EthereumNetwork::Mainnet);
543
544 let config = EthereumConfig::default();
545 let result = verify_offline(×tamp, &config);
546
547 assert!(!result.verified);
548 assert!(result.error.unwrap().contains("Invalid transaction hash"));
549 }
550
551 #[test]
552 fn test_ethereum_config_builder() {
553 let config = EthereumConfig::new()
554 .with_min_confirmations(6)
555 .with_rpc_url("https://eth.example.com")
556 .with_etherscan("myapikey");
557
558 assert_eq!(config.min_confirmations, 6);
559 assert!(config.use_etherscan);
560 assert_eq!(config.etherscan_api_key, Some("myapikey".to_string()));
561 }
562
563 #[test]
564 fn test_timestamp_method_display() {
565 assert_eq!(
566 EthereumTimestampMethod::TransactionData.to_string(),
567 "Transaction Data"
568 );
569 assert_eq!(
570 EthereumTimestampMethod::SmartContract.to_string(),
571 "Smart Contract Event"
572 );
573 }
574
575 #[test]
576 fn test_ethereum_timestamp_serialization() {
577 let timestamp =
578 EthereumTimestamp::new(test_tx_hash(), test_hash(), EthereumNetwork::Mainnet)
579 .with_block_number(12_345_678);
580
581 let json = serde_json::to_string(×tamp).unwrap();
582 assert!(json.contains("\"network\":\"mainnet\""));
583 assert!(json.contains("\"blockNumber\":12345678")); let deserialized: EthereumTimestamp = serde_json::from_str(&json).unwrap();
586 assert_eq!(deserialized.block_number, Some(12_345_678));
587 }
588}