Blockchain-anchored document notarization for trustless contract verification.
TabNotary is an open-source implementation for notarizing documents on the blockchain. It creates tamper-proof, independently verifiable records by anchoring SHA-256 document hashes on-chain. Originally built for Tabmac venue contracts, the architecture is generic and can be adapted to any document notarization use case.
Try it out: https://venue.tabmac.com/verify
Other Languages: 中文 | Español | Deutsch
- How It Works
- Architecture Overview
- Components
- Environment Variables
- Smart Contract Deployment
- Verification Flow
- Data Structures
- Security Considerations
- Adapting for Your Use Case
- License
TabNotary implements a three-phase notarization pipeline:
Phase 1: SNAPSHOT Phase 2: ANCHOR Phase 3: VERIFY
┌─────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ Collect document │ │ Submit SHA-256 hash │ │ Upload PDF or paste │
│ data at moment of │────────>│ to smart contract on │────>│ hash to independently│
│ agreement │ │ Base blockchain │ │ verify on-chain │
│ │ │ │ │ │
│ - Canonicalize JSON │ │ - anchorContract() │ │ - PDF integrity │
│ - Sort keys (det.) │ │ - Wait 10 confirms │ │ (client-side) │
│ - SHA-256 hash │ │ - Record tx details │ │ - Blockchain lookup │
│ - Store snapshot │ │ - Retry on failure │ │ (server-side) │
└─────────────────────┘ └──────────────────────┘ └──────────────────────┘
Key properties:
- Deterministic — Identical input always produces identical hash via canonical JSON serialization (sorted keys)
- Tamper-evident — Any modification to the document changes the hash, which won't match on-chain
- Independently verifiable — Anyone can verify a hash against the public blockchain without trusting the issuer
- Privacy-preserving — Only the hash is stored on-chain; no personal data is exposed
- Immutable — Once anchored, the record cannot be altered or deleted by any party
┌────────────────────────────────────────────────────────────────────────┐
│ BACKEND SERVER │
│ │
│ ┌─────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Contract │ │ Blockchain │ │ Notarization │ │
│ │ Snapshot │───>│ Notary Service │<───│ Worker │ │
│ │ (canonicalize │ │ (ethers.js + │ │ (30s interval, │ │
│ │ + SHA-256) │ │ Base RPC) │ │ job queue) │ │
│ └─────────────────┘ └──────────────────┘ └──────────────────┘ │
│ │ │ │ │
│ v v v │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ MongoDB │ │
│ │ snapshots collection │ notarization_jobs │ bookings │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌──────────────────────────────────────────┐ │
│ │ Verification API (public, no auth) │ │
│ │ POST /api/verify-contract │ │
│ │ GET /api/verify-contract/:hash │ │
│ └──────────────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────┘
│ ▲
│ Base Blockchain │
│ ┌─────────────────────┐ │
└────────>│ TabmacNotary.sol │──────────────┘
│ (Solidity ^0.8.20) │ isAnchored() view call
│ │
│ anchorContract() │
│ isAnchored() │
└─────────────────────┘
┌────────────────────────────────────────────────────────────────────────┐
│ FRONTEND (Browser) │
│ │
│ ┌──────────────────────────┐ ┌─────────────────────────────────┐ │
│ │ Step 1: PDF Integrity │ │ Step 2: Blockchain Verification │ │
│ │ (runs entirely in │───>│ (calls /api/verify-contract) │ │
│ │ browser via Web Crypto) │ │ │ │
│ │ │ │ Displays: network, block #, │ │
│ │ - Extract text (pdfjs) │ │ tx hash, confirmations, │ │
│ │ - Find canonical payload │ │ Basescan explorer link │ │
│ │ - SHA-256 hash & compare │ │ │ │
│ └──────────────────────────┘ └─────────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────────┘
File: contracts/TabmacNotary.sol
A minimal, auditable Solidity contract deployed on the Base blockchain (L2).
contract TabmacNotary is Ownable {
mapping(bytes32 => bool) public anchored;
function anchorContract(
bytes32 bookingHash, // keccak256(documentId) — for indexed lookup
bytes32 contractHash, // SHA-256 hash of canonical document
uint8 canonicalizationVersion,
uint16 snapshotVersion // monotonic version per document
) external onlyOwner { ... }
function isAnchored(
bytes32 contractHash,
uint16 snapshotVersion
) external view returns (bool) { ... }
}Key design decisions:
onlyOwnerfor writes — Only the notary wallet can anchor hashes, preventing spamisAnchored()is public — Anyone can verify without permission- Composite key —
keccak256(contractHash, snapshotVersion)allows versioned snapshots - Event emission —
ContractAnchoredevent enables off-chain indexing - Base L2 — Low gas costs (~$0.01-0.05 per anchor) while inheriting Ethereum security
File: backend/contractSnapshot.js
Produces a deterministic, reproducible hash from any document data.
// 1. Build structured payload from document data
const snapshot = buildCanonicalSnapshot(document, metadata, events);
// 2. Recursively sort all object keys for determinism
const sorted = deepSortKeys(snapshot);
// 3. Serialize to JSON bytes
const bytes = Buffer.from(JSON.stringify(sorted), 'utf-8');
// 4. SHA-256 hash
const hash = crypto.createHash('sha256').update(bytes).digest('hex');
// → "a1b2c3d4e5f6..." (64 hex characters)Why canonicalization matters:
JSON serialization is non-deterministic — {"b":1,"a":2} and {"a":2,"b":1} are semantically identical but produce different hashes. By recursively sorting all keys before serialization, the same input always produces the same hash, regardless of the order data was assembled.
File: backend/blockchainNotary.js
Handles all blockchain interactions using ethers.js v6.
| Function | Purpose |
|---|---|
anchorHash(docId, hash, canonVersion, snapVersion) |
Submit anchor transaction |
waitForConfirmation(txHash, depth) |
Wait for N block confirmations |
checkConfirmation(txHash) |
Poll current confirmation depth |
verifyAnchor(hash, snapVersion) |
Read-only check if hash is on-chain |
toBytes32(hexStr) |
Convert SHA-256 hex to Solidity bytes32 |
hashBookingId(id) |
keccak256 of document ID for indexing |
File: backend/notarizationWorker.js
A background job processor that runs on a 30-second interval.
Job Lifecycle:
queued → processing → sent → confirming → confirmed
│ │
└──── failed (after 5 retries) ──┘
with exponential backoff
(30s, 2min, 8min, 32min)
Features:
- Processes up to 5 jobs per cycle to avoid overloading the RPC
- Idempotency check before submitting (calls
isAnchored()first) - Requires 10 block confirmations before marking as confirmed
- Exponential backoff on failure:
4^attempts * 30s - Updates the parent document record with full blockchain proof details
File: backend/contract-verification.js
Public, unauthenticated endpoints for hash verification.
POST /api/verify-contract
Body: { "contractHash": "a1b2c3d4..." }
GET /api/verify-contract/:hash
Response:
{
"verified": true,
"onChainVerified": true,
"message": "This contract hash matches a record anchored on the Base blockchain.",
"contractHash": "a1b2c3d4...",
"snapshotMetadata": {
"snapshotVersion": 1,
"schemaVersion": "1.0.0",
"hashAlgorithm": "SHA-256",
"createdAt": "2026-01-15T10:30:00.000Z"
},
"blockchainProof": {
"chainId": 8453,
"networkName": "Base",
"contractAddress": "0x59Db...",
"transactionHash": "0xabc...",
"blockNumber": 12345678,
"blockTimestamp": "2026-01-15T10:35:00.000Z",
"confirmationDepth": 20,
"anchoredAt": "2026-01-15T10:35:00.000Z",
"explorerUrl": "https://basescan.org/tx/0xabc..."
}
}Security:
- Rate limited: 10 requests per IP per minute
- No PII in response — only hash metadata and blockchain proof
- No authentication required (public verification by design)
File: frontend/Verify.tsx
A React component implementing a two-step verification flow with visual feedback.
Input methods:
- PDF Upload — Runs both Step 1 (integrity) and Step 2 (blockchain)
- Manual Hash — Paste a SHA-256 hash directly, skips Step 1
- URL Parameter —
/verify/:hashauto-triggers Step 2
File: frontend/contractIntegrity.ts
Verifies document integrity entirely in the browser — no data leaves the client.
PDF Document
│
├── Extract text (pdfjs-dist)
├── Find "Contract Hash (SHA-256): [64-char hex]"
├── Find canonical payload between markers:
│ ---BEGIN CANONICAL PAYLOAD---
│ [base64-encoded data]
│ ---END CANONICAL PAYLOAD---
│
├── Decode base64 → bytes
├── SHA-256(bytes) via Web Crypto API
└── Compare computed hash vs extracted hash
├── MATCH → Document is authentic
└── MISMATCH → Document has been tampered with
These are the minimum environment variables needed to run blockchain notarization:
| Variable | Description | Example |
|---|---|---|
BASE_RPC_URL_1 |
Base mainnet JSON-RPC endpoint (Alchemy, Infura, etc.) | https://base-mainnet.g.alchemy.com/v2/YOUR_KEY |
NOTARY_WALLET_PRIVATE_KEY |
Private key of the wallet that owns the smart contract. This wallet pays gas fees and is the only address authorized to call anchorContract(). Keep this secret. |
your_64_char_hex_key (no 0x prefix) |
NOTARY_CONTRACT_ADDRESS |
Deployed TabmacNotary contract address on Base | 0xYourContractAddress |
NOTARIZATION_ENABLED |
Master switch to enable/disable notarization | true |
| Variable | Description | Default |
|---|---|---|
BASE_EXPLORER |
Block explorer base URL (for generating tx links) | https://basescan.org/ |
BASESCAN_API_KEY |
Basescan API key for contract verification during deployment | (none) |
NOTARY_CONTRACT_VERSION |
Version string stored with notarization records | 1.0.0 |
| Variable | Description | Example |
|---|---|---|
VITE_API_URL |
Backend API base URL | https://api.example.com |
You need a JSON-RPC endpoint for the Base blockchain. Options:
| Provider | Free Tier | URL Format |
|---|---|---|
| Alchemy | 300M compute units/month | https://base-mainnet.g.alchemy.com/v2/YOUR_API_KEY |
| Infura | 100K requests/day | https://base-mainnet.infura.io/v3/YOUR_PROJECT_ID |
| QuickNode | 50 req/sec | https://YOUR_ENDPOINT.base-mainnet.quiknode.pro/YOUR_TOKEN |
| Public RPC | Rate limited | https://mainnet.base.org |
For testnet (Base Sepolia): Use https://sepolia.base.org or your provider's Sepolia endpoint.
This is the private key of the Ethereum wallet that will deploy and interact with the smart contract.
# Generate a new wallet using ethers.js
node -e "const { ethers } = require('ethers'); const w = ethers.Wallet.createRandom(); console.log('Address:', w.address); console.log('Private Key:', w.privateKey.slice(2));"Important:
- The wallet needs ETH on Base to pay gas fees (~$0.01-0.05 per anchor transaction)
- Fund via a bridge from Ethereum mainnet → Base, or buy directly on Base
- For testnet, use the Base Sepolia Faucet
- Never commit this key to version control — use
.envfiles or secrets management
Obtained after deploying the smart contract (see Smart Contract Deployment).
Required only for verifying the contract source code on Basescan after deployment.
- Create an account at basescan.org
- Go to API Keys in your account settings
- Create a new API key
cd contracts
npm installBASE_RPC_URL_1=https://base-mainnet.g.alchemy.com/v2/YOUR_KEY
NOTARY_WALLET_PRIVATE_KEY=your_64_char_hex_private_key
BASESCAN_API_KEY=your_basescan_api_keynpx hardhat run scripts/deploy.js --network baseSepolianpx hardhat run scripts/deploy.js --network baseThe deploy script will:
- Deploy the
TabmacNotarycontract - Wait for 5 block confirmations
- Verify the source code on Basescan
- Print the contract address — add this to your
.envasNOTARY_CONTRACT_ADDRESS
npx hardhat verify --network base YOUR_CONTRACT_ADDRESS┌─────────────────────────────────────────────────────────────────────┐
│ USER UPLOADS PDF │
│ │ │
│ v │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ STEP 1: Document Integrity (client-side, in-browser) │ │
│ │ │ │
│ │ 1. Extract text from PDF (pdfjs-dist) │ │
│ │ 2. Find "Contract Hash (SHA-256): <64-char hex>" │ │
│ │ 3. Find canonical payload between BEGIN/END markers │ │
│ │ 4. Decode base64 payload │ │
│ │ 5. Compute SHA-256 of decoded bytes (Web Crypto API) │ │
│ │ 6. Compare computed hash vs extracted hash │ │
│ │ │ │
│ │ Result: PASSED (green) / TAMPERED (red) / UNAVAILABLE │ │
│ └─────────────────────────────────────────────────────────────┘ │
│ │ │
│ (auto-proceeds if passed) │
│ │ │
│ v │
│ ┌─────────────────────────────────────────────────────────────┐ │
│ │ STEP 2: Blockchain Verification (server-side) │ │
│ │ │ │
│ │ 1. POST /api/verify-contract { contractHash } │ │
│ │ 2. Server looks up hash in database │ │
│ │ 3. Server calls isAnchored() on smart contract │ │
│ │ 4. Returns verification status + blockchain proof │ │
│ │ │ │
│ │ Result: VERIFIED (green) / RECORD FOUND (yellow) / │ │
│ │ NOT FOUND (red) │ │
│ └─────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘
| Entry Point | Step 1 | Step 2 | Use Case |
|---|---|---|---|
| PDF Upload | Yes | Auto | User has the PDF document |
| Manual Hash Input | Skipped | Yes | User has only the hash |
URL /verify/:hash |
Skipped | Auto | QR code / email link |
| State | Color | Meaning |
|---|---|---|
| Verified on Blockchain | Green | Hash found on-chain with 10+ confirmations |
| Record Found | Yellow | Hash exists in database, pending on-chain anchoring |
| Not Found | Red | Hash not in database or on-chain |
| Document Integrity Passed | Green | PDF canonical payload matches printed hash |
| Document Tampered | Red | Computed hash doesn't match — PDF was modified |
| Integrity Unavailable | Yellow | Legacy PDF without embedded canonical payload |
The canonical snapshot captures all material contract terms at the moment of agreement:
{
schemaVersion: "1.0.0",
bookingId: "uuid",
proposalId: "uuid",
parties: {
customer: { tabmacUserId, email, name, company },
host: { tabmacUserId, email, name, venueId, venueName }
},
venue: { id, name, address, timezone },
event: {
eventType, guestCount, spaceType, serviceStyle,
timeSlots: [{ slotIndex, date, startTime, endTime, guestQuote }]
},
pricing: {
hostQuote, commissionAmount, taxAmount, gratuityAmount,
transactionFeeAmount, guestQuote, depositAmount,
itemizedLineItems: [{ label, amount, category }],
currency: "usd"
},
payment: {
stripePaymentIntentId, stripeChargeId, amount,
capturedAt, paymentMethodBrand, paymentMethodLast4
},
formationEvents: [{
eventType, actorRole, actorEmail, agreedAt,
signingMethod, serverTimestamp
}],
cancellationPolicy: { type, terms },
metadata: { createdAt, canonicalizationVersion: "1.0.0" }
}Note: This schema is specific to Tabmac's venue booking use case. When adapting TabNotary, replace this with your own document schema — the canonicalization and hashing logic remains the same regardless of payload structure.
{
chainId: 8453, // Base mainnet
networkName: "Base",
contractAddress: "0x59Db...", // TabmacNotary address
transactionHash: "0xabc...", // Anchor transaction
blockNumber: 12345678, // Block containing the tx
blockTimestamp: "2026-01-15T...", // Block timestamp (UTC)
confirmationDepth: 20, // Current confirmations
anchoredAt: "2026-01-15T...", // When notarization completed
explorerUrl: "https://basescan.org/tx/0xabc..."
} ┌─────────┐
│ queued │ ← Created when snapshot is stored
└────┬────┘
│ Worker picks up job
v
┌───────────┐
│ processing│ ← Checking idempotency
└─────┬─────┘
│ anchorContract() tx submitted
v
┌─────────┐
│ sent │ ← Waiting for block inclusion
└────┬────┘
│ Transaction included in block
v
┌────────────┐
│ confirming │ ← Waiting for 10 confirmations
└──────┬─────┘
│ 10 confirmations reached
v
┌────────────┐
│ confirmed │ ← Final state, proof stored
└────────────┘
On error at any step:
┌─────────┐
│ failed │ ← After 5 retries with exponential backoff
└─────────┘
- Owner-only writes — Only the deployer wallet can anchor hashes, preventing unauthorized entries
- Immutable records — Once anchored, records cannot be modified or deleted
- Public reads —
isAnchored()is a view function callable by anyone
- Private key management — The
NOTARY_WALLET_PRIVATE_KEYshould be stored in a secrets manager (AWS Secrets Manager, Vault, etc.), never in version control - Rate limiting — Verification endpoint is rate-limited to prevent abuse
- No PII exposure — Verification responses contain only hash metadata and blockchain proof
- Idempotency — Worker checks
isAnchored()before submitting to avoid duplicate transactions
- Local verification — Step 1 (PDF integrity) runs entirely in the browser via Web Crypto API
- No data exfiltration — PDF content is never sent to the server during integrity verification
- Cryptographic guarantee — SHA-256 via
crypto.subtle.digest()(native browser implementation)
- Use a dedicated hot wallet with minimal ETH balance (enough for ~100 transactions)
- Monitor wallet balance and set up alerts for unusual activity
- Consider a multisig for contract ownership in production
- Rotate the wallet periodically by transferring contract ownership
TabNotary's architecture is generic. To adapt it for your documents:
Replace buildCanonicalSnapshot() in contractSnapshot.js with your document structure:
function buildCanonicalSnapshot(yourDocument) {
return {
schemaVersion: '1.0.0',
documentId: yourDocument.id,
// ... your fields ...
metadata: {
createdAt: new Date().toISOString(),
canonicalizationVersion: '1.0.0',
},
};
}The deepSortKeys() → JSON.stringify() → SHA-256 pipeline works for any JSON-serializable data. Don't change this unless you have a specific reason.
To enable client-side verification, embed the hash and canonical payload in your generated PDFs:
Contract Hash (SHA-256)
a1b2c3d4e5f6...
---BEGIN CANONICAL PAYLOAD---
eyJhIjoiYiIsImMiOiJkIn0= (base64 of canonical JSON)
---END CANONICAL PAYLOAD---
Deploy TabmacNotary.sol on your preferred EVM chain. The contract works on any EVM-compatible network (Ethereum, Base, Arbitrum, Polygon, etc.). Adjust BASE_RPC_URL_1 and chain ID accordingly.
The implementation uses MongoDB, but the pattern works with any database. You need two collections/tables:
- Snapshots — Store canonical payload + hash + version
- Notarization Jobs — Job queue for the worker
GPL-3.0 — See LICENSE for details.
Contributions are welcome. Please open an issue first to discuss proposed changes.