This document provides a complete technical specification of the Farewell protocol, including user lifecycle management, message encryption, cryptographic key sharing, FHE integration, claiming workflows, delivery verification, and the council voting system.
Farewell is a decentralized protocol for posthumous encrypted messages using Fully Homomorphic Encryption (FHE) on Ethereum. The protocol is designed around three core principles:
-
No central operator: The protocol runs entirely on-chain via a smart contract. Once messages are stored, they persist indefinitely without depending on any service.
-
Blockchain persistence: Messages are cryptographically committed to the blockchain, making them tamper-proof and permanently available once released.
-
Encryption and access control: Messages remain encrypted until a user stops checking in (liveness timeout), after which council members and the contract can prove the user is deceased and release messages to authorized claimers.
The protocol combines:
- Zama FHEVM for on-chain encryption of sensitive fields (recipient emails, key shares)
- AES-128-GCM for client-side encryption of message payloads
- Groth16 zero-knowledge proofs (via zk-email) for proof-of-delivery
- Council voting for liveness determination during grace periods
Live deployment: https://farewell.world (Sepolia testnet)
Users progress through four possible states:
enum UserStatus {
Alive, // Within current check-in period
Grace, // Check-in period expired, within grace period
Deceased, // User marked deceased (grace period expired or finalized)
FinalAlive // Council voted alive, timer reset
}┌─────────────────────────────────────────────────────────────────┐
│ USER LIFECYCLE STATES │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Alive │
│ ────────────────────────────────────────────────────────── │
│ ├─ User registered at block X │
│ ├─ ping() can be called any time to reset timer │
│ ├─ If ping() called before (X + checkInPeriod): stays Alive │
│ └─ If checkInPeriod expires without ping(): enter Grace │
│ │
│ ↓ │
│ │
│ Grace │
│ ────────────────────────────────────────────────────────────│
│ ├─ Timer at (X + checkInPeriod) │
│ ├─ Lasts until (X + checkInPeriod + gracePeriod) │
│ ├─ Council members can vote during this window │
│ ├─ If ping() called: resets to Alive │
│ ├─ If councilVote → majority alive: → FinalAlive │
│ ├─ If councilVote → majority dead: → Deceased │
│ └─ If gracePeriod expires: anyone can call markDeceased() │
│ │
│ ↓ │
│ │
│ Deceased │
│ ────────────────────────────────────────────────────────────│
│ ├─ User is finalized as deceased │
│ ├─ Messages become claimable │
│ ├─ No transitions out of Deceased │
│ └─ claim() and retrieve() functions become available │
│ │
│ ↓ (voter becomes "notifier" with 24h claim priority) │
│ │
│ FinalAlive │
│ ────────────────────────────────────────────────────────────│
│ ├─ Council voted majority alive │
│ ├─ Timer and grace period are reset │
│ ├─ User re-enters normal check-in cycle │
│ ├─ ping() is required again to stay alive │
│ ├─ If ping() called: back to Alive (grace votes cleared) │
│ └─ If checkInPeriod expires again: → Grace │
│ │
└─────────────────────────────────────────────────────────────────┘
Users register once with customizable check-in and grace periods:
function register(
string calldata name,
uint64 checkInPeriod,
uint64 gracePeriod
) externalParameters:
name: Display name (max 100 bytes)checkInPeriod: Minimum time between pings (min 1 day, max ~50 years)gracePeriod: Time for council voting after timeout (min 1 day, max ~50 years)
Event: UserRegistered(user, checkInPeriod, gracePeriod, registeredOn)
Users must periodically call ping() to reset their timer:
function ping() externalEffects:
- Resets
lastPingto current block timestamp - If user was in Grace: reverts to Alive
- If user was in FinalAlive: clears council votes, resets to Alive
Event: Ping(user, when)
After both checkInPeriod and gracePeriod have expired without a ping or council decision, anyone can mark the user
deceased:
function markDeceased(address user) externalRequirements:
block.timestamp >= lastPing[user] + checkInPeriod[user] + gracePeriod[user]- User status must not be FinalAlive
Effects:
- Sets
status[user] = Deceased - Records the caller as the "notifier" (eligible for 24-hour claim priority)
- Messages become claimable
Event: Deceased(user, when, notifier)
Messages are encrypted client-side using AES-128-GCM before submission to the contract. This is critical because all data stored on-chain is publicly visible.
AES-128-GCM ciphertexts are packed into a single hex string:
┌─────────────────────────────────────────────────────────────┐
│ 0x │ IV (12 bytes) │ ciphertext │ GCM-tag (16 bytes) │
└─────────────────────────────────────────────────────────────┘
12 bytes variable 16 bytes
(ciphertext length)
Total overhead: 28 bytes (12-byte IV + 16-byte authentication tag)
Example:
0xAB12...CD | 000102030405060708090A0B | 48656C6C6F 20576F726C64 | 0102030405060708090A0B0C0D0E0F10
└─ IV └─ plaintext (hello world) └─ GCM tag
The packed AES format is chosen because:
- Chainable: IV and tag remain in the same field
- Deterministic: No additional parameters needed for decryption
- Compact: Minimal on-chain storage
- Recipient-verifiable: Recipients can independently verify the contentHash
The AES-encrypted payload is stored on-chain as raw bytes:
bytes payload; // AES-128-GCM encrypted messageConstraints:
- Maximum 10,240 bytes (10 KB) to prevent spam
- Publicly visible on-chain (encryption is mandatory)
- Immutable after creation (cannot be changed, only revoked)
The protocol uses an XOR-based key sharing scheme to prevent decryption until after the user's death. This design ensures that before death, neither the recipient (who has incomplete information) nor on-chain observers can decrypt the message.
┌────────────────────────────────────────────────────────────────┐
│ KEY SHARING SCHEME │
├────────────────────────────────────────────────────────────────┤
│ │
│ USER (while alive) RECIPIENT (after death) │
│ ───────────────────── ────────────────────────── │
│ │
│ Generate message M Receives s' from user │
│ ├─ Choose AES-128 key sk (e.g., email, QR code) │
│ │ │
│ ├─ Generate random s (128-bit) Receives claim package │
│ │ Example: s = 0x12345... └─ Contains s (on-chain, │
│ │ FHE-decrypted) │
│ ├─ Compute s' = sk XOR s │
│ │ Example: if sk = 0xABCD... Computes sk: │
│ │ s' = 0x... sk = s XOR s' │
│ │ = 0x... │
│ │ │
│ └─ Share s' with recipient Uses sk to decrypt: │
│ (off-chain, any channel) AES-128-GCM.decrypt( │
│ sk, encryptedPayload) │
│ │
│ Store on-chain: │
│ ├─ enc(s) via FHE (euint128) │
│ └─ enc_aes(M) as bytes │
│ │
└────────────────────────────────────────────────────────────────┘
Before user death:
- Recipient has s' but cannot derive sk without s
- sk is encrypted on-chain as euint128 via FHE
- Even contract observers cannot see s or sk (FHE hides plaintext)
- Message remains secure
After user death:
- Claimer retrieves encrypted s via FHE.allow()
- Recipient gets s and uses their off-chain s' to compute sk
- Only the intended recipient can reconstruct sk and decrypt M
Attack resistance:
- Attacker with only s' cannot decrypt (needs s)
- Attacker with only enc(s) cannot decrypt (FHE keeps s hidden)
- Attacker with both s' and enc(s) still cannot proceed without FHE decryption capability (granted only to claimed recipients)
Recipient emails are encrypted using Zama's FHEVM. To prevent length-based leakage attacks, all emails are first padded to a fixed length.
┌──────────────────────────────────────────────────────────┐
│ EMAIL ENCRYPTION FLOW │
├──────────────────────────────────────────────────────────┤
│ │
│ Input: "alice@example.com" (21 bytes) │
│ │
│ Step 1: Pad to MAX_EMAIL_BYTE_LEN (224 bytes) │
│ ─────────────────────────────────────────────────── │
│ "alice@example.com" + 203 zero bytes │
│ = 224-byte padded string │
│ │
│ Step 2: Split into 7 x 32-byte limbs │
│ ──────────────────────────────────────── │
│ 224 bytes / 32 = 7 limbs (no remainder) │
│ │
│ Limb structure: │
│ ┌─────────────────────────────────────────────────┐ │
│ │ Limb[0] │ Limb[1] │ ... │ Limb[6] │ │
│ │ 32 bytes│ 32 bytes│ │ 32 bytes (padded) │ │
│ └─────────────────────────────────────────────────┘ │
│ 0-31 32-63 192-223 │
│ │
│ Step 3: Encrypt each limb as euint256 │
│ ────────────────────────────────────── │
│ For each 32-byte limb L: │
│ limb_encrypted = FHE.asEuint256(L) │
│ │
│ Result: 7 FHE-encrypted limbs (euint256[]) │
│ │
└──────────────────────────────────────────────────────────┘
Why this approach:
- Prevents length leakage: All emails appear 224 bytes (even "a@b.c")
- euint256 is 256 bits: Each 32-byte limb fits exactly in one euint256
- No overflow: 7 × 256 = 1792 bits out of FHEVM's 2048-bit limit
- Deterministic: Receiver knows to split into 7 limbs without additional metadata
The 128-bit key share s is stored as euint128:
euint128 encSkShare; // FHE-encrypted 128-bit key shareTotal FHE input per message:
- 7 × euint256 (recipient email limbs) = 1792 bits
- 1 × euint128 (key share) = 128 bits
- Total = 1920 bits (within 2048-bit FHEVM limit)
Messages are stored with restricted FHE visibility:
Initial state (message just added):
Message owner: Can FHE-decrypt email limbs and key share
Contract: Can manage FHE permissions but not decrypt
Claimer: No access
After claim(user, messageIndex):
FHE.allow(encSkShare, claimer); // Grant access to key share
FHE.allow(encRecipientEmail[i], claimer); // Grant access to each email limb
The FHE.allow() call is irreversible — once a claimer is granted access, it cannot be revoked.
After markDeceased() is called, the caller (notifier) receives a 24-hour priority window to claim messages before anyone else can:
┌──────────────────────────────────────────────────┐
│ 24-HOUR CLAIM EXCLUSIVITY │
├──────────────────────────────────────────────────┤
│ │
│ T0: markDeceased() called by Address A │
│ └─ notifier = Address A │
│ └─ notifierClaimDeadline = T0 + 24 hours │
│ │
│ T0 to T0+24h: │
│ └─ Only Address A can call claim() │
│ └─ Others: revert("Too early") │
│ │
│ T0+24h onwards: │
│ └─ Anyone can call claim() │
│ └─ notifier loses priority │
│ │
└──────────────────────────────────────────────────┘
Purpose: Incentivize good-faith notification of death and allow original notifier first access to attach delivery proofs.
function claim(address user, uint256 index) externalRequirements:
- User must be Deceased
- Message must not already be claimed
- Caller must either be notifier (within 24h) or 24h has passed
- User must still be deceased (not revived by recovery mechanism)
Effects:
- Sets
claimed[user][index] = true - Calls
FHE.allow()to grant caller decryption access:FHE.allow(messages[user][index].encSkShare, msg.sender); for (uint i = 0; i < 7; i++) { FHE.allow(messages[user][index].encRecipientEmail[i], msg.sender); }
Event: Claimed(user, index, claimer)
function retrieve(address owner, uint256 index) external view
returns (
euint128 skShare,
euint256[] memory encRecipientEmail,
uint32 emailByteLen,
bytes memory payload,
string memory publicMessage,
bytes32 contentHash
)Requirements:
- Message must be claimed by caller (FHE access granted)
- Returns the encrypted data and metadata
Process:
- User supplies the claimed message
- Contract returns FHE-encrypted email limbs and key share (only callable by claimer due to FHE permissions)
- Claimer's client-side FHEVM library decrypts the email and key share locally
- Claimer proceeds to delivery workflow
The claim package is a JSON file downloaded after claiming a message. It contains all the data needed for delivery verification and recipient decryption.
{
"type": "farewell-claim-package",
"version": 1,
"owner": "0x1234567890123456789012345678901234567890",
"messageIndex": 0,
"recipients": ["alice@example.com", "bob@example.com"],
"skShare": "0x75554596171405abc...",
"encryptedPayload": "0xab12...cd",
"contentHash": "0x1234567890abcdef...",
"subject": "Farewell Message"
}| Field | Type | Purpose |
|---|---|---|
type |
string | Must be "farewell-claim-package" (format identifier for claimer tool) |
version |
number | Schema version for backward compatibility (currently 1) |
owner |
address | Message creator's wallet address |
messageIndex |
number | Index in owner's message array |
recipients |
string[] | Email addresses to receive the message |
skShare |
hex | FHE-decrypted on-chain half of AES-128 key (128 bits) |
encryptedPayload |
hex | AES-128-GCM encrypted message (packed format) |
contentHash |
hex | keccak256 of plaintext message (for proof verification) |
subject |
string | Email subject line |
┌─────────────────────────────────────┐
│ retrieve() on-chain │
│ ├─ encryptedPayload (AES ciphertext)
│ ├─ contentHash (keccak256 of plaintext)
│ ├─ encSkShare (FHE-encrypted on-chain half)
│ ├─ encRecipientEmail (FHE-encrypted limbs)
│ └─ recipients (plaintext email array)
└──────────────┬──────────────────────┘
│
↓
┌─────────────────────────────────────┐
│ Claim Package JSON │
│ ├─ contentHash (passed through) │
│ ├─ encryptedPayload (passed through)
│ ├─ skShare (FHE-decrypted) │
│ ├─ recipients (passed through) │
│ └─ owner, messageIndex (metadata) │
└──────────────┬──────────────────────┘
│
↓
┌─────────────────────────────────────┐
│ farewell-claimer (Python tool) │
│ ├─ Sends email with claim package │
│ ├─ Proves delivery via zk-email │
│ └─ Generates DeliveryProofJson │
└─────────────────────────────────────┘
Each message can have an optional ETH reward for delivery proof:
reward = BASE_REWARD + REWARD_PER_KB × ceil(payloadSize / 1024)
where:
BASE_REWARD = 0.01 ETHREWARD_PER_KB = 0.005 ETHpayloadSize = encryptedPayload.lengthin bytes
Example: 5 KB payload
payloadSize = 5120 bytes
ceil(5120 / 1024) = 5 KB
reward = 0.01 + 0.005 × 5 = 0.035 ETH
Messages can target multiple recipients. Each recipient's proof is tracked separately using a bitmap:
uint256 provenRecipientsBitmap;
// Bit i set to 1 if recipient[i] has proven deliveryN = number of recipients
For each proven recipient at index i:
provenRecipientsBitmap |= (1 << i)
Example: 3 recipients, indices 0, 1, 2
All proven: (1 << 0) | (1 << 1) | (1 << 2)
= 0b001 | 0b010 | 0b100
= 0b111 = 7
Reward claimable when:
provenRecipientsBitmap == (2^N - 1)
For N=3: (1 << 3) - 1 = 0b111 = 7 ✓
function claimReward(address user, uint256 messageIndex) externalRequirements:
- All recipients must be proven:
provenRecipientsBitmap == (2^numRecipients - 1) - Caller must be the message claimer
- Reward has not already been claimed
Effects:
- Transfers locked reward to claimer
- Clears reward amount to prevent double-claiming
Event: RewardClaimed(user, messageIndex, claimer, amount)
Each user can designate up to 20 trusted council members to vote on their liveness during the grace period:
mapping(address user => address[] councilMembers) council;
// Constraint: councilMembers[user].length <= 20function addCouncilMember(address member) externalRequirements:
- Caller must be the user
- Member must not already be on council
- Council size must be less than 20
Effects:
- Adds member to user's council
- Member can now vote during grace periods
Event: CouncilMemberAdded(user, member)
function removeCouncilMember(address member) externalEffects:
- Removes member from council
- Clears any active votes by this member
Event: CouncilMemberRemoved(user, member)
function voteOnStatus(address user, bool voteAlive) externalRequirements:
- Caller must be a council member of user
- User must currently be in Grace status
- Caller must not have already voted on this grace period
Effects:
- Records vote (alive or deceased)
- May trigger immediate resolution if majority is reached
Event: GraceVoteCast(user, voter, voteAlive)
┌─────────────────────────────────────────────────────┐
│ GRACE PERIOD VOTING FLOW │
├─────────────────────────────────────────────────────┤
│ │
│ User enters Grace status │
│ └─ Council votes can now be cast │
│ voting_deadline = grace_period_start + gracePeriod
│ │
│ During voting: │
│ ├─ Council members call voteOnStatus(alive/dead) │
│ ├─ Votes are recorded: aliveCounts, deadCounts │
│ └─ If majority reached instantly: │
│ ├─ If alive majority (>= councilSize/2 + 1): │
│ │ └─ user status = FinalAlive │
│ │ └─ ping() timer is reset │
│ │ └─ user must ping to re-enter normal cycle │
│ │ │
│ └─ If dead majority (> councilSize/2): │
│ └─ user status = Deceased │
│ └─ voter becomes notifier │
│ └─ messages become claimable │
│ │
│ Grace period expires without resolution: │
│ └─ Anyone can call markDeceased() │
│ └─ user status = Deceased (finalized) │
│ │
│ User pings during grace: │
│ └─ Reverts to Alive status │
│ └─ Votes are cleared │
│ └─ Council must start over if user times out again
│ │
└─────────────────────────────────────────────────────┘
uint councilSize = council[user].length;
uint majorityRequired = (councilSize / 2) + 1;
// Alive majority
if (aliveCounts >= majorityRequired) {
status[user] = FinalAlive;
lastPing[user] = block.timestamp; // Reset timer
}
// Dead majority
if (deadCounts > (councilSize / 2)) {
status[user] = Deceased;
notifier[user] = currentVoter;
}For detailed specifications of the proof format, verification flow, and zk-email integration, see docs/proof-structure.md.
Key concepts:
- Claim Package: Downloaded from UI after claiming a message
- Delivery: Claimer uses farewell-claimer tool to send email and generate proof
- Proof Format: Groth16 proof with 3 public signals (recipient hash, DKIM key hash, content hash)
- Bitmap Tracking: Multi-recipient messages tracked via bitmaps
- Reward Claim: Once all recipients proven, claimer withdraws ETH
Users marked as Deceased cannot be recovered except via council vote before the grace period expires and the Deceased status is finalized.
Mitigation:
- Set reasonable grace periods (default 7 days)
- Use council members as additional liveness confirmation
Once FHE.allow() grants a claimer access to encrypted data, it cannot be revoked.
Implication:
- Claimers must be trusted (they can FHE-decrypt all recipient emails)
- No revocation if claimer becomes malicious
- Separate proof-of-delivery prevents unrewarded claims
Block timestamps can be manipulated by miners/validators within ~15 seconds.
Impact:
- Low for multi-day check-in periods (30 days default)
- Negligible compared to protocol timeouts
- No practical attack vector at current parameter values
All payloads, emails, and metadata are visible on the blockchain.
Mitigation:
- Mandatory client-side AES encryption of payloads
- FHE encryption of emails (hidden to network observers)
- Recipient emails visible in claim package only after claiming
If a user generates a weak AES-128 key sk, encryption is compromised.
Mitigation:
- Client uses Web Crypto API with system randomness (strong entropy)
- Key derivation from seed phrases uses KDF
- User education on secure key generation
After the 24-hour notifier exclusivity window, anyone can claim messages. Current implementation allows repeated claims.
Status:
- Beta implementation doesn't prevent this yet
- Proof-of-delivery framework prevents repeated reward claims
- Future: Message can be marked delivered/complete after first full proof
Current implementation has a placeholder verifier that accepts all proofs if no verifier is set.
Implication:
- Delivery proofs are not cryptographically verified in beta
- Cannot claim rewards without proper verifier configured
- Future: Real Groth16 verifier deployed
All protocol constants are defined in the smart contract:
| Constant | Type | Value | Rationale |
|---|---|---|---|
DEFAULT_CHECKIN |
uint64 | 30 days (2,592,000 s) | Default monthly check-in interval |
DEFAULT_GRACE |
uint64 | 7 days (604,800 s) | One week for council voting |
MAX_EMAIL_BYTE_LEN |
uint32 | 224 | Padded email length (7 × 32-byte FHE limbs) |
MAX_PAYLOAD_BYTE_LEN |
uint32 | 10,240 | 10 KB max message size (spam prevention) |
BASE_REWARD |
uint256 | 0.01 ether | Entry fee for delivery proofs |
REWARD_PER_KB |
uint256 | 0.005 ether | Scaling reward for larger messages |
MAX_COUNCIL_SIZE |
uint8 | 20 | Prevents unbounded voting loops |
NOTIFIER_CLAIM_PRIORITY_WINDOW |
uint256 | 24 hours | Exclusivity window for markDeceased caller |
The contract emits the following events:
event UserRegistered(address indexed user, uint64 checkInPeriod, uint64 gracePeriod, uint64 registeredOn);
event UserUpdated(address indexed user, uint64 checkInPeriod, uint64 gracePeriod, uint64 registeredOn);
event Ping(address indexed user, uint64 when);
event Deceased(address indexed user, uint64 when, address indexed notifier);event MessageAdded(address indexed user, uint256 indexed index);
event MessageEdited(address indexed user, uint256 indexed index);
event MessageRevoked(address indexed user, uint256 indexed index);
event Claimed(address indexed user, uint256 indexed index, address indexed claimer);event CouncilMemberAdded(address indexed user, address indexed member);
event CouncilMemberRemoved(address indexed user, address indexed member);
event GraceVoteCast(address indexed user, address indexed voter, bool votedAlive);
event StatusDecided(address indexed user, bool isAlive);event DeliveryProven(address indexed user, uint256 indexed messageIndex, uint256 recipientIndex, address claimer);
event RewardClaimed(address indexed user, uint256 indexed messageIndex, address indexed claimer, uint256 amount);event ZkEmailVerifierSet(address verifier);
event DkimKeyUpdated(bytes32 domain, uint256 pubkeyHash, bool trusted);The protocol supports arbitrary check-in and grace periods:
// Example: Weekly check-in, 3-day grace
register("Alice", 1 weeks, 3 days);
// Example: Annual check-in, 30-day grace
register("Bob", 365 days, 30 days);Each user can choose independent durations, allowing flexible liveness strategies.
In addition to encrypted payloads, messages can include cleartext:
struct Message {
// ... encrypted fields ...
string publicMessage; // Optional cleartext
}Use cases:
- Funeral instructions
- Memorial text
- Will preview (without sensitive data)
- Messages to people who are not on email list
The claim package and proof system support multiple recipients via the recipients array:
- Each recipient has their own
s'(off-chain) - All recipients get the same encrypted payload
- Claimer proves delivery to each independently via zk-email
- Bitmap tracks completion for each recipient
Claimers need to find deceased users on-chain to deliver their messages. Without an enumerable list, there is no way to
discover user addresses — only individual lookups by known address exist. Events (Deceased, UserRegistered) work
off-chain but require indexers or archive nodes. Additionally, markDeceased(user) requires knowing the address, but
there is no on-chain way to discover addresses.
An opt-in discoverable list of all registered users. Users are not listed by default — they must explicitly opt in
by calling setDiscoverable(true), accepting that their address is publicly visible as a Farewell user.
- Call
getDiscoverableCount()to get total number of discoverable users - Paginate through
getDiscoverableUsers(offset, limit) - For each address, call
getUserState(addr)to check status - If status indicates timeout: call
markDeceased(addr), thenclaim(addr, index)
Opting into discoverability reveals:
- The user's wallet address is a Farewell user
- Their liveness status (via
getUserState())
It does not reveal message contents, recipient emails, or any encrypted data.
discoverableUsers: Dynamic array of opted-in addressesdiscoverableIndex: 1-indexed mapping for O(1) lookup and swap-and-pop removal- Opt-out uses swap-and-pop to avoid gaps in the array
For API details, see docs/contract-api.md.
The contract implements several gas optimizations:
- Unchecked arithmetic: Where overflow is impossible
- Storage pointers: Avoid redundant SLOAD/SSTORE
- Bitmap operations: Single uint256 tracks up to 256 recipients
- Event indexing: Three indexed parameters per event for efficient filtering
- FHE limbs: Exactly 7 × euint256 fits cleanly with euint128 within FHEVM limits
Before deploying Farewell to a new network:
- Verify FHEVM Support: Network must support Zama FHEVM with coprocessor
- Set DKIM Trusted Keys: Call
setTrustedDkimKey()for major email providers (Gmail, Outlook, etc.) - Deploy ZK Verifier: Set verifier via
setZkEmailVerifier() - Test Council Functions: Verify voting works with different council sizes
- Test Claim Packages: Download and verify claim package JSON schema
- Gas Limits: Verify all transactions fit within network gas limits
- Monitor Events: Set up off-chain indexers for UserRegistered, Ping, Deceased events
- Documentation: Update URLs and chain IDs in all references
For more information on specific aspects of the protocol:
- Contract API Reference — Function signatures, events, errors, and constants
- Building a Client — Step-by-step guide with TypeScript examples
- Delivery Proof Architecture — ZK-email proofs and verification
- Zama FHEVM Documentation — Homomorphic encryption details