📜 Notary

This guide explains how you could implement a Notary contract on pod.

The Notary smart contract provides a timestamping service for documents. By submitting a document hash and an associated timestamp, users can create an immutable record that attests to the document’s existence at or before a certain time. Importantly, this mechanism proves the age of a document but does not make any assertions about its ownership or authorship.

 

Notary Contract

Features

  • Immutable Timestamping: Once a document is timestamped, the earliest submitted valid timestamp is retained.

  • Multiple Submissions: Re-submissions of the same document hash are permitted and can only reduce (never increase) the timestamp.

  • Conflict Resolution via Minimum Timestamp: Competing transactions for the same document hash are resolved by storing the smallest valid timestamp.

Smart contract interface

State

mapping(bytes32 => uint256) public timestamps;
  • Maps a document’s keccak256 hash to the smallest timestamp submitted.
  • Public visibility allows external verification of timestamps.

Events

event DocumentTimestamped(bytes32 indexed documentHash, address indexed submitter, uint256 timestamp);
  • Emitted whenever a document hash is timestamped or updated with an earlier timestamp.

Functions

function timestamp(bytes32 documentHash, uint256 ts) external;
Parameters
  • documentHash: Keccak256 hash of the document content.
  • ts: Proposed timestamp, which must be in the future at validation time.
Behavior
  • Requires ts to be in the future (requireTimeBefore(ts, ...)).
  • If the document has not been timestamped before, sets timestamps[documentHash] = ts.
  • If the document is already timestamped, updates it to the minimum of the existing and the new timestamp.
  • Emits DocumentTimestamped event.
pragma solidity ^0.8.26; import {requireTimeBefore} from "../../../contracts/src/lib/Time.sol"; function min(uint256 a, uint256 b) pure returns (uint256) { return a <= b ? a : b; } contract Notary { mapping(bytes32 => uint256) public timestamps; event DocumentTimestamped(bytes32 indexed documentHash, address indexed submitter, uint256 timestamp); /// @notice Submit a document hash to be timestamped /// @param documentHash The keccak256 hash of the document /// @param ts The timestamp of the document. Must be in the future. function timestamp(bytes32 documentHash, uint256 ts) external { requireTimeBefore(ts, "timestamp must be in the future"); if (timestamps[documentHash] == 0) { timestamps[documentHash] = ts; emit DocumentTimestamped(documentHash, msg.sender, ts); return; } uint256 minTimestamp = min(ts, timestamps[documentHash]); if (minTimestamp != timestamps[documentHash]) { timestamps[documentHash] = minTimestamp; emit DocumentTimestamped(documentHash, msg.sender, minTimestamp); } } }

🧠 pod caveats

Execution Model in pod network

The pod execution environment does not guarantee a total order of transactions signed by different signers. Instead:

  • Transactions signed by the same signer are strictly ordered via their nonce.
  • Transactions signed by different signers may be executed in different orders on different validators, leading to temporary state divergence across the network.

This property can lead to the following behavior in the context of the Notary contract:

  • If multiple users simultaneously attempt to timestamp the same document with different timestamps, validators might execute these transactions in different orders.
  • Consequently, different validators may temporarily hold different timestamps[documentHash] values depending on execution order.

Consistency Mechanism

Despite this temporary divergence, pod ensures eventual consistency:

  • As all validators eventually process the same set of transactions, the state converges to a single consistent result.
  • The contract is designed to accommodate this by always applying the minimum timestamp, ensuring that final state across validators aligns.

Attestation vs. Execution

The requireTimeBefore function executes only during the attestation stage, not during execution:

  • Attestation Stage: Ensures the timestamp is in the future at the time of submission. The transaction is decided to be valid in the eyes of each validator separately and signed, making it eligible for execution in the second stage.
  • Execution Stage: The contract updates state without re-checking requireTimeBefore. This is intentional so that all validators execute the transaction if the quorum attested its validity no matter when the transaction is actually executed.

Security Considerations

  • This contract does not validate ownership of the document.
  • Users could timestamp arbitrary data, so clients must verify document authenticity through external means.
 

Interacting with the contract

A Rust-based client can interact with the Notary contract via the pod-sdk, which includes utilities for constructing transactions, querying state, and subscribing to events.

Generating bindings

Bindings can be auto-generated using the

alloy-sol-types::sol! macro. It needs to be

passed path to the smart contract file.

alloy::sol!( #[sol(rpc)] "Notary.sol" );

Timestamping a document

Here’s how a document can be timestamped.

First, we need to create a PodProvider and an instance of the Notary contract client.

let pod_provider = PodProviderBuilder::with_recommended_settings() .with_private_key(pk) .on_url(&rpc_url) .await?; let notary = Notary::new(contract_address, pod_provider.clone());

Then, the document is hashed using keccak256 and sent in a transaction calling the timestamp() function.

let document_hash = keccak256(doc); let pendix_tx = notary .timestamp(document_hash, U256::from(timestamp.as_micros())) .send() .await?; let receipt = pendix_tx.get_receipt().await?; anyhow::ensure!(receipt.status(), "timestamping failed");

Finally, we wait for the past perfect time after the timestamp to make sure its value settled and will never change (ensure finality).

pod_provider .wait_past_perfect_time(timestamp) .await .context("waiting for timestamp settlement")?; let got_ts = get_timestamp(rpc_url, contract_address, document_hash) .await .context("getting timestamp")?; if let Some(got_ts) = got_ts { ensure!( got_ts == timestamp.into(), "document has an earlier timestamp {} already", humantime::format_rfc3339(got_ts) ); Ok((document_hash, got_ts)) } else { Err(anyhow!("failed to set timestamp")) }
async fn timestamp( rpc_url: String, contract_address: Address, private_key: String, document: String, timestamp: Timestamp, ) -> Result<(Hash, SystemTime)> { let pk_bytes = hex::decode(private_key)?; let pk = pod_sdk::SigningKey::from_slice(&pk_bytes)?; let pod_provider = PodProviderBuilder::with_recommended_settings() .with_private_key(pk) .on_url(&rpc_url) .await?; let notary = Notary::new(contract_address, pod_provider.clone()); let document_hash = keccak256(doc); let pendix_tx = notary .timestamp(document_hash, U256::from(timestamp.as_micros())) .send() .await?; let receipt = pendix_tx.get_receipt().await?; anyhow::ensure!(receipt.status(), "timestamping failed"); // Wait past perfect time to make sure document timestamp // is settled forever. pod_provider .wait_past_perfect_time(timestamp) .await .context("waiting for timestamp settlement")?; let got_ts = get_timestamp(rpc_url, contract_address, document_hash) .await .context("getting timestamp")?; if let Some(got_ts) = got_ts { ensure!( got_ts == timestamp.into(), "document has an earlier timestamp {} already", humantime::format_rfc3339(got_ts) ); Ok((document_hash, got_ts)) } else { Err(anyhow!("failed to set timestamp")) } }

Retrieving a document timestamp

In order to get a timestamp we need to create a PodProvider again, but this time it doesn’t need a private key. The reason is because we will not be executing a transaction, but only calling a contract method, which can happen on a node only (it never reaches a validator).

let timestamp = notary.timestamps(hash).call().await?._0; if timestamp.is_zero() { return Ok(None); }

A timestamp equal to zero means that the document is not timestamped

async fn get_timestamp( rpc_url: String, contract_address: Address, hash: Hash, ) -> Result> { let pod_provider = PodProviderBuilder::with_recommended_settings() .on_url(rpc_url) .await?; let notary = Notary::new(contract_address, pod_provider); let timestamp = notary.timestamps(hash).call().await?._0; if timestamp.is_zero() { return Ok(None); } Ok(Some(decode_timestamp(timestamp)?)) }

Watching DocumentTimestamped event

The watch() function demonstrates how to subscribe to DocumentTimestamped events.

First, it creates a filter for the DocumentTimestamped event.

let mut filter = Filter::new() .address(contract_address) .event_signature(Notary::DocumentTimestamped::SIGNATURE_HASH);

It then optionally filters by a specific submitter address.

if let Some(submitter) = submitter { filter = filter.topic2(submitter.into_word()); }

It subscribes to events with subscribe_verifiable_logs() to receive logs with validator attestations.

let mut stream = pod_provider .subscribe_verifiable_logs(&filter) .await? .into_stream();

The received logs are decoded and verified each event against the committee retrieved from the node.

let committee = pod_provider.get_committee().await?; while let Some(log) = stream.next().await { if !log.verify(&committee)? { eprintln!(" got invalid event!"); continue; } let event = Notary::DocumentTimestamped::decode_log(&log.inner.inner, true) .context("decoding event failed. deployed contract version might not match")?; }

It finally prints the resulting timestamp, document hash, and submitter address.

async fn watch( rpc_url: String, contract_address: Address, submitter: Option
, ) -> Result<()> { let pod_provider = PodProviderBuilder::with_recommended_settings() .on_url(rpc_url) .await?; let mut filter = Filter::new() .address(contract_address) .event_signature(Notary::DocumentTimestamped::SIGNATURE_HASH); if let Some(submitter) = submitter { filter = filter.topic2(submitter.into_word()); } let mut stream = pod_provider .subscribe_verifiable_logs(&filter) .await? .into_stream(); let committee = pod_provider.get_committee().await?; while let Some(log) = stream.next().await { if !log.verify(&committee)? { eprintln!(" got invalid event!"); continue; } let event = Notary::DocumentTimestamped::decode_log(&log.inner.inner, true) .context("decoding event failed. deployed contract version might not match")?; let timestamp = humantime::format_rfc3339(decode_timestamp(event.timestamp)?); println!( "Address {} timestamped document hash {} @ {}", event.submitter, event.documentHash, timestamp ); } Ok(()) }