← Blog
FileSeal: Replacing Notaries with Ethereum Smart Contracts

FileSeal: Replacing Notaries with Ethereum Smart Contracts

BlockchainEthereumSolidityWeb3

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:

  1. Single point of trust and failure — if the platform is compromised or shut down, the proof record disappears with it.

  2. Vendor lock-in and pricing power — the platform holds your data and sets the terms.

  3. 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)

  1. Connect wallet

  2. Select the file → browser computes SHA-256 locally

  3. Enter participant wallet addresses

  4. Submit createSession(fileHash, participants[]) transaction

  5. Share the file hash with participants via a separate channel

Flow B — Attest (Participant)

  1. Connect wallet

  2. Select the same file (or paste the hash directly)

  3. Frontend queries the contract to confirm the address is invited

  4. Submit attest(fileHash) transaction

  5. Attestation 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 gas

  • The same data can be reconstructed client-side by filtering Attested(fileHash, attestor) events via eth_getLogs

  • Reading 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, call eth_requestAccounts

  • Listen for accountsChanged / chainChanged events

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.

FileSeal: Replacing Notaries with Ethereum Smart Contracts | WeiTanZzz