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 return EthereumVerification::success(block_num, confirmations, block_ts);
412 }
413 } else {
414 return EthereumVerification::failure(format!(
415 "Insufficient confirmations: {} < {}",
416 confirmations, config.min_confirmations
417 ));
418 }
419 }
420
421 EthereumVerification::failure("Cannot verify offline without confirmation data")
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428
429 fn test_hash() -> DocumentId {
430 "sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
431 .parse()
432 .unwrap()
433 }
434
435 fn test_tx_hash() -> String {
436 "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef".to_string()
437 }
438
439 #[test]
440 fn test_ethereum_timestamp_creation() {
441 let timestamp =
442 EthereumTimestamp::new(test_tx_hash(), test_hash(), EthereumNetwork::Mainnet);
443
444 assert_eq!(timestamp.network, EthereumNetwork::Mainnet);
445 assert!(timestamp.is_valid_tx_hash());
446 }
447
448 #[test]
449 fn test_ethereum_timestamp_builder() {
450 let timestamp =
451 EthereumTimestamp::new(test_tx_hash(), test_hash(), EthereumNetwork::Mainnet)
452 .with_block_number(12_345_678)
453 .with_confirmations(100)
454 .with_block_timestamp(1_700_000_000);
455
456 assert_eq!(timestamp.block_number, Some(12_345_678));
457 assert_eq!(timestamp.confirmations, Some(100));
458 assert!(timestamp.is_confirmed(50));
459 }
460
461 #[test]
462 fn test_invalid_tx_hash() {
463 let timestamp =
464 EthereumTimestamp::new("invalid".to_string(), test_hash(), EthereumNetwork::Mainnet);
465
466 assert!(!timestamp.is_valid_tx_hash());
467 }
468
469 #[test]
470 fn test_ethereum_network_chain_id() {
471 assert_eq!(EthereumNetwork::Mainnet.chain_id(), 1);
472 assert_eq!(EthereumNetwork::Polygon.chain_id(), 137);
473 assert_eq!(EthereumNetwork::Custom(99999).chain_id(), 99999);
474 }
475
476 #[test]
477 fn test_ethereum_network_is_production() {
478 assert!(EthereumNetwork::Mainnet.is_production());
479 assert!(EthereumNetwork::Polygon.is_production());
480 assert!(!EthereumNetwork::Sepolia.is_production());
481 }
482
483 #[test]
484 fn test_ethereum_network_explorer_url() {
485 let url = EthereumNetwork::Mainnet.explorer_url("0x1234");
486 assert_eq!(url, Some("https://etherscan.io/tx/0x1234".to_string()));
487
488 let custom_url = EthereumNetwork::Custom(12345).explorer_url("0x1234");
489 assert!(custom_url.is_none());
490 }
491
492 #[test]
493 fn test_ethereum_verification_success() {
494 let result = EthereumVerification::success(12_345_678, 100, 1_700_000_000);
495 assert!(result.verified);
496 assert!(result.hash_matches);
497 assert_eq!(result.confirmations, 100);
498 }
499
500 #[test]
501 fn test_ethereum_verification_failure() {
502 let result = EthereumVerification::failure("Test error");
503 assert!(!result.verified);
504 assert_eq!(result.error, Some("Test error".to_string()));
505 }
506
507 #[test]
508 fn test_verify_offline_valid() {
509 let timestamp =
510 EthereumTimestamp::new(test_tx_hash(), test_hash(), EthereumNetwork::Mainnet)
511 .with_block_number(12_345_678)
512 .with_confirmations(100)
513 .with_block_timestamp(1_700_000_000);
514
515 let config = EthereumConfig::new().with_min_confirmations(12);
516 let result = verify_offline(×tamp, &config);
517
518 assert!(result.verified);
519 }
520
521 #[test]
522 fn test_verify_offline_insufficient_confirmations() {
523 let timestamp =
524 EthereumTimestamp::new(test_tx_hash(), test_hash(), EthereumNetwork::Mainnet)
525 .with_confirmations(5);
526
527 let config = EthereumConfig::new().with_min_confirmations(12);
528 let result = verify_offline(×tamp, &config);
529
530 assert!(!result.verified);
531 assert!(result.error.unwrap().contains("Insufficient"));
532 }
533
534 #[test]
535 fn test_verify_offline_invalid_hash() {
536 let timestamp =
537 EthereumTimestamp::new("invalid".to_string(), test_hash(), EthereumNetwork::Mainnet);
538
539 let config = EthereumConfig::default();
540 let result = verify_offline(×tamp, &config);
541
542 assert!(!result.verified);
543 assert!(result.error.unwrap().contains("Invalid transaction hash"));
544 }
545
546 #[test]
547 fn test_ethereum_config_builder() {
548 let config = EthereumConfig::new()
549 .with_min_confirmations(6)
550 .with_rpc_url("https://eth.example.com")
551 .with_etherscan("myapikey");
552
553 assert_eq!(config.min_confirmations, 6);
554 assert!(config.use_etherscan);
555 assert_eq!(config.etherscan_api_key, Some("myapikey".to_string()));
556 }
557
558 #[test]
559 fn test_timestamp_method_display() {
560 assert_eq!(
561 EthereumTimestampMethod::TransactionData.to_string(),
562 "Transaction Data"
563 );
564 assert_eq!(
565 EthereumTimestampMethod::SmartContract.to_string(),
566 "Smart Contract Event"
567 );
568 }
569
570 #[test]
571 fn test_ethereum_timestamp_serialization() {
572 let timestamp =
573 EthereumTimestamp::new(test_tx_hash(), test_hash(), EthereumNetwork::Mainnet)
574 .with_block_number(12_345_678);
575
576 let json = serde_json::to_string(×tamp).unwrap();
577 assert!(json.contains("\"network\":\"mainnet\""));
578 assert!(json.contains("\"blockNumber\":12345678")); let deserialized: EthereumTimestamp = serde_json::from_str(&json).unwrap();
581 assert_eq!(deserialized.block_number, Some(12_345_678));
582 }
583}