How we built a protocol where AI agents vote on subjective tasks without ever revealing their individual scores — using FHE, Solidity, and a dark-terminal frontend.
The Problem
When multiple AI agents evaluate the same task — an LLM output, a code review, a creative brief — their individual scores carry valuable signal. But today's on-chain voting has a fundamental flaw: every vote is visible.
This creates three problems:
Anchoring — The first score revealed biases all subsequent voters. If Agent A submits an 87, Agent B is subconsciously pulled toward 87 rather than evaluating independently.
Collusion — Agents can coordinate to manipulate the consensus by adjusting their scores after seeing peers' submissions.
Chilling effects — An agent that wants to submit an honest low score (say, 30) may hesitate if other agents or the round creator can see it and retaliate.
Existing solutions — multi-sigs, DAO voting, commit-reveal schemes — all leak individual votes at some point in the lifecycle. Commit-reveal just delays the reveal; it doesn't prevent it.
What we needed was a way to compute the average of encrypted inputs — and only decrypt the final result.
The Solution: FHE-Based Consensus
Fully Homomorphic Encryption (FHE) lets you perform arithmetic directly on ciphertexts. You can add, subtract, multiply, and divide encrypted numbers — and the result, when decrypted, equals what you'd get by operating on the plaintexts.
Conclave uses Fhenix's CoFHE (Confidential FHE) SDK to implement a multi-agent consensus protocol where:
- Scores are encrypted on the client side — no one, including the contract, ever sees a plaintext score
- The contract sums ciphertexts using FHE — the running total stays encrypted throughout
- Only the final average is decrypted — by a threshold network of FHE nodes, after all agents have committed
Individual scores remain encrypted forever.
Agent 1 ───── encrypt(87) ─────┐
▼
Agent 2 ───── encrypt(92) ───► FHE.sum() ──► FHE.div() ──► avg = 81
▲
Agent 3 ───── encrypt(64) ─────┘
│
Threshold Decrypt
│
81/100
The Protocol
Conclave's protocol has five phases, encoded as a Solidity enum:
enum Phase { Voting, Revision, Finalized, Revealed }
1. Create
The round creator specifies:
- A list of agent addresses (2–50)
- A task URI (either a URL or inline
data:application/jsonURI) - A
revisionsEnabledflag
The contract initializes two FHE accumulators — encryptedSum and encryptedCount — both set to FHE.asEuint32(0). These will hold the running total and count in encrypted form.
r.encryptedSum = FHE.asEuint32(0);
r.encryptedCount = FHE.asEuint32(0);
FHE.allowThis(r.encryptedSum);
FHE.allowThis(r.encryptedCount);
The allowThis calls are CoFHE's permission model — they grant the contract itself the ability to read (and operate on) these encrypted values.
2. Vote
Each agent:
- Fetches the task from the URI
- Scores it via an LLM (e.g., GPT-4o), producing a plaintext integer 0–100
- Encrypts it client-side using
@cofhe/sdk:
const [encrypted] = await cofheClient
.encryptInputs([Encryptable.uint32(BigInt(score))])
.execute();
- Submits the
InEuint32ciphertext to the contract
The contract decrypts nothing. It adds the ciphertext to the encrypted sum using pure FHE arithmetic:
euint32 score = FHE.asEuint32(encryptedScore);
euint32 newSum = FHE.add(r.encryptedSum, score);
r.encryptedSum = newSum;
euint32 newCount = FHE.add(r.encryptedCount, FHE.asEuint32(1));
r.encryptedCount = newCount;
3. Revision (Optional)
If the creator enabled revisions, they can open a revision window after all agents have voted. Agents who already voted can submit a new encrypted score. The contract subtracts the old ciphertext and adds the new one — all in the encrypted domain:
euint32 sumAfterRemoval = FHE.sub(r.encryptedSum, oldScore);
euint32 newSum = FHE.add(sumAfterRemoval, newScore);
r.encryptedSum = newSum;
This is the power of FHE: the contract can update values without ever seeing what those values are.
4. Finalize
Once quorum is met, the creator locks the round. The contract computes the encrypted average:
euint32 avg = FHE.div(r.encryptedSum, r.encryptedCount);
It stores this encrypted average as consensusHandle and moves the phase to Finalized. At this point, no more votes or revisions are accepted.
5. Reveal
The Fhenix threshold network decrypts the average. revealConsensus() is called with the plaintext result and a cryptographic proof:
function revealConsensus(uint256 roundId, euint32 ctHash, uint32 plaintext, bytes calldata signature) external {
FHE.publishDecryptResult(ctHash, plaintext, signature);
r.consensusScore = plaintext;
r.phase = Phase.Revealed;
}
The threshold network ensures that no single party can forge a decryption — the signature proves a quorum of FHE nodes validated the result.
Smart Contract Architecture (353 Lines)
The entire protocol fits in a single Solidity contract. Key design decisions:
Errors, Not Strings
18 custom errors for gas-efficient reverts. This is Solidity best practice — custom errors are cheaper than require with string messages and provide typed data for frontend handling.
error QuorumNotMet(uint256 roundId, uint32 submitted, uint32 required);
error ScoreOutOfRange(uint32 plaintext, uint32 max);
error RevisionNotEnabled(uint256 roundId);
Permission Model
CoFHE requires explicit allow calls for every encrypted value — otherwise it's inaccessible. The contract scopes access tightly:
-
FHE.allowThis()— the contract itself can read and operate on the value -
FHE.allow(score, msg.sender)— the submitting agent can read their own score later -
FHE.allowPublic()— only called on the final average, so the threshold network can decrypt it
Without these permits, even the contract deployer cannot decrypt individual votes.
Zero-Knowledge About Individual Inputs
The contract never stores plaintext scores. The mapping storing each agent's vote is:
mapping(uint256 => mapping(address => euint32)) private _agentScoreHandle;
This is an encrypted handle, not a plaintext value. Even a storage-level attack on the blockchain would only yield ciphertexts.
The Agent Runner
The agent/ directory contains a Node.js/TypeScript runner that:
- Polls the contract every 30 seconds
- Fetches the task from the on-chain URI
- Sends it to an OpenAI-compatible LLM with the scoring rubric
- Encrypts the score using
@cofhe/sdk/node - Submits the encrypted ciphertext via
submitVote()
We recently refactored it to accept a --agent flag so each agent runs in its own terminal:
npx ts-node agent/index.ts --agent 1
npx ts-node agent/index.ts --agent 2
npx ts-node agent/index.ts --agent 3
The agent process is stateless — it reads everything it needs from the contract. You can kill and restart any agent, and it will pick up where it left off.
Rate Limiting
Running 3 agents against Infura's free tier quickly hits rate limits. We added exponential backoff with jitter:
async function withRetry<T>(fn: () => Promise<T>, attempt = 1): Promise<T> {
try {
return await fn();
} catch (e: any) {
if (e?.code === "BAD_DATA" && attempt < 6) {
const delay = Math.min(1000 * 2^attempt + Math.random() * 1000, 30_000);
await new Promise((r) => setTimeout(r, delay));
return withRetry(fn, attempt + 1);
}
throw e;
}
}
Each agent also starts with a random 0–5s stagger and desynchronizes its poll interval by ±2.5s.
The Frontend
The frontend is a Next.js 14 App Router application with a visual identity inspired by Linear, Vercel, and Stripe — clean, monochrome, with a single purple accent (#c084fc).
Design System
The palette is deliberately restricted:
- Base: #040816 (near-black blue)
- Surfaces: #070B1F, #0A0F2A, #1A1F3A
- Accent: #c084fc (purple, single use)
- Success: #22c55e (emerald)
- Warning: #f59e0b (amber)
- Borders: thin, 1px solid #1A1F3A
No gradients. No blur effects. No box shadows. The "glass" card is achieved with flat color and a thin border — no backdrop-filter:
.glass {
background: rgba(10, 15, 42, 0.6);
border: 1px solid rgba(30, 35, 60, 0.6);
}
Pages
| Route | Purpose |
|---|---|
/ |
Landing page (hero, protocol explainer, FHE comparison) when disconnected. Switches to round list when wallet connects. |
/create |
4-step wizard: Task, Agents, Rules, Launch. Supports inline task entry (title, description, rubric) or URL paste. |
/round/[id] |
Mission control. Two-column layout: protocol execution flow on the left, agent telemetry on the right. |
Key Components
- FhePipeline — Vertical stage diagram showing the protocol execution flow with animated timeline connectors
- AgentList — Participant identity cards showing wallet address, vote status, ciphertext count, and local decrypt
- ConsensusResult — SVG ring meter with the final score and protocol completion bar
- RoundCard — Protocol timeline with horizontal phase indicators (Created → Voting → Revision → Finalized → Revealed)
- VoteForm — Encrypted vote slider with encrypt-then-submit flow
- ProtocolStrip — Phase status bar at the top of every round detail page
The Consensus Visualization
The landing page features an animated SVG showing the full protocol flow: agent nodes pulse, data packets travel downward into an FHE computation box, then through threshold decryption, and finally the consensus score appears with a scale-in animation.
@keyframes data-packet {
0% { opacity: 0; transform: translateY(0); }
25% { opacity: 1; }
75% { opacity: 1; }
100% { opacity: 0; transform: translateY(68px); }
}
Testing
52 test cases covering the full protocol spec. Run with:
npx hardhat test
Breakdown:
| Group | Tests | What It Covers |
|---|---|---|
| Create Round | 4 | Quorum validation, duplicates, empty URI, max agents |
| Submit Vote | 6 | Encryption path, submission, double-vote rejection, agent-only access |
| Revision | 8 | Open, revise, re-revision rejection, disabled-round |
| Finalize | 6 | Quorum check, re-finalize rejection, encrypted average |
| Reveal | 6 | Threshold decrypt, score bounds, double-reveal |
| ACL | 6 | Permission model, allowThis/allow scoping |
| Full E2E | 8 | No-revision path, with-revision path, all phases |
| Truncation | 8 | Score boundaries, div-by-zero safety |
Test Helper: FHE
Testing FHE contracts requires a local CoFHE node. The test helper sets up the environment:
// test/helpers/fhe.ts
import { createCofheConfig, createCofheClient } from "@cofhe/sdk/node";
// ... configures hardhat in-process environment
Deployment
The contract is deployed on Ethereum Sepolia at:
0x0C83824a9800f9ED1e22ec289CB67E065ceA73C2
Deployment via Hardhat:
npx hardhat run scripts/deploy.ts --network sepolia
The deploy script uses dynamic gas pricing — 2 × baseFee + maxPriorityFeePerGas — to avoid "max fee per gas less than block base fee" errors that plagued earlier versions with hardcoded caps.
What We Learned
1. FHE is production-ready (with caveats)
The Fhenix CoFHE SDK works. Client-side encryption is straightforward. The threshold decryption network adds latency (5–15 seconds for decrypt), but it's reliable on Sepolia. The biggest friction point was configuration: the SDK's environment option accepts "node", "hardhat", "web", or "react" — not chain names like "sepolia".
2. Monorepos need explicit Vercel config
The frontend lives in frontend/ but Vercel expects the root to contain the Next.js app. The solution: set Root Directory to frontend/ in the Vercel project settings. vercel.json's rootDirectory property isn't valid when the project is already linked via GitHub — you must use the dashboard.
3. Hydration errors are a wagmi footgun
useAccount() returns different values on the server vs client, causing React hydration mismatches. The fix: use a mounted state guard that renders a static placeholder until useEffect fires on the client.
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return <StaticPlaceholder />;
4. Infura's free tier is aggressive
3 agents × multiple eth_calls per poll × 30s interval = rapid rate limiting. Solution: exponential backoff, staggered startup, desynchronized poll intervals.
5. WASM circular dependencies
CoFHE's WebAssembly workers create circular webpack chunks. They're harmless but noisy. Suppress with:
config.ignoreWarnings = [/Circular dependency.*cofhe_sdk/];
Project Structure
conclave/
├── contracts/
│ └── Conclave.sol # 353 lines, full protocol
├── frontend/
│ └── src/
│ ├── app/ # 4 routes (layout, home, create, round)
│ ├── components/ # 6 React components
│ ├── hooks/ # 6 custom hooks (useRound, useVote, etc.)
│ ├── lib/ # ABI, chain config, CoFHE client
│ └── types/ # Phase enum, Round interface
├── agent/
│ ├── index.ts # Agent runner with --agent flag
│ ├── llm.ts # OpenAI-compatible scoring
│ └── wallets.ts # Wallet management
├── test/
│ ├── Conclave.test.ts # 52 tests
│ └── helpers/
│ └── fhe.ts # FHE test utilities
└── scripts/
└── deploy.ts # Hardhat deployment
Try It Yourself
Prerequisites
- 3 Sepolia-funded agent wallets (~0.005 ETH each)
- An OpenAI API key
-
SEPOLIA_RPC_URL(Alchemy recommended over Infura)
Quick Start
git clone https://github.com/harishkotra/conclave
npm install
# Set up .env
cp .env.example .env
# Fill in: SEPOLIA_RPC_URL, AGENT_{1,2,3}_PRIVATE_KEY, LLM_API_KEY
# Deploy the contract (or use the existing one at 0x0C83...)
npm run deploy
# Start the frontend
cd frontend && npm run dev
# In 3 separate terminals, run the agents:
npx ts-node agent/index.ts --agent 1
npx ts-node agent/index.ts --agent 2
npx ts-node agent/index.ts --agent 3
# Create a round on the frontend with those 3 agent addresses
# Watch the protocol execute
Future Work
- Multi-round scheduling: An auction or queue where rounds are processed in batches
- Gas optimization: The current contract makes multiple FHE operations per vote; batching could reduce costs
- Slashing: Penalties for agents that submit scores but fail to revise when the revision phase opens
- VC (Verifiable Computation) proofs: Prove that the FHE computation was correct without relying on the threshold network
- Agent reputation: Track historical score deviations from consensus to weight future votes
Conclave is open source. Built with Solidity, Fhenix CoFHE, Next.js, and TypeScript.
Screenshots
Code and more: https://www.dailybuild.xyz/project/155-conclave






Top comments (0)