Skip to main content

cdx_core/provenance/
ethereum.rs

1//! Ethereum blockchain timestamp anchoring.
2//!
3//! This module provides types and verification for Ethereum-based
4//! document timestamping. Timestamps are anchored by storing a hash
5//! in an Ethereum transaction.
6//!
7//! # Timestamp Methods
8//!
9//! Ethereum timestamps can be created in several ways:
10//!
11//! 1. **Transaction data**: Store the hash in the `input` field of a transaction
12//! 2. **Smart contract**: Call a timestamping contract that emits an event
13//! 3. **`OP_RETURN` equivalent**: Use the transaction data field for hash storage
14//!
15//! # Verification
16//!
17//! Verification requires:
18//! 1. The transaction hash
19//! 2. Access to an Ethereum node or block explorer API
20//! 3. Confirmation that the transaction is in a confirmed block
21//!
22//! # Example
23//!
24//! ```rust,ignore
25//! use cdx_core::provenance::ethereum::{EthereumTimestamp, EthereumNetwork};
26//!
27//! let timestamp = EthereumTimestamp::new(
28//!     "0x1234...".to_string(),
29//!     document_hash,
30//!     EthereumNetwork::Mainnet,
31//! );
32//!
33//! // Verify with a node/API
34//! let verified = verifier.verify(&timestamp).await?;
35//! ```
36
37use serde::{Deserialize, Serialize};
38
39use crate::DocumentId;
40
41/// An Ethereum-based timestamp record.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct EthereumTimestamp {
45    /// Transaction hash (0x-prefixed hex string).
46    pub transaction_hash: String,
47
48    /// Block number containing the transaction.
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub block_number: Option<u64>,
51
52    /// Block hash.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub block_hash: Option<String>,
55
56    /// Document hash that was timestamped.
57    pub document_hash: DocumentId,
58
59    /// Network where the timestamp was anchored.
60    pub network: EthereumNetwork,
61
62    /// Number of confirmations at time of verification.
63    #[serde(default, skip_serializing_if = "Option::is_none")]
64    pub confirmations: Option<u64>,
65
66    /// Timestamp method used.
67    pub method: EthereumTimestampMethod,
68
69    /// Smart contract address (for contract-based timestamps).
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub contract_address: Option<String>,
72
73    /// Block timestamp (Unix epoch seconds).
74    #[serde(default, skip_serializing_if = "Option::is_none")]
75    pub block_timestamp: Option<u64>,
76}
77
78impl EthereumTimestamp {
79    /// Create a new Ethereum timestamp.
80    #[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    /// Set the block number.
100    #[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    /// Set the block hash.
107    #[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    /// Set the number of confirmations.
114    #[must_use]
115    pub fn with_confirmations(mut self, confirmations: u64) -> Self {
116        self.confirmations = Some(confirmations);
117        self
118    }
119
120    /// Set the timestamp method.
121    #[must_use]
122    pub fn with_method(mut self, method: EthereumTimestampMethod) -> Self {
123        self.method = method;
124        self
125    }
126
127    /// Set the contract address (for contract-based timestamps).
128    #[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    /// Set the block timestamp.
136    #[must_use]
137    pub fn with_block_timestamp(mut self, timestamp: u64) -> Self {
138        self.block_timestamp = Some(timestamp);
139        self
140    }
141
142    /// Check if the timestamp has sufficient confirmations.
143    #[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    /// Validate the transaction hash format.
149    #[must_use]
150    pub fn is_valid_tx_hash(&self) -> bool {
151        // Ethereum transaction hashes are 66 characters (0x + 64 hex chars)
152        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    /// Get the timestamp as a Unix epoch timestamp if available.
160    #[must_use]
161    pub fn unix_timestamp(&self) -> Option<u64> {
162        self.block_timestamp
163    }
164}
165
166/// Ethereum network for timestamp anchoring.
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
168#[serde(rename_all = "lowercase")]
169pub enum EthereumNetwork {
170    /// Ethereum Mainnet (chain ID 1).
171    Mainnet,
172    /// Sepolia testnet (chain ID 11155111).
173    Sepolia,
174    /// Holesky testnet (chain ID 17000).
175    Holesky,
176    /// Polygon Mainnet (chain ID 137).
177    Polygon,
178    /// Arbitrum One (chain ID 42161).
179    Arbitrum,
180    /// Optimism (chain ID 10).
181    Optimism,
182    /// Base (chain ID 8453).
183    Base,
184    /// Custom network with chain ID.
185    Custom(u64),
186}
187
188impl EthereumNetwork {
189    /// Get the chain ID for this network.
190    #[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    /// Get the network name.
205    #[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    /// Check if this is a production network.
220    #[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    /// Get a block explorer URL for transactions.
229    #[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/// Method used to anchor the timestamp on Ethereum.
251#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, strum::Display)]
252#[serde(rename_all = "camelCase")]
253pub enum EthereumTimestampMethod {
254    /// Hash stored in transaction input data.
255    #[strum(serialize = "Transaction Data")]
256    TransactionData,
257    /// Hash emitted via smart contract event.
258    #[strum(serialize = "Smart Contract Event")]
259    SmartContract,
260    /// Hash stored in contract storage.
261    #[strum(serialize = "Contract Storage")]
262    ContractStorage,
263}
264
265/// Result of Ethereum timestamp verification.
266#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
267#[serde(rename_all = "camelCase")]
268pub struct EthereumVerification {
269    /// Whether the timestamp is verified.
270    pub verified: bool,
271
272    /// Block number where the transaction was included.
273    pub block_number: Option<u64>,
274
275    /// Number of confirmations.
276    pub confirmations: u64,
277
278    /// Block timestamp (Unix epoch).
279    pub block_timestamp: Option<u64>,
280
281    /// Whether the hash in the transaction matches the document hash.
282    pub hash_matches: bool,
283
284    /// Any error message.
285    #[serde(default, skip_serializing_if = "Option::is_none")]
286    pub error: Option<String>,
287}
288
289impl EthereumVerification {
290    /// Create a successful verification result.
291    #[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    /// Create a failed verification result.
304    #[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    /// Create an unconfirmed (pending) result.
317    #[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/// Configuration for Ethereum timestamp verification.
331#[derive(Debug, Clone)]
332pub struct EthereumConfig {
333    /// Minimum confirmations required for a valid timestamp.
334    pub min_confirmations: u64,
335
336    /// RPC endpoint URL.
337    pub rpc_url: Option<String>,
338
339    /// Whether to use Etherscan API for verification.
340    pub use_etherscan: bool,
341
342    /// Etherscan API key.
343    pub etherscan_api_key: Option<String>,
344}
345
346impl Default for EthereumConfig {
347    fn default() -> Self {
348        Self {
349            min_confirmations: 12, // ~3 minutes on mainnet
350            rpc_url: None,
351            use_etherscan: false,
352            etherscan_api_key: None,
353        }
354    }
355}
356
357impl EthereumConfig {
358    /// Create a new configuration with default settings.
359    #[must_use]
360    pub fn new() -> Self {
361        Self::default()
362    }
363
364    /// Set the minimum confirmations.
365    #[must_use]
366    pub fn with_min_confirmations(mut self, confirmations: u64) -> Self {
367        self.min_confirmations = confirmations;
368        self
369    }
370
371    /// Set the RPC URL.
372    #[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    /// Enable Etherscan API verification.
379    #[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/// Verify an Ethereum timestamp offline (format and structure only).
388///
389/// This checks:
390/// - Transaction hash format
391/// - Network validity
392/// - Confirmation count (if provided)
393///
394/// For full verification, use an Ethereum RPC client.
395#[must_use]
396pub fn verify_offline(
397    timestamp: &EthereumTimestamp,
398    config: &EthereumConfig,
399) -> EthereumVerification {
400    // Check transaction hash format
401    if !timestamp.is_valid_tx_hash() {
402        return EthereumVerification::failure("Invalid transaction hash format");
403    }
404
405    // Check if we have confirmation data
406    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    // No confirmation data - cannot verify offline
422    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(&timestamp, &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(&timestamp, &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(&timestamp, &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(&timestamp).unwrap();
577        assert!(json.contains("\"network\":\"mainnet\""));
578        assert!(json.contains("\"blockNumber\":12345678")); // JSON doesn't use underscores
579
580        let deserialized: EthereumTimestamp = serde_json::from_str(&json).unwrap();
581        assert_eq!(deserialized.block_number, Some(12_345_678));
582    }
583}