
FileSeal: Replacing Notaries with Ethereum Smart Contracts
This is a technical write-up of my MSc dissertation project, which explores how Ethereum smart contracts can enable decentralised, trustless multi-party proof of document co-possession.
This project is available at https://fileseal.txz.cool, you can play around it.
The Problem
When multiple parties need to prove "we each hold the same document," almost every existing tool relies on a trusted third-party platform — DocuSign, Adobe Sign, and their peers. These solutions share three fundamental flaws:
Single point of trust and failure — if the platform is compromised or shut down, the proof record disappears with it.
Vendor lock-in and pricing power — the platform holds your data and sets the terms.
Privacy risk — document content must be uploaded to a third-party server.
The core question: is there an open, permissionless, privacy-preserving way for N parties to record on-chain that they each hold a specific document?
The Insight
A file's SHA-256 hash uniquely identifies its contents. If multiple parties each sign the same hash on-chain, they collectively prove co-possession — and the file itself never leaves anyone's device.
That's FileSeal: an Ethereum smart contract paired with a fully client-side frontend.
System Design
Roles
RoleDescriptionInitiatorCreates the session and specifies which addresses must participateParticipantAn invited address that submits its own on-chain attestationObserverAnyone can query attestation status without connecting a wallet
Three Core Flows
Flow A — Create Session (Initiator)
Connect wallet
Select the file → browser computes SHA-256 locally
Enter participant wallet addresses
Submit
createSession(fileHash, participants[])transactionShare the file hash with participants via a separate channel
Flow B — Attest (Participant)
Connect wallet
Select the same file (or paste the hash directly)
Frontend queries the contract to confirm the address is invited
Submit
attest(fileHash)transactionAttestation is permanently written to the chain
Flow C — Query
By file hash: returns attested addresses + pending addresses
By wallet address: returns all file hashes the address has attested (reconstructed client-side from
eth_getLogs)
Smart Contract Implementation
Data Structures
struct Session {
address initiator; // 20 bytes ─┐ packed into one slot
bool exists; // 1 byte ─┘
address[] required; // enumerable participant list
address[] attested; // enumerable attestation list
mapping(address => bool) isRequired; // O(1) eligibility check
mapping(address => bool) hasAttested; // O(1) duplicate prevention
}
mapping(bytes32 => Session) private _sessions;
The two mappings are the key optimisation: attest() eligibility checks stay O(1), avoiding array iteration.
Function Interface
FunctionTypeDescriptioncreateSession(bytes32, address[])writeInitiator creates a session with participantsattest(bytes32)writeParticipant submits their attestationgetSession(bytes32)viewReturns full session detailsgetPendingParticipants(bytes32)viewReturns addresses that haven't attested yethasAttested(bytes32, address)viewChecks whether a specific address has attested
Custom Errors
Solidity custom errors instead of require strings — lower gas cost, and more precise frontend decoding:
error SessionAlreadyExists();
error SessionNotFound();
error NotAParticipant();
error AlreadyAttested();
error TooManyParticipants();
error EmptyParticipantList();
error InvalidAddress();
error DuplicateParticipant();
Events
event SessionCreated(bytes32 indexed fileHash, address indexed initiator, address[] participants);
event Attested(bytes32 indexed fileHash, address indexed attestor);
Both fileHash and attestor are indexed, enabling efficient bidirectional filtering — which is exactly how the "query by address" feature works.
Key Design Decisions
1. Drop the reverse index; use event logs instead
An early design included mapping(address => bytes32[]) attestedFiles to record which files an address had attested. It was removed:
Every
attest()call would write an extra SSTORE, adding ~20k gasThe same data can be reconstructed client-side by filtering
Attested(fileHash, attestor)events viaeth_getLogsReading event logs is free
Result: attest() gas dropped from ~80k to ~50–60k.
2. Hard cap of 20 participants
MAX_PARTICIPANTS = 20 is an engineering choice: it prevents a malicious caller from making createSession gas unpredictable with oversized arrays, while still covering the vast majority of real-world use cases.
3. File content never touches the chain
The hash is computed in the browser using the Web Crypto API — zero server contact. The chain only ever sees a bytes32. No one can reverse a hash to recover the file contents.
Frontend
Tech Stack: React 18 + TypeScript · Vite · shadcn/ui · ethers.js v6 · Web Crypto API · deployed on Cloudflare Workers
Wallet Connection
Two connection modes, both resolved to a unified ethers.Signer:
MetaMask (browser extension)
Detect
window.ethereum, calleth_requestAccountsListen for
accountsChanged/chainChangedevents
Private key / mnemonic import
Construct signer via
ethers.Wallet(privateKey, provider)Private key lives only in React component state — never written to any storage
Prominent security warning displayed
Cleared immediately on disconnect or page unload
A Subtle ethers v6 Gotcha
When estimateGas fails, ethers v6 places the custom error's 4-byte selector in error.data while keeping error.revert as null — causing automatic decoding to silently fail.
Fix: manually decode the raw selector using Interface.parseError():
const FILESEAL_IFACE = new Interface([
"error SessionAlreadyExists()",
"error SessionNotFound()",
// ...
]);
if (typeof anyE.data === "string" && anyE.data.length >= 10) {
const parsed = FILESEAL_IFACE.parseError(anyE.data);
if (parsed) return CONTRACT_ERROR_MESSAGES[parsed.name] ?? parsed.name;
}
Gas Summary
OperationMain CostEstimatecreateSession (N participants)N × SSTORE (isRequired) + array writes~50k + 20k × N gasattest()2 × SSTORE + push to attested[]~50–60k gasAll view functionsSLOAD only< 5k gas
What's Next
A few directions that were out of scope for the dissertation:
Attestation revocation — currently on-chain attestations are permanent; adding revocation would increase gas cost and complexity.
Legal standing — the legal weight of an on-chain timestamp plus wallet signature varies by jurisdiction. A separate research question entirely.
Multi-chain deployment — the contract is pure EVM and could be deployed to any compatible chain (L2s, sidechains).
IPFS integration — the system stores nothing today; optional IPFS content addressing could enable integrity proofs for publicly shared files.
Conclusion
FileSeal's core insight is simple: a hash is a file's identity; an on-chain signature is proof of possession. Combine the two, and you get multi-party document co-possession attestation without uploading any file or trusting any platform.
The entire system is roughly 136 lines of Solidity and a fully static frontend, deployed on Ethereum Sepolia. Small, but it makes the point.