- High-Level Overview
- Why FHEVM v0.9
- Threat Model & Privacy Guarantees
- Core Design Decisions
- Function-by-Function Walkthrough
- Why Progress Reveal Is Omitted
- Demo Withdraw Model
- Security Considerations
- Why This Is a Reference Implementation
Traditional fundraising on Ethereum exposes every donation amount, total raised, and goal status in plaintext. This creates several problems:
- Donor privacy: Anyone can trace which addresses donated how much
- Goal gaming: If a campaign is close to its goal, strategic donors can manipulate perception by withholding or flooding donations at the last moment
- Competitive intelligence: Rival campaigns can monitor progress and adjust their strategy accordingly
- Psychological effects: Campaigns far from their goal may discourage further participation
Ethereum's execution model is fundamentally transparent. Every state transition must be:
- Verifiable by all validators
- Reproducible from transaction inputs
- Stored in plaintext on-chain
Standard encryption approaches fail because:
- Symmetric encryption: Contract cannot hold private keys without exposure
- Asymmetric encryption: Cannot perform arithmetic on ciphertexts (cannot add donations)
- Zero-knowledge proofs: Can prove statements about hidden values but cannot maintain encrypted state across multiple transactions
Fully Homomorphic Encryption (FHE) allows computation on encrypted data without decryption. This contract requires:
- Addition: Accumulating donations while keeping amounts hidden
- Comparison: Checking if total ≥ goal without revealing either value
- Persistent encrypted state: Maintaining
encryptedTotalacross multiple transactions - Selective disclosure: Only revealing goal status at campaign end
FHE is the only cryptographic primitive that provides all four properties simultaneously.
FHEVM v0.9 uses a "self-relaying" model where:
- Encrypted values never exist in plaintext on-chain
- Homomorphic operations occur in a separate FHE coprocessor
- Results are re-encrypted and returned to the EVM
- Decryption requires cryptographic permission and off-chain signature verification
This differs from older FHE schemes that attempted to perform homomorphic operations directly in the EVM.
The type externalEuint32 represents an encrypted value created off-chain by a user's client. The workflow is:
- User encrypts their value locally using the network's public key
- User submits ciphertext + zero-knowledge proof of correctness
- Contract calls
FHE.fromExternal()to convert external ciphertext into on-chaineuint32 - The proof prevents malformed ciphertexts from corrupting contract state
Why this matters: Without externalEuint32, users could submit arbitrary ciphertexts that decrypt to values they don't actually possess (e.g., donating encrypted "1 million" when they only hold 1 token).
The line FHE.allowThis(goal) grants this contract permission to operate on the encrypted value. FHEVM's permission model works as follows:
- Each encrypted value has an access control list (ACL)
- Only addresses in the ACL can perform operations on that ciphertext
FHE.allowThis()addsaddress(this)to the ACL- Without this,
FHE.add()orFHE.ge()would revert
Security rationale: Prevents malicious contracts from operating on encrypted values they shouldn't access. Even though the contract cannot decrypt the value, homomorphic operations could still leak information through side channels.
Decryption in FHEVM occurs through a two-phase protocol:
- Request phase: Contract calls
FHE.makePubliclyDecryptable()to mark a ciphertext for decryption - Signature phase: Off-chain relayers decrypt the value and produce a signature proving correctness
- Verification phase: Contract calls
FHE.checkSignatures()to verify the decrypted value matches the encrypted handle
Why delayed: Immediate decryption would require synchronous communication with the FHE coprocessor, breaking the EVM's deterministic execution model.
Why signed: Prevents anyone from submitting false decryptions. The signature cryptographically binds the plaintext to the encrypted handle.
What Information Is Hidden
- Campaign goal: Only the creator knows the fundraising target
- Donation amounts: Each individual contribution is encrypted
- Running total: No one can observe how close the campaign is to its goal during the campaign
- Goal status during campaign: Even comparative information (e.g., "50% funded") is unavailable
- Campaign existence: The fact that campaign N exists is public
- Campaign owner: Address that created the campaign
- Deadline: Campaign end time (required for trustless finalization)
- Donor identity: Addresses that called
donate()are visible - Number of donations: Observable through
DonationReceivedevents - Final goal status: Whether the goal was reached (revealed only after deadline)
This contract does not handle actual ETH transfers for a critical reason: transfer amounts would appear in plaintext.
If the contract used msg.value:
function donate(uint256 campaignId) external payable {
// msg.value is visible to everyone → privacy leak
}Even if we stored an encrypted record, the plaintext msg.value on the transaction would reveal the donation amount.
Alternative approaches:
- Use an encrypted ERC20 token where balances are encrypted (future work)
- Handle ETH in a separate escrow contract that only knows campaign IDs, not amounts (adds complexity)
- Off-chain settlement where this contract only authorizes withdrawals (this contract's approach)
An observer monitoring all transactions cannot determine:
- Campaign viability: Whether a campaign is likely to succeed
- Donation distribution: Whether donations are concentrated or distributed
- Strategic timing: Whether late donations were attempting to push the campaign over the goal
- Donor wealth: Cannot correlate donation amounts with donor addresses
The only information leaked is behavioral metadata (who donated, when), which is unavoidable in a public blockchain without full anonymity (e.g., Zcash-style shielded pools).
What is done:
euint32 encryptedGoal;
euint32 encryptedTotal;Why it is done:
encryptedGoal: Prevents strategic donors from knowing the exact targetencryptedTotal: Hides progress to avoid discouragement or gaming
What would break if done differently:
- Plaintext goal: Donors could wait until the campaign is near its goal, then donate just enough to claim credit for success
- Plaintext total: The "Kickstarter effect" where stalled campaigns become self-fulfilling failures
What is done:
ebool isReached = FHE.ge(campaign.encryptedTotal, campaign.encryptedGoal);Why it is done:
- Compares two encrypted values homomorphically
- Result is also encrypted (
ebool) - No intermediate plaintext values exist
How it works:
FHE.ge(a, b)performs greater-or-equal comparison on ciphertexts- Returns encrypted boolean (ebool)
- Implementation uses TFHE bitwise comparison circuits
What would break if done differently:
- Decrypt first, then compare: Would reveal both goal and total
- Reveal total only: Would allow reverse-engineering the goal through multiple test campaigns
- Approximate comparison: Could leak information through error bounds
What is done:
FHE.makePubliclyDecryptable(campaign.encryptedGoalReached);Why it is done:
- Marks the encrypted boolean for decryption by the network
- Allows relayers to generate a proof without revealing other encrypted values
- Minimizes information disclosure (only 1 bit: goal reached yes/no)
What would break if done differently:
- Decrypt total: Would reveal exact amount raised
- Decrypt both total and goal: Would reveal complete financial data
- No decryption: Campaign owner could never prove goal was reached
Why only the boolean: The boolean represents the minimum information needed to decide fund release. Revealing more would violate privacy without adding utility.
What is done:
// Phase 1: Compute encrypted result
function allowFinalize(uint256 campaignId) external {
ebool isReached = FHE.ge(campaign.encryptedTotal, campaign.encryptedGoal);
FHE.makePubliclyDecryptable(campaign.encryptedGoalReached);
emit FinalizeDecryptionAllowed(campaignId);
}
// Phase 2: Submit decrypted result
function submitFinalize(uint256 campaignId, bool goalReached, bytes memory signature) external {
FHE.checkSignatures(handle, cleartext, signature);
campaign.withdrawAuthorized = goalReached;
}Why it is done:
- Phase 1: Triggers off-chain decryption process
- Phase 2: Verifies and commits the decrypted result
What would break if done differently:
- Single transaction: Impossible due to asynchronous decryption
- No signature verification: Anyone could submit false results
- Automatic decryption: Would require trust in a centralized oracle
Security properties:
- Only values marked with
makePubliclyDecryptablecan be decrypted - Decryption signature cryptographically binds plaintext to ciphertext
- Contract verifies signature before accepting the result
- Owner cannot selectively discard unfavorable results (decryption is deterministic)
Purpose: Initializes a new confidential fundraising campaign.
Inputs:
externalEuint32 encryptedGoal: Creator's encrypted fundraising targetbytes calldata inputProof: Zero-knowledge proof that the encrypted goal is well-formeduint256 durationDays: Campaign length (1-365 days)string calldata metadataURI: Off-chain reference for campaign description
Cryptographic operations:
euint32 goal = FHE.fromExternal(encryptedGoal, inputProof);- Verifies the ZK proof
- Converts external ciphertext to internal encrypted type
- Prevents malicious actors from submitting invalid ciphertexts
euint32 total = FHE.asEuint32(0);- Creates an encrypted zero
- Initializes the donation accumulator
FHE.allowThis(goal);
FHE.allowThis(total);- Grants this contract permission to operate on these ciphertexts
- Required for future
FHE.add()andFHE.ge()operations
Why permission checks exist:
- No special permission checks here (anyone can create a campaign)
- In a production system, might add stake requirements or identity verification
How FHE safety is preserved:
- Goal never decrypted until campaign ends
- Initial total is provably zero (no hidden starting balance)
Purpose: Adds an encrypted donation to a campaign's running total.
Inputs:
uint256 campaignId: Target campaignexternalEuint32 encryptedAmount: Donor's encrypted contributionbytes calldata inputproof: Proof of valid encryption
Cryptographic operations:
euint32 amount = FHE.fromExternal(encryptedAmount, inputproof);
campaign.encryptedTotal = FHE.add(campaign.encryptedTotal, amount);- Verifies donation amount is correctly encrypted
- Performs homomorphic addition on ciphertexts
- Result is a new ciphertext representing the sum
Why permission checks exist:
campaignExists: Prevents donations to non-existent campaignscampaignActive: Ensures campaign is open and not expired- No donor whitelist (anyone can donate)
How FHE safety is preserved:
FHE.allowThis(campaign.encryptedTotal);- Updates ACL for the new ciphertext
- Without this, future operations on
encryptedTotalwould fail
What this function does NOT do:
- Does not transfer actual tokens (demo-only contract)
- Does not validate minimum donation amounts (would require encrypted comparison)
- Does not prevent donation overflow (32-bit limit)
Purpose: Initiates the decryption process for goal status.
Inputs:
uint256 campaignId: Campaign to finalize
Cryptographic operations:
ebool isReached = FHE.ge(campaign.encryptedTotal, campaign.encryptedGoal);- Performs homomorphic greater-or-equal comparison
- Returns encrypted boolean (true if total ≥ goal, false otherwise)
FHE.makePubliclyDecryptable(campaign.encryptedGoalReached);- Marks this specific
eboolfor decryption - Relayers can now decrypt this value and generate a signature
- Does NOT decrypt other encrypted values (goal and total remain hidden)
Why permission checks exist:
onlyCampaignOwner: Only creator can initiate finalization!campaign.finalized: Prevents double-finalizationblock.timestamp >= campaign.deadline: Ensures campaign has ended
How FHE safety is preserved:
- Only the boolean result is marked for decryption
- Goal and total remain encrypted forever
- Even the campaign owner cannot force decryption of the actual amounts
Timing considerations:
- Must be called after deadline (trustless trigger)
- Must be called before
submitFinalize(two-phase requirement) - Gas cost is modest (mostly memory operations)
Purpose: Verifies and commits the decrypted goal status.
Inputs:
uint256 campaignId: Campaign being finalizedbool goalReached: Plaintext decryption resultbytes memory signature: Cryptographic proof of correct decryption
Cryptographic operations:
bytes32[] memory handle = new bytes32[](1);
handle[0] = FHE.toBytes32(campaign.encryptedGoalReached);
bytes memory cleartext = abi.encode(goalReached);
FHE.checkSignatures(handle, cleartext, signature);What checkSignatures does:
- Extracts the encrypted handle (ciphertext identifier)
- Verifies the signature was produced by an authorized relayer
- Confirms the signature binds this specific
goalReachedvalue to this specific ciphertext - Reverts if signature is invalid or mismatched
Why permission checks exist:
onlyCampaignOwner: Only creator can submit the result (though anyone could in theory)!campaign.finalized: Prevents result from being changed after commitment
Why anyone can submit decrypted data safely:
- The signature verification ensures correctness
- A malicious actor cannot submit
goalReached = trueif the encrypted comparison yieldedfalse - The cryptographic binding between ciphertext and plaintext is unforgeable
Result of successful verification:
campaign.withdrawAuthorized = goalReached;
campaign.isActive = false;
campaign.finalized = true;- Campaign is permanently closed
- Withdrawal is authorized only if goal was reached
- No further modifications possible
Purpose: Demonstrates withdrawal authorization without handling real funds.
Inputs:
uint256 campaignId: Campaign to withdraw from
Cryptographic operations: None (plaintext-only function).
Why permission checks exist:
onlyCampaignOwner: Only creator can withdrawcampaign.finalized: Ensures goal status has been verifiedcampaign.withdrawAuthorized: Only succeeds if goal was reached
What this function does:
emit WithdrawAuthorized(campaignId, campaign.owner);- Emits an event signaling withdrawal permission
- Off-chain systems can observe this event and release funds
- No ETH transfer occurs on-chain
Why this pattern is used: See Demo Withdraw Model below.
Many fundraising platforms show real-time progress (e.g., "75% funded"). This is not implemented here.
Scenario 1: Strategic exploitation
If getCurrentPercentage() returned encryptedTotal / encryptedGoal:
- The result would be a 32-bit encrypted fraction
- Decrypting periodically would reveal the rate of change
- Donors could infer optimal donation timing
Scenario 2: Statistical attacks If progress were revealed at fixed intervals:
- An observer could correlate donations with progress changes
- By creating multiple test campaigns with known goals, an attacker could reverse-engineer the function
- Over many campaigns, this would leak goal distributions
Scenario 3: Psychological manipulation
- Revealing that a campaign is at "99% of goal" creates enormous pressure for a final push
- Conversely, "1% funded" discourages participation
- The encrypted approach removes this gamification
A single bit of information (goal reached: yes/no) leaked at the end reveals:
- Almost nothing about the total raised (only an inequality)
- Nothing about individual donations
- Nothing about the goal magnitude
Information theory perspective:
- Progress reveal: Continuous information leakage over time
- Final boolean: Single bit revealed at conclusion
- The difference is exponential in entropy terms
This contract prioritizes cryptographic soundness over user experience. A production system might:
- Allow voluntary progress disclosure by the campaign owner
- Use secure multi-party computation to reveal statistical aggregates
- Implement differential privacy to add noise to progress reports
However, each of these introduces complexity and potential vulnerabilities. For a reference implementation, simplicity is preferred.
Problem: ETH transfers require plaintext amounts.
// This would defeat the purpose
payable(owner).transfer(plaintextAmount); // ← Privacy leakEven if we maintained encrypted records internally, the actual transfer reveals the amount in the transaction data.
The pattern used:
emit WithdrawAuthorized(campaignId, campaign.owner);How this works:
- Contract cryptographically proves goal was reached
- Contract emits authorization event
- Off-chain system (exchange, escrow service, Layer 2) observes event
- Off-chain system releases funds to campaign owner
- Real amounts never touch the encrypted contract
Benefits:
- Full privacy on-chain
- Flexible settlement mechanisms
- No smart contract holds funds (reduced attack surface)
- Compatible with future encrypted token standards
Separation of concerns:
- On-chain: Cryptographic proof of goal achievement
- Off-chain: Actual fund transfer
Trust model:
- Donors deposit funds into an escrow (custodial exchange, L2, multisig)
- Escrow trusts this contract to determine goal status
- No need for the contract to touch funds directly
Future integration: Once FHEVM-compatible encrypted ERC20 tokens exist:
// Hypothetical future code
encryptedToken.transfer(campaign.owner, campaign.encryptedTotal);This would enable fully on-chain settlement with preserved privacy.
Issue: Could an attacker reuse a signature from a previous finalization?
Mitigation:
bytes32[] memory handle = new bytes32[](1);
handle[0] = FHE.toBytes32(campaign.encryptedGoalReached);The signature is bound to the specific encryptedGoalReached ciphertext, which is unique per campaign. Replaying a signature from Campaign A on Campaign B will fail because the encrypted handles differ.
Additional safety:
require(!campaign.finalized, "Already finalized");Prevents re-finalization of the same campaign.
Issue: Could a malicious relayer selectively decrypt and only submit favorable results?
Mitigation:
- Decryption is deterministic (same ciphertext always decrypts to the same plaintext)
- Once
makePubliclyDecryptableis called, anyone can request decryption from the network - Multiple independent relayers can verify the result
- If a relayer submits an incorrect
goalReachedvalue,checkSignatureswill revert
Consequence: Campaign owner cannot censor unfavorable results. If the goal was not reached, no valid signature for goalReached = true exists.
Issue: Could someone finalize a campaign prematurely or without permission?
Mitigation:
modifier onlyCampaignOwner(uint256 campaignId) {
require(msg.sender == campaigns[campaignId].owner, "Only campaign owner");
_;
}
require(block.timestamp >= campaign.deadline, "Not ended");Rationale: Only the campaign owner can initiate finalization, and only after the deadline. This prevents:
- Front-running by competitors
- Premature closure during an active campaign
- Griefing attacks
Alternative design: Allow anyone to call allowFinalize after the deadline. This would be trustless but might incur gas costs for public goods. The current design leaves this decision to the campaign owner.
Issue: If submitFinalize were permissionless, couldn't an attacker submit false data?
Mitigation:
FHE.checkSignatures(handle, cleartext, signature);The signature verification ensures that only the correct decryption can be submitted. An attacker without a valid signature cannot change the result.
Design choice: Currently onlyCampaignOwner is enforced, but this could be relaxed to allow any address with a valid signature to submit. The owner modifier is a conservative choice that prevents potential griefing (e.g., an attacker submitting many failed attempts to burn the owner's gas).
Issue: Can a block proposer see donations in the mempool and front-run?
Analysis:
- Transaction data contains ciphertexts, which are semantically secure
- Proposer cannot decrypt the amount
- Proposer can observe that a donation occurred and the donor's address
- Proposer cannot reorder donations to influence goal status (finalization is after deadline)
Residual risk: Timing-based MEV (e.g., a proposer delaying a donation to the next block). This is a general blockchain problem not specific to FHE.
Issue: euint32 has a maximum value of 2^32 - 1. What if donations exceed this?
Current behavior: Homomorphic addition modulo 2^32. If overflow occurs, the total wraps around.
Impact: Campaigns with extremely high donation volumes could overflow, causing incorrect goal comparisons.
Mitigation strategies (not implemented, for simplicity):
- Use
euint64for larger campaigns - Add overflow detection (difficult with encrypted values)
- Cap donations at creation time
For a demo contract, this is acceptable. Production systems should assess expected donation volumes and choose appropriate bit widths.
Design philosophy: This contract demonstrates core FHEVM primitives without extraneous features.
What is omitted:
- Refunds (would require encrypted balance tracking per donor)
- Partial withdrawals (would reveal amounts)
- Campaign updates (changing goal would complicate privacy model)
- Multi-token support (adds contract complexity)
- Governance (out of scope)
Why these omissions matter: Each additional feature would require careful cryptographic analysis. By stripping to essentials, this contract serves as a clear teaching example.
Composable privacy primitives:
// Example: Campaign marketplace
contract Marketplace {
Confidential charityContract;
function recommendCampaign(address user) external view returns (uint256) {
// Uses encrypted goal data without accessing plaintext
}
}Possible extensions:
- Encrypted voting: Replace "donations" with "votes", compare against encrypted quorum
- Sealed-bid auctions: Replace "goal" with "highest bid", compare bids homomorphically
- Private allocations: Use this as a building block for confidential grants
Integration patterns:
// Hypothetical: Encrypted ERC20 integration
import {EncryptedERC20} from "fhevm-erc20";
contract PrivateCharity is Confidential {
EncryptedERC20 public donationToken;
function donateToken(uint256 campaignId, externalEuint32 amount, bytes calldata proof) external {
// Transfer encrypted amount from donor to contract
donationToken.encryptedTransferFrom(msg.sender, address(this), amount, proof);
// Add to campaign (same logic as current donate function)
campaigns[campaignId].encryptedTotal = FHE.add(campaigns[campaignId].encryptedTotal, amount);
}
}Cryptographic complexity: FHE is already complex. Each homomorphic operation has:
- Gas costs (higher than plaintext equivalents)
- Security assumptions (ciphertext leakage risks)
- Correctness requirements (proof verification)
Audit surface: A simple contract with 5 functions and 2 encrypted operations is auditable. A complex contract with 20 functions and 50 encrypted operations is exponentially harder to verify.
Formal verification: This contract's logic could be formally verified:
∀ campaignId:
(encryptedTotal >= encryptedGoal) ⟺ (goalReached = true after finalization)
Adding features would require additional proofs.
Security invariants: This contract maintains:
- Goal is never decrypted before finalization
- Total is never decrypted at all
- Only authorized decryptions occur
- Finalization is deterministic
Each additional feature risks violating an invariant.
Recommended study order:
- Understand this contract (encrypted state + comparison + decryption)
- Study FHEVM ERC20 (encrypted balances + transfers)
- Study FHEVM NFT privacy (encrypted metadata)
- Build domain-specific applications
Key concepts demonstrated:
externalEuint32input handling- Homomorphic arithmetic (
FHE.add) - Homomorphic comparison (
FHE.ge) - Permission management (
FHE.allowThis) - Decryption workflow (
makePubliclyDecryptable+checkSignatures)
Next steps:
- Implement encrypted refunds (requires donor tracking)
- Add encrypted minimum donation threshold
- Create multi-campaign coordination logic
- Build UI with client-side encryption
This contract represents a foundational pattern for confidential state machines on FHEVM. By separating cryptographic primitives (encrypted arithmetic, comparison, controlled decryption) from application logic, it provides a reusable template for privacy-preserving smart contracts.
The design prioritizes:
- Correctness: Cryptographic operations are used as intended
- Minimalism: No unnecessary features
- Clarity: Code structure mirrors the FHE workflow
- Security: Conservative permission model
For production use, developers should:
- Assess gas costs for expected usage patterns
- Integrate with encrypted token standards
- Add application-specific features carefully
- Conduct thorough security audits
This contract is not a product—it is a pedagogical reference. Its value lies in demonstrating how FHE can be applied to a familiar use case (fundraising) in a way that illuminates the underlying architecture.
Further Reading:
Contract Address: To be deployed on Zama devnet
License: BSD 3-Clause Clear License
Version: FHEVM v0.9