Writing a Smart Contract: Proof of Existence
Designing, Building, and Understanding a Real-World Smart Contract
In my free time iwrite and raise my voice in toast.
Part 1: Understanding the Problem
1. What Is Proof of Existence and Why Does It Matter?
Imagine you write a novel. You want to prove that YOU wrote it, and that it existed on a specific date. If someone later copies your novel and claims they wrote it first, you need irrefutable evidence that your version existed before theirs.
In the physical world, you might mail yourself a sealed copy (the "poor man's copyright") or register with a government office. Both are slow, expensive, and rely on trusted third parties.
Proof of Existence on the blockchain eliminates the middleman. You take a digital fingerprint (hash) of your document and record it on the blockchain with a timestamp. The blockchain is immutable — once recorded, no one can change the timestamp, delete the entry, or claim it never happened. The proof is:
Timestamped: The block timestamp proves the document existed at or before that time
Immutable: No one — not you, not the government, not the blockchain developers — can alter the record
Decentralized: No single authority controls or can censor the proof
Verifiable: Anyone can check the proof at any time without asking permission
Permanent: As long as the blockchain exists, the proof exists
This is NOT about storing the document on-chain. The document stays with you. Only the hash (a 32-byte fingerprint) goes on-chain. The hash proves the document existed without revealing its contents.
2. How Blockchain Makes This Possible
The blockchain provides three properties that make Proof of Existence work:
Property 1: Immutability
Once a transaction is included in a block and finalized, it cannot be changed. The hash you record today will be readable in 10, 50, or 100 years. No one can tamper with it.
This comes from the storage system you studied: an SSTORE writes to a contract's storage slot. That write is included in the block's state root. Altering it would require recomputing every subsequent block — computationally impossible.
Property 2: Timestamps
Every block has a block.timestamp — a Unix timestamp set by the block validator. When your notarizeDocument() function records block.timestamp, that timestamp is tied to the block number, which is part of the public, verifiable blockchain history.
Property 3: Public Verifiability
Anyone can call the verifyDocument() function to check if a document hash exists and when it was registered. No API keys, no accounts, no permissions. The contract's storage is readable by anyone via STATICCALL or eth_getStorageAt.
3. The Real-World Use Cases
Use Case | What's Hashed | Why It Matters |
|---|---|---|
Intellectual property | Novel manuscript, song, code | Prove creation date before patent/copyright disputes |
Academic research | Research paper, dataset | Prove priority of discovery |
Legal documents | Contract PDF, will, deed | Prove document existed at signing time |
Software releases | Source code, binary | Prove a specific version existed at release date |
Medical records | Patient records, test results | Prove records weren't altered after the fact |
Supply chain | Certificate of origin, inspection report | Prove authenticity of documentation |
Whistleblowing | Evidence files | Prove evidence existed before investigation |
Art & NFTs | Original artwork file | Prove original creation before minting |
Part 2: Designing Before Coding
4. Requirements: What the Contract Must Do
Before writing a single line of Solidity, we define exactly what the contract must support. This is the system design discipline from the System Design article applied to a concrete problem.
Functional Requirements
FR-1: A user can register a document hash on-chain
FR-2: The system records WHO registered it and WHEN
FR-3: Anyone can verify whether a document hash has been registered
FR-4: Anyone can retrieve the full details of a registered document
FR-5: A user can retrieve all documents they have registered
FR-6: The document owner can transfer ownership to another address
FR-7: The document owner can revoke (invalidate) a document record
FR-8: A document hash can only be registered ONCE (no duplicates)
Non-Functional Requirements
NFR-1: Every user-facing function must be O(1) (constant gas cost)
NFR-2: The contract must emit events for every state change
NFR-3: The contract must handle edge cases (zero hash, zero address)
NFR-4: The contract must follow Checks-Effects-Interactions pattern
NFR-5: The contract must be gas-efficient (cache storage reads)
5. System Design: Choosing the Architecture
Monolith or Multi-Contract?
This is a focused, single-purpose system. There's no token, no vault, no complex interactions. A single contract is the right choice — it keeps the code simple, avoids cross-contract call overhead, and makes the entire system auditable in one file.
Does It Need Upgradeability?
For a proof-of-existence contract, immutability is a feature, not a limitation. The POINT is that records can't be altered. Making the contract upgradeable would undermine trust — an admin could theoretically change the logic to alter records. We choose a non-upgradeable, immutable contract.
Does It Need an Interface?
If other contracts might call this contract (e.g., a notary service that registers documents through a factory), an interface would be useful. We'll design clean function signatures that could be extracted into an interface later.
Does It Need Access Control?
Basic functions (notarize, verify) should be available to everyone. Ownership transfer and revocation should be restricted to the document owner. There's no global admin role needed — each user controls their own documents. No Ownable inheritance required.
6. Data Design: What State Do We Need?
For each registered document, we need to store:
1. The document hash (bytes32) — the unique identifier
2. The owner's address — who registered it
3. The timestamp — when it was registered
4. A name/description — human-readable label (optional but useful)
5. Active status — whether the record has been revoked
For the system overall, we need:
6. A way to look up a document by its hash → mapping(bytes32 => Document)
7. A way to check if a hash has been registered → the mapping above (check exists field)
8. A way to list all documents by a specific user → mapping(address => bytes32[])
9. A counter of total documents → uint256
Why These Choices?
mapping(bytes32 => Document): O(1) lookup by hash. This is the core data structure — given a hash, instantly find the document record. Same pattern as balances[address] in ERC20.
mapping(address => bytes32[]): Array per user for enumeration. Same pattern as studentIds[] in SchoolManagement. Allows getMyDocuments() to return all of a user's document hashes.
uint256 counter: Like nextStudentId in SchoolManagement — a running total.
7. Access Control Design: Who Can Do What?
┌─────────────────────────────────────────────────────────┐
│ Function │ Who Can Call │ Why │
├───────────────────────┼────────────────┼────────────────┤
│ notarizeDocument() │ Anyone │ Open service │
│ verifyDocument() │ Anyone │ Public verify │
│ getDocument() │ Anyone │ Public records │
│ getMyDocuments() │ Anyone │ Public query │
│ transferOwnership() │ Document owner │ Owner's choice │
│ revokeDocument() │ Document owner │ Owner's choice │
└─────────────────────────────────────────────────────────┘
No global admin. No onlyOwner modifier. Each document has its own owner, and only that owner can modify that specific document.
8. The Cryptographic Foundation: Why Hashes, Not Files
We store hashes, not files. Here's why and how.
What a Hash Is
A hash function takes ANY input (a 1-byte text file, a 10 GB video) and produces a fixed-size output (32 bytes for keccak256 and SHA-256). The same input ALWAYS produces the same output. Different inputs produce different outputs (with astronomically high probability).
Input: "Hello, World!"
SHA-256: dffd6021bb2bd5b0af676290809ec3a53191dd81c7f70a4b28688a362182986f
Input: "Hello, World?" (one character changed)
SHA-256: 907f9420e68e0e41937b1b4e00a27fa96b6f47ccc9e44c2e3d2b0a3a12f3b5c8
Input: A 500-page PDF of a novel
SHA-256: (some different 32-byte value)
Why Hashes Work for Proof of Existence
Deterministic: Hash the same file tomorrow, next year, in 100 years — you get the same hash. You can always prove your file matches the on-chain hash.
One-way: Given the hash, you cannot reconstruct the document. Your document's contents remain private.
Collision-resistant: It's computationally infeasible to find two different documents that produce the same hash. The probability of a collision in SHA-256 is 1/2²⁵⁶ — far less likely than winning the lottery a billion times in a row.
Compact: A 32-byte hash is cheap to store on-chain (~20,000 gas for a new storage slot). Storing a full document would cost millions of gas.
The Workflow
OFF-CHAIN (free):
1. User has a document (PDF, text file, image — any file)
2. User computes SHA-256 or keccak256 hash of the file
3. Hash is 32 bytes: 0xabc123...
ON-CHAIN (costs gas):
4. User calls notarizeDocument(0xabc123..., "My Novel")
5. Contract stores the hash + owner + timestamp
LATER — VERIFICATION (free, view function):
6. Anyone calls verifyDocument(0xabc123...)
7. Contract returns: "Yes, registered by Alice on 2026-02-20"
8. Alice provides the file. Verifier hashes it. Hash matches → proof valid.
Part 3: Building the Contract — Every Line Explained
9. The License and Pragma: Starting the File
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
// SPDX-License-Identifier: MIT: Every Solidity file should declare its license. MIT is permissive — anyone can use, modify, and distribute the code. This line has zero effect on the compiled bytecode; it's metadata for humans and tools like Etherscan.
pragma solidity ^0.8.20: Tells the compiler which version to use. The ^ means "0.8.20 or any compatible version up to but not including 0.9.0." We use 0.8.20+ for overflow protection (built in since 0.8.0), custom errors (0.8.4+), and recent optimizations.
10. The Contract Shell and State Variables
contract ProofOfExistence {
uint256 public totalDocuments;
contract ProofOfExistence: Declares the contract. At deployment, this becomes a single address on the blockchain with its own storage, bytecode, and ETH balance.
uint256 public totalDocuments: A counter tracking how many documents have been registered across all users. Starts at 0 (the default for uint256). The public visibility auto-generates a getter function: function totalDocuments() external view returns (uint256).
This is an accumulator — the same pattern as total_supply in your ERC20 contract. Instead of looping through all documents to count them (O(n)), we maintain a running total that's updated on every registration (O(1) read).
11. The Document Struct: Designing the Record
struct Document {
bytes32 hash;
address owner;
uint256 timestamp;
string description;
bool exists;
bool revoked;
}
Each field serves a specific purpose:
bytes32 hash: The document's fingerprint. We store it inside the struct even though it's also the mapping key. This is intentional — when we return a Document struct from a function, the caller can see the hash without needing to pass it separately. It also makes the struct self-contained.
address owner: The address that registered the document. This is the "who" in the proof. Stored as 20 bytes. Combined with timestamp, this answers: "Who registered this document and when?"
uint256 timestamp: The block.timestamp at the time of registration. This is the "when" in the proof. Once written, it can never change. A verifier can compare this timestamp to the blockchain's block history for additional confidence.
string description: A human-readable label like "Research paper v3" or "Patent filing #12345". Optional context for the document. Stored as a dynamic string — short strings (≤31 bytes) pack into one storage slot, longer strings use multi-slot encoding.
bool exists: The existence flag. Since every possible bytes32 key in a mapping returns a default-initialized struct (all zeros), we need a way to distinguish "this document was registered" from "this key was never used." If exists == false, the document was never registered. If exists == true, it was.
This is the same pattern used in SchoolManagement's isActive field — a struct field that doubles as an existence check.
bool revoked: Whether the owner has invalidated this record. A revoked document still exists on-chain (immutability), but the revoked flag signals that the owner considers it no longer valid.
Storage Layout of the Struct
When a Document struct is stored in a mapping, its fields occupy consecutive storage slots from the base slot:
Base slot = keccak256(abi.encode(documentHash, mapping_slot_number))
Slot base + 0: hash (bytes32, 32 bytes, full slot)
Slot base + 1: owner (address, 20 bytes)
+ exists (bool, 1 byte)
+ revoked (bool, 1 byte)
= 22 bytes → PACKED into one slot
Slot base + 2: timestamp (uint256, 32 bytes, full slot)
Slot base + 3: description (string, length/pointer)
Wait — the packing depends on the ORDER of fields in the struct. Let me reconsider. The Solidity compiler packs consecutive small fields in a struct:
hash(bytes32, 32 bytes) → full slotowner(address, 20 bytes) → starts new slottimestamp(uint256, 32 bytes) → can't fit with address, starts new slotdescription(string, 32 bytes pointer) → new slotexists(bool, 1 byte) → starts new slotrevoked(bool, 1 byte) → packs withexistsin same slot
Actually, the compiler packs in declaration order. owner (20 bytes) is followed by timestamp (32 bytes) — too big to pack. Then description (dynamic). Then exists (1 byte) + revoked (1 byte) = 2 bytes — these pack together.
Total: approximately 5 storage slots per document. At 20,000 gas per new slot, creating a document costs ~100,000 gas for storage alone.
12. The Mappings: Choosing the Right Data Structures
mapping(bytes32 => Document) private documents;
mapping(address => bytes32[]) private userDocuments;
mapping(bytes32 => Document) private documents
The core data structure. Given a document hash, returns the full Document struct. O(1) lookup.
Why private? We want to control how document data is accessed. Instead of letting the auto-generated getter return raw struct fields, we write our own getDocument() function with proper validation and formatting.
If this were public, the compiler would generate:
function documents(bytes32 key) external view returns (
bytes32 hash, address owner, uint256 timestamp, string memory description, bool exists, bool revoked
);
This would work, but our custom getter can add checks (like reverting for non-existent documents) and return more meaningful data.
mapping(address => bytes32[]) private userDocuments
Each address has an array of document hashes they've registered. This is the mapping + array enumerable pattern from the Mappings article.
Why we need this: documents (the mapping) lets us look up a document by hash, but it can't tell us "what documents did Alice register?" For that, we need a list per user.
Why bytes32[] and not Document[]? Storing the full struct in the array would duplicate data (the struct already lives in the documents mapping). Instead, we store just the hashes and look up the full data from documents when needed.
13. Events: What the Outside World Needs to Know
event DocumentNotarized(
bytes32 indexed hash,
address indexed owner,
uint256 timestamp,
string description
);
event DocumentTransferred(
bytes32 indexed hash,
address indexed previousOwner,
address indexed newOwner,
uint256 timestamp
);
event DocumentRevoked(
bytes32 indexed hash,
address indexed owner,
uint256 timestamp
);
Why These Events?
Every state change gets an event. This is the event-driven architecture from the System Design article:
DocumentNotarized: A new document was registered. Frontends and indexers listen for this to update their UI.
DocumentTransferred: A document changed owners. Important for audit trails.
DocumentRevoked: A document was invalidated. Verifiers need to know this.
Why indexed?
The indexed keyword creates a topic in the log, allowing efficient filtering. A frontend can say "show me all DocumentNotarized events for this specific hash" without scanning every event.
Up to 3 parameters can be indexed per event (the EVM supports 4 topics, but topic 0 is the event signature hash). We index the most commonly filtered fields: hash and owner.
14. The Constructor: Initializing the Contract
constructor() {
// No initialization needed — all state variables
// start at their default values:
// totalDocuments = 0
// documents mapping: all keys return default structs
// userDocuments mapping: all keys return empty arrays
}
This contract has no constructor logic. No owner to set, no token address to wire, no parameters to validate. The contract is ready to use immediately after deployment.
This is intentionally minimal. Unlike your SaveAsset (which needs a token_address at deployment), or SchoolManagement (which needs level fees configured), this contract is self-contained. Anyone can use it immediately.
15. notarizeDocument(): The Core Function
function notarizeDocument(bytes32 _hash, string calldata _description) external {
require(_hash != bytes32(0), "ProofOfExistence: empty hash");
require(!documents[_hash].exists, "ProofOfExistence: already registered");
documents[_hash] = Document({
hash: _hash,
owner: msg.sender,
timestamp: block.timestamp,
description: _description,
exists: true,
revoked: false
});
userDocuments[msg.sender].push(_hash);
totalDocuments++;
emit DocumentNotarized(_hash, msg.sender, block.timestamp, _description);
}
This is the heart of the contract. Let's trace every line.
Line 1: Function Signature
function notarizeDocument(bytes32 _hash, string calldata _description) external {
bytes32 _hash: The document's fingerprint. The user computes this off-chain (using SHA-256 or keccak256 on their file) and passes it as a parameter. bytes32 is a fixed-size 32-byte value — it fits in one stack element and one calldata word.
string calldata _description: A human-readable label. calldata means the string is read directly from the transaction input without copying to memory — cheaper than string memory. See the calldata vs memory explanation from the SchoolManagement article.
external: Only callable from outside the contract (by users or other contracts). Cannot be called internally. Slightly more gas-efficient than public for functions that are never called internally.
Lines 2-3: Validation (Checks)
require(_hash != bytes32(0), "ProofOfExistence: empty hash");
bytes32(0) is 32 zero bytes — the default value. This rejects the zero hash because:
An empty hash has no meaning (no document produces a zero hash in practice)
It avoids accidental registrations from bugs or empty inputs
It reserves the zero value as "not set" across the system
require(!documents[_hash].exists, "ProofOfExistence: already registered");
This checks whether the hash has already been registered. documents[_hash] returns a Document struct. If no document was ever registered with this hash, all fields are defaults — including exists == false. The !false evaluates to true, so the require passes.
If someone already registered this hash, exists == true, and !true is false → the require reverts with "already registered."
This is the duplicate prevention mechanism. The first person to register a hash owns it. No one else can register the same hash — this is fundamental to proving priority.
Lines 4-11: State Changes (Effects)
documents[_hash] = Document({
hash: _hash,
owner: msg.sender,
timestamp: block.timestamp,
description: _description,
exists: true,
revoked: false
});
This writes the full Document struct to the mapping. Named field assignment (same as SchoolManagement's students[studentId] = Student({...})) — explicit, self-documenting, order-independent.
hash: Store the hash itself for self-contained retrievalowner:msg.sender— the address that signed the transactiontimestamp:block.timestamp— the current block's Unix timestampdescription: The user's labelexists: Set totrue— this hash is now registeredrevoked: Set tofalse— it's active
At the EVM level, this writes to approximately 5 storage slots at the keccak256-computed location for this hash in the documents mapping.
userDocuments[msg.sender].push(_hash);
Appends this hash to the caller's array. After this, userDocuments[msg.sender] contains all hashes the caller has ever registered. This enables the getMyDocuments() view function.
totalDocuments++;
Increments the global counter. Same accumulator pattern as total_supply += _amount in your ERC20.
Line 12: Event (Interaction — though technically no external call)
emit DocumentNotarized(_hash, msg.sender, block.timestamp, _description);
Broadcasts the registration to the world. This event is indexed by _hash and msg.sender, allowing frontends to filter efficiently.
CEI Analysis
This function has no external calls, so there's no reentrancy risk. The pattern is:
Checks: Validate hash and existence
Effects: All state changes (struct write, array push, counter increment)
Interactions: None (just an event, which is not an external call)
This is a clean, safe function.
16. verifyDocument(): Checking If a Document Exists
function verifyDocument(bytes32 _hash) external view returns (
bool isRegistered,
address owner,
uint256 timestamp,
bool isRevoked
) {
Document storage doc = documents[_hash];
if (!doc.exists) {
return (false, address(0), 0, false);
}
return (true, doc.owner, doc.timestamp, doc.revoked);
}
Design Decisions
Returns a tuple, not a struct: The function returns four named values instead of the full struct. This is intentional — the caller gets exactly what they need for verification without unnecessary data (like the description).
Named return variables: returns (bool isRegistered, address owner, ...) — the names make the return values self-documenting. A frontend can destructure: const [isRegistered, owner, timestamp, isRevoked] = await contract.verifyDocument(hash).
Document storage doc: A storage reference — reads fields directly from storage without copying the entire struct to memory. Since we only read exists, owner, timestamp, and revoked, we pay for exactly 4 SLOADs (or fewer if fields are packed in the same slot).
Returns defaults for non-existent documents: Instead of reverting, the function returns (false, address(0), 0, false). This is a design choice — verification should tell you "no, this document doesn't exist" rather than failing. The caller can check isRegistered and handle accordingly.
Complexity: O(1)
One mapping lookup, a few field reads. Constant time regardless of how many documents exist.
17. getDocument(): Retrieving Full Document Details
function getDocument(bytes32 _hash) external view returns (
bytes32 hash,
address owner,
uint256 timestamp,
string memory description,
bool exists,
bool revoked
) {
Document storage doc = documents[_hash];
require(doc.exists, "ProofOfExistence: document not found");
return (doc.hash, doc.owner, doc.timestamp, doc.description, doc.exists, doc.revoked);
}
Unlike verifyDocument, this function reverts if the document doesn't exist. The reason: getDocument is for retrieving full details, and returning empty data for a non-existent document could be misleading. A clear revert tells the caller "this hash was never registered."
The string memory in the return type means the description is copied from storage into memory for the return value. This costs extra gas (proportional to string length), but is necessary because external functions can't return storage references.
18. getMyDocuments(): Retrieving a User's Documents
function getMyDocuments() external view returns (bytes32[] memory) {
return userDocuments[msg.sender];
}
function getDocumentsByOwner(address _owner) external view returns (bytes32[] memory) {
return userDocuments[_owner];
}
Two variants: getMyDocuments() returns the caller's own documents (convenient), and getDocumentsByOwner() lets anyone look up any address's documents (transparency).
Both return bytes32[] memory — the entire array of hashes is copied from storage into memory. For a user with 100 documents, this costs 100 SLOADs. As a view function called off-chain, this is free. On-chain, it would be expensive — but no other contract should need to call this.
Why Not Return Document[] Instead of bytes32[]?
Structs with dynamic fields (like string description) make array returns more complex and expensive. Returning just the hashes is cheaper and more flexible — the caller can then call getDocument() for any specific hash they're interested in.
19. transferOwnership() for Documents: Changing Who Owns a Record
function transferDocumentOwnership(bytes32 _hash, address _newOwner) external {
require(_newOwner != address(0), "ProofOfExistence: zero address");
Document storage doc = documents[_hash];
require(doc.exists, "ProofOfExistence: document not found");
require(!doc.revoked, "ProofOfExistence: document revoked");
require(doc.owner == msg.sender, "ProofOfExistence: not owner");
address previousOwner = doc.owner;
doc.owner = _newOwner;
userDocuments[_newOwner].push(_hash);
// Note: we do NOT remove from the previous owner's array
// (removing from a storage array is expensive — O(n))
// The old owner's array keeps a historical record
emit DocumentTransferred(_hash, previousOwner, _newOwner, block.timestamp);
}
Key Design Decisions
Only the current owner can transfer: require(doc.owner == msg.sender) — same pattern as ERC20's transfer checking balances[msg.sender], but applied to document ownership.
Cannot transfer revoked documents: A revoked document is invalidated. Transferring it would be meaningless.
Does NOT remove from previous owner's array: Removing an element from a storage array requires either O(n) shifting (preserves order) or O(1) swap-and-pop (changes order). Both are expensive. Instead, we leave the hash in the previous owner's array as a historical record. The ownership in the documents mapping is the source of truth — the array is supplementary.
This means getDocumentsByOwner(oldOwner) will still show the transferred hash, but getDocument(hash).owner will show the new owner. The frontend can reconcile this by checking actual ownership.
Storage reference doc: doc.owner = _newOwner writes directly to the struct's storage slot. No need to reload the entire struct — one SSTORE for the owner field.
20. revokeDocument(): Invalidating a Record
function revokeDocument(bytes32 _hash) external {
Document storage doc = documents[_hash];
require(doc.exists, "ProofOfExistence: document not found");
require(!doc.revoked, "ProofOfExistence: already revoked");
require(doc.owner == msg.sender, "ProofOfExistence: not owner");
doc.revoked = true;
emit DocumentRevoked(_hash, msg.sender, block.timestamp);
}
Revocation sets the revoked flag to true. The document record still exists — you can still call getDocument() and see the hash, owner, and timestamp. But the revoked = true flag signals that the owner considers this record invalid.
Use cases for revocation:
The document was registered with an error
The document is superseded by a newer version
Legal requirement to invalidate the record
Revocation is irreversible in this design — there's no unrevokeDocument(). Once revoked, always revoked. This is a deliberate design choice for simplicity and trust. If you need to re-register, create a new hash (even a slightly modified document produces a completely different hash).
Part 4: The Complete Contract
21. The Full Source Code: Everything Together
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title ProofOfExistence
/// @notice Register document hashes on-chain to prove they existed at a specific time
/// @dev Uses keccak256/SHA-256 document hashes as unique identifiers
contract ProofOfExistence {
// ═══════════════════════════════════════════
// DATA STRUCTURES
// ═══════════════════════════════════════════
struct Document {
bytes32 hash;
address owner;
uint256 timestamp;
string description;
bool exists;
bool revoked;
}
// ═══════════════════════════════════════════
// STATE VARIABLES
// ═══════════════════════════════════════════
/// @dev Hash → Document record (source of truth)
mapping(bytes32 => Document) private documents;
/// @dev Owner → list of document hashes they registered
mapping(address => bytes32[]) private userDocuments;
/// @notice Total number of documents ever registered
uint256 public totalDocuments;
// ═══════════════════════════════════════════
// EVENTS
// ═══════════════════════════════════════════
event DocumentNotarized(
bytes32 indexed hash,
address indexed owner,
uint256 timestamp,
string description
);
event DocumentTransferred(
bytes32 indexed hash,
address indexed previousOwner,
address indexed newOwner,
uint256 timestamp
);
event DocumentRevoked(
bytes32 indexed hash,
address indexed owner,
uint256 timestamp
);
// ═══════════════════════════════════════════
// CORE FUNCTIONS
// ═══════════════════════════════════════════
/// @notice Register a document hash on-chain
/// @param _hash The keccak256 or SHA-256 hash of the document
/// @param _description A human-readable label for the document
function notarizeDocument(bytes32 _hash, string calldata _description) external {
require(_hash != bytes32(0), "ProofOfExistence: empty hash");
require(!documents[_hash].exists, "ProofOfExistence: already registered");
documents[_hash] = Document({
hash: _hash,
owner: msg.sender,
timestamp: block.timestamp,
description: _description,
exists: true,
revoked: false
});
userDocuments[msg.sender].push(_hash);
totalDocuments++;
emit DocumentNotarized(_hash, msg.sender, block.timestamp, _description);
}
// ═══════════════════════════════════════════
// VERIFICATION FUNCTIONS
// ═══════════════════════════════════════════
/// @notice Check if a document hash has been registered
/// @param _hash The document hash to verify
/// @return isRegistered Whether the hash exists on-chain
/// @return owner The address that registered it (address(0) if not found)
/// @return timestamp When it was registered (0 if not found)
/// @return isRevoked Whether the record has been revoked
function verifyDocument(bytes32 _hash) external view returns (
bool isRegistered,
address owner,
uint256 timestamp,
bool isRevoked
) {
Document storage doc = documents[_hash];
if (!doc.exists) {
return (false, address(0), 0, false);
}
return (true, doc.owner, doc.timestamp, doc.revoked);
}
/// @notice Get full details of a registered document
/// @param _hash The document hash to look up
function getDocument(bytes32 _hash) external view returns (
bytes32 hash,
address owner,
uint256 timestamp,
string memory description,
bool exists,
bool revoked
) {
Document storage doc = documents[_hash];
require(doc.exists, "ProofOfExistence: document not found");
return (doc.hash, doc.owner, doc.timestamp, doc.description, doc.exists, doc.revoked);
}
// ═══════════════════════════════════════════
// USER QUERY FUNCTIONS
// ═══════════════════════════════════════════
/// @notice Get all document hashes registered by the caller
function getMyDocuments() external view returns (bytes32[] memory) {
return userDocuments[msg.sender];
}
/// @notice Get all document hashes registered by a specific address
/// @param _owner The address to query
function getDocumentsByOwner(address _owner) external view returns (bytes32[] memory) {
return userDocuments[_owner];
}
/// @notice Get the number of documents registered by a specific address
/// @param _owner The address to query
function getDocumentCount(address _owner) external view returns (uint256) {
return userDocuments[_owner].length;
}
// ═══════════════════════════════════════════
// OWNER-ONLY DOCUMENT MANAGEMENT
// ═══════════════════════════════════════════
/// @notice Transfer document ownership to a new address
/// @param _hash The document hash to transfer
/// @param _newOwner The address to receive ownership
function transferDocumentOwnership(bytes32 _hash, address _newOwner) external {
require(_newOwner != address(0), "ProofOfExistence: zero address");
Document storage doc = documents[_hash];
require(doc.exists, "ProofOfExistence: document not found");
require(!doc.revoked, "ProofOfExistence: document revoked");
require(doc.owner == msg.sender, "ProofOfExistence: not owner");
address previousOwner = doc.owner;
doc.owner = _newOwner;
userDocuments[_newOwner].push(_hash);
emit DocumentTransferred(_hash, previousOwner, _newOwner, block.timestamp);
}
/// @notice Revoke a document record (irreversible)
/// @param _hash The document hash to revoke
function revokeDocument(bytes32 _hash) external {
Document storage doc = documents[_hash];
require(doc.exists, "ProofOfExistence: document not found");
require(!doc.revoked, "ProofOfExistence: already revoked");
require(doc.owner == msg.sender, "ProofOfExistence: not owner");
doc.revoked = true;
emit DocumentRevoked(_hash, msg.sender, block.timestamp);
}
}
Part 5: Deep Analysis
22. Storage Layout: Where Every Byte Lives
Slot 0: totalDocuments (uint256, 32 bytes)
Slot 1: documents mapping base
└── documents[hash] struct starts at keccak256(abi.encode(hash, 1))
Slot base+0: hash (bytes32)
Slot base+1: owner (address, 20 bytes) — may pack with bools
Slot base+2: timestamp (uint256)
Slot base+3: description (string length/data)
Slot base+4: exists + revoked (bool + bool, packed)
Slot 2: userDocuments mapping base
└── userDocuments[addr] array length at keccak256(abi.encode(addr, 2))
Elements at keccak256(keccak256(abi.encode(addr, 2))) + 0, +1, +2, ...
23. Time Complexity: Every Function Measured
Function | Complexity | Reason |
|---|---|---|
| O(1) | Mapping write + array push + counter increment |
| O(1) | One mapping read, field access |
| O(1) | One mapping read, field access |
| O(n) | Returns full array (n = user's doc count) |
| O(n) | Returns full array |
| O(1) | Reads array length |
| O(1) | Mapping field write + array push |
| O(1) | One mapping field write |
Every state-changing function is O(1). The O(n) functions are view-only (free off-chain). This is the gold standard.
24. Gas Cost Analysis: What Each Operation Costs
notarizeDocument() — Most Expensive (First-Time Registration)
require checks:
SLOAD documents[hash].exists (cold) 2,100 gas
Comparison and branch ~20 gas
Struct write (all new slots):
SSTORE hash (0 → non-zero) 20,000 gas
SSTORE owner+bools (0 → non-zero) 20,000 gas
SSTORE timestamp (0 → non-zero) 20,000 gas
SSTORE description (0 → non-zero) 20,000 gas
Array push:
SLOAD array length 2,100 gas
SSTORE new element (0 → non-zero) 20,000 gas
SSTORE updated length 5,000 gas
Counter increment:
SLOAD totalDocuments 2,100 gas
SSTORE totalDocuments 5,000 gas
Event emission:
LOG2 (2 indexed topics) 750 gas
Data encoding ~200 gas
Calldata + transaction base: ~25,000 gas
TOTAL: approximately 142,000 - 165,000 gas
At 20 gwei gas price and $2,000 ETH: approximately $0.006 — less than a penny to create an immutable proof.
verifyDocument() — Free (View Function)
Off-chain calls are free. On-chain would cost approximately 4,500 gas (two SLOADs + comparisons).
revokeDocument() — Cheapest State Change
SLOAD for validation 2,100 gas
SSTORE revoked (false → true) 5,000 gas
Event + overhead ~5,000 gas
TOTAL: approximately 35,000 - 45,000 gas
25. Security Analysis: Every Attack Vector Examined
Attack 1: Registering Someone Else's Document First (Front-Running)
Vector: Alice broadcasts a notarizeDocument transaction. Bob sees it in the mempool, copies the hash, and broadcasts his own notarizeDocument with a higher gas price. Bob's transaction is included first.
Impact: Bob appears as the first registrant of Alice's document.
Mitigation: This is a fundamental limitation of public mempools. Solutions include:
Commit-reveal: Alice first commits a hidden hash, then reveals it later
Private mempool services (Flashbots Protect)
Accept the risk — in most practical scenarios, the court considers additional evidence
Attack 2: Modifying a Registered Record
Vector: Attacker tries to change the owner or timestamp of an existing record.
Impact: None. The contract has no function that modifies timestamp. The owner can only be changed by the current owner via transferDocumentOwnership. The hash and timestamp are write-once.
Attack 3: Deleting a Record
Vector: Attacker tries to remove a document from the blockchain.
Impact: Impossible. There's no deleteDocument() function. Even revokeDocument() only sets a flag — the data remains. Blockchain immutability ensures the record persists.
Attack 4: Registering an Empty or Meaningless Hash
Vector: Attacker calls notarizeDocument(bytes32(0), "").
Impact: Prevented by require(_hash != bytes32(0)). The zero hash is rejected.
Attack 5: DoS via Array Growth
Vector: Attacker registers thousands of documents to make getMyDocuments() return a huge array.
Impact: The attacker pays gas for each registration (~150,000 gas each). getMyDocuments() is a view function — large arrays only cost the caller (off-chain, free). No on-chain function depends on array length except the push (which is O(1)).
The system is safe because no state-changing function iterates over the array.
Attack 6: Reentrancy
Vector: The contract has no external calls in any state-changing function. There is no .call(), no .transfer(), no IERC20.transferFrom(). Without external calls, reentrancy is impossible.
26. How This Contract Connects to Everything You've Learned
Concept | Where It Appears in ProofOfExistence |
|---|---|
Mappings (from Mappings deep-dive) |
|
Structs (from SchoolManagement) |
|
Arrays (from Time Complexity) |
|
Events (from ERC20) | Three events with indexed parameters for off-chain filtering |
Storage layout (from EVM Storage) | Struct fields in consecutive slots, mapping base slots via keccak256 |
Checks-Effects-Interactions (from SaveAsset) | All checks before state changes, no external calls |
Accumulator pattern (from Time Complexity) |
|
Existence tracking (from Mappings) |
|
calldata vs memory (from SchoolManagement) |
|
Time complexity (from DSA deep-dive) | Every state-changing function is O(1) |
System design (from System Design) | Single contract, no admin, immutable, no upgrade path by design |
Part 6: Using the Contract
27. The Complete User Flow: From Document to Proof
═══════════════════════════════════════════════════════════════
STEP 1: Alice has a document (off-chain)
═══════════════════════════════════════════════════════════════
Alice has a PDF: "research_paper_v3.pdf" (5.2 MB)
She computes the SHA-256 hash locally:
$ sha256sum research_paper_v3.pdf
→ 0x7a3b8c...def456 (32 bytes)
═══════════════════════════════════════════════════════════════
STEP 2: Alice registers the hash on-chain (costs gas)
═══════════════════════════════════════════════════════════════
Alice calls:
ProofOfExistence.notarizeDocument(
0x7a3b8c...def456,
"Research paper: Quantum effects in blockchain consensus v3"
)
Transaction included in block #19,234,567 at timestamp 1740067200
(February 20, 2025 12:00:00 UTC)
On-chain state:
documents[0x7a3b8c...def456] = {
hash: 0x7a3b8c...def456,
owner: Alice,
timestamp: 1740067200,
description: "Research paper: Quantum effects in blockchain consensus v3",
exists: true,
revoked: false
}
userDocuments[Alice] = [0x7a3b8c...def456]
totalDocuments = 1
═══════════════════════════════════════════════════════════════
STEP 3: One year later, Bob claims he wrote it first
═══════════════════════════════════════════════════════════════
Bob publishes the same paper and claims he wrote it in 2024.
═══════════════════════════════════════════════════════════════
STEP 4: Alice proves her priority (free verification)
═══════════════════════════════════════════════════════════════
Anyone calls:
ProofOfExistence.verifyDocument(0x7a3b8c...def456)
Returns:
isRegistered: true
owner: Alice's address
timestamp: 1740067200 (February 20, 2025)
isRevoked: false
Alice provides the original PDF. The verifier:
1. Computes sha256(PDF) → 0x7a3b8c...def456 ← matches!
2. Checks the blockchain: registered by Alice on Feb 20, 2025
3. Conclusion: Alice's document existed on-chain before Bob's claim
Bob cannot:
- Alter Alice's record (immutable)
- Delete Alice's record (no delete function)
- Register the same hash (duplicate prevention)
- Change the timestamp (write-once field)
═══════════════════════════════════════════════════════════════
RESULT: Alice has cryptographic, timestamped, immutable
proof that her document existed first.
═══════════════════════════════════════════════════════════════
28. Frontend Integration: How a DApp Would Use This
// Using ethers.js v6
// 1. HASH THE DOCUMENT (off-chain, free)
async function hashDocument(file) {
const buffer = await file.arrayBuffer();
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
const hashArray = new Uint8Array(hashBuffer);
return '0x' + Array.from(hashArray).map(b => b.toString(16).padStart(2, '0')).join('');
}
// 2. REGISTER ON-CHAIN (costs gas)
async function notarize(contract, file, description) {
const hash = await hashDocument(file);
const tx = await contract.notarizeDocument(hash, description);
const receipt = await tx.wait();
return { hash, receipt };
}
// 3. VERIFY (free — view function)
async function verify(contract, file) {
const hash = await hashDocument(file);
const [isRegistered, owner, timestamp, isRevoked] = await contract.verifyDocument(hash);
return {
isRegistered,
owner,
date: new Date(Number(timestamp) * 1000),
isRevoked
};
}
// 4. GET USER'S DOCUMENTS (free — view function)
async function getMyDocs(contract) {
const hashes = await contract.getMyDocuments();
const documents = [];
for (const hash of hashes) {
const doc = await contract.getDocument(hash);
documents.push(doc);
}
return documents;
}
29. Testing Scenarios: Every Edge Case
TEST 1: Basic Registration
✓ Register a hash → stored correctly
✓ totalDocuments increments
✓ DocumentNotarized event emitted
✓ verifyDocument returns (true, caller, timestamp, false)
TEST 2: Duplicate Prevention
✓ Register hash A → success
✗ Register hash A again → reverts "already registered"
✗ Register hash A from different address → reverts "already registered"
TEST 3: Zero Hash Rejection
✗ Register bytes32(0) → reverts "empty hash"
TEST 4: Verification of Non-Existent Hash
✓ verifyDocument(unknownHash) → (false, address(0), 0, false)
✗ getDocument(unknownHash) → reverts "document not found"
TEST 5: Document Ownership Transfer
✓ Owner transfers to new address → doc.owner updated
✓ DocumentTransferred event emitted
✗ Non-owner tries to transfer → reverts "not owner"
✗ Transfer to address(0) → reverts "zero address"
✗ Transfer revoked document → reverts "document revoked"
TEST 6: Document Revocation
✓ Owner revokes → doc.revoked = true
✓ DocumentRevoked event emitted
✗ Non-owner tries to revoke → reverts "not owner"
✗ Revoke already-revoked → reverts "already revoked"
✓ After revocation, verifyDocument shows isRevoked = true
✓ After revocation, getDocument still returns full data
TEST 7: User Document List
✓ Register 3 documents → getMyDocuments returns array of 3 hashes
✓ getDocumentCount returns 3
✓ Different user's list is independent
TEST 8: After Ownership Transfer
✓ New owner can revoke the document
✗ Old owner cannot revoke (no longer owner)
✓ Hash appears in new owner's userDocuments
✓ Hash still appears in old owner's userDocuments (historical)
TEST 9: Multiple Users
✓ Alice registers hash A, Bob registers hash B → both succeed
✗ Bob tries to register hash A → fails (Alice already owns it)
✓ Each user's getMyDocuments returns only their own hashes
TEST 10: Empty Description
✓ Register with "" description → succeeds (description is optional)
✓ getDocument returns empty string for description
30. Extensions: Where to Go From Here
Extension 1: Add Fees
uint256 public notarizationFee = 0.001 ether;
address public feeCollector;
function notarizeDocument(bytes32 _hash, string calldata _description) external payable {
require(msg.value >= notarizationFee, "Insufficient fee");
// ... existing logic ...
}
function withdrawFees() external {
require(msg.sender == feeCollector);
(bool ok, ) = payable(feeCollector).call{value: address(this).balance}("");
require(ok);
}
Now you have a business model. This connects to SaveAsset's ETH handling patterns.
Extension 2: Multi-Token Fee Payment
IERC20 public paymentToken;
function notarizeDocumentWithToken(bytes32 _hash, string calldata _description) external {
require(paymentToken.transferFrom(msg.sender, address(this), tokenFee));
// ... existing logic ...
}
This connects to the approve → transferFrom pull pattern from your ERC20 and SaveAsset contracts.
Extension 3: Batch Registration
function notarizeMultiple(bytes32[] calldata _hashes, string[] calldata _descriptions) external {
require(_hashes.length == _descriptions.length, "Length mismatch");
require(_hashes.length <= 50, "Too many documents"); // Bound the loop!
for (uint256 i = 0; i < _hashes.length; i++) {
_notarize(_hashes[i], _descriptions[i]);
}
}
Bounded O(n) loop — acceptable because n is capped at 50.
Extension 4: Add Inheritance
contract ProofOfExistence is Ownable, Pausable, ReentrancyGuard {
// Ownable: admin can set fees, pause contract
// Pausable: emergency stop
// ReentrancyGuard: protection if fees introduce external calls
function notarizeDocument(...) external whenNotPaused { ... }
function setFee(uint256 _fee) external onlyOwner { ... }
}
This connects directly to the Inheritance article — combining multiple parent contracts for production readiness.
Extension 5: EIP-712 Typed Signatures (Gasless Notarization)
Allow a trusted relayer to submit notarizations on behalf of users who sign off-chain:
function notarizeWithSignature(
bytes32 _hash,
string calldata _description,
address _signer,
bytes calldata _signature
) external {
// Verify EIP-712 signature from _signer
// Register document with _signer as owner
// The relayer pays gas, but _signer is the recorded owner
}
This enables gasless UX — users sign a message, a backend submits the transaction.
Each of these extensions builds on concepts from your previous contracts: ERC20 token integration, the pull payment pattern, bounded loops, inheritance hierarchies, and cross-contract communication. The ProofOfExistence contract is a foundation that can grow into a full notarization platform.



