Summary
presidentielle.fun gets a social space ahead of the 2027 French presidential election. Users can post, comment on a post, and reply to a comment.
Three properties define it:
- Sybil-resistant. One verified French passport gives one account. Citizenship is proven with Self zero-knowledge proofs, and the account is the user's w3pk wallet, which is already embedded in the site.
- Moderated before publication. Every post and comment goes through an LLM before it is stored. Nothing is published unchecked.
- Community-governed rules. Successful verification mints a non-transferable membership NFT from w3hc/gov. One NFT is one vote on the moderation rules, which the moderator reads directly from the chain.
The split between onchain and offchain is deliberate:
| Concern | Where | Why |
|---|---|---|
| Citizenship check, membership NFT, votes, moderation rules | Celo contracts | Nobody, the operator included, can mint a vote without a valid proof |
| Posts, comments, moderation log | PostgreSQL on an Infomaniak Ubuntu server | Simple, fast, free for users |
| Moderation and API | Next.js routes on Vercel | The LLM key stays server-side, and no separate backend is needed |
Architecture
1. Identity and membership (onchain)
Contracts
| Contract | Source | Changes |
|---|---|---|
NFT | w3hc/gov NFT.sol | None |
Gov | w3hc/gov Gov.sol | None |
CitizenVerifier | New, extends Self's SelfVerificationRoot | — |
The verification is onchain: in the Self dashboard the configuration uses On-chain verification, and the Self app sends the proof to the Self Hub on Celo rather than to a webhook.
CitizenVerifier
It is the NFT's operator, so no human-held key can mint. The customVerificationHook:
- Reads the user's address from
output.userIdentifier. The frontend setsuserIdto the w3pk address. - Checks that
output.nationality == "FRA". The verification config discloses nationality, and the check happens in the contract rather than relying only on the dashboard's included-countries list. - Checks that
usedNullifiers[output.nullifier]is false, then sets it. This is what ties one passport to one address, since Self's nullifier is unique per document per scope. - Calls
nft.operatorMint(user, tokenURI). The NFT itself reverts withOneMemberOneVoteif the address already holds a membership.
function customVerificationHook(
ISelfVerificationRoot.GenericDiscloseOutputV2 memory output,
bytes memory /* userData */
) internal override {
address user = address(uint160(output.userIdentifier));
if (keccak256(bytes(output.nationality)) != keccak256("FRA")) revert NotFrench();
if (usedNullifiers[output.nullifier]) revert AlreadyVerified();
usedNullifiers[output.nullifier] = true;
nft.operatorMint(user, tokenURI);
emit MemberVerified(user, output.nullifier);
}Field names follow Self's V2 output struct. Check them against the installed @selfxyz/contracts version before writing tests.
Deployment sequence
- Deploy
NFTwith the deployer as temporary owner, andGovpointing at it, using the deploy script and its config file. - Deploy
CitizenVerifierwith the Hub V2 address, the scope and the verification config (nationality disclosed, OFAC check as desired). - As owner:
nft.setOperator(verifier)andnft.setOperatorExpiration(0), meaning no expiry. - Transfer NFT ownership to
Gov. From then on, only a governance vote can change the operator. - Register the config in the Self dashboard and point the frontend's
SelfAppBuilderat the verifier, withendpointset to the verifier address in lowercase,endpointType: "celo"anduserIdType: "hex".
Bootstrapping: Gov needs members before anyone can propose. Either start with an empty _firstMembers list and let the first verified users carry the first votes, or seed a few founding members. Seeded members are exempt from the Self check, so publish their list.
Alternative considered: backend verification
In backend mode, Self sends results to a webhook on a Vercel route, which then calls operatorMint with a key stored in Vercel's environment variables. It was rejected for two reasons:
- Whoever holds that key can mint unlimited votes, which is the trust assumption the gov README warns about.
- The nullifier table would move to PostgreSQL, so Sybil resistance would depend on the database being intact and honest.
2. Posting and reading (Vercel + PostgreSQL)
Authentication
There are no sessions or cookies. Every write is an EIP-712 signature from the user's w3pk wallet:
const types = {
Post: [
{ name: "body", type: "string" },
{ name: "parentId", type: "uint256" }, // 0 = top-level post
{ name: "timestamp", type: "uint256" },
],
};The domain includes the site name, a version and the Celo chain ID, so a signature can't be replayed elsewhere. The server rejects timestamps more than 5 minutes old, and the signature's uniqueness in the database prevents replays within that window.
POST /api/post
- Verify the EIP-712 signature with ethers v6 and recover the author.
- Check
nft.balanceOf(author) == 1over Celo RPC. Cache positive results for a few minutes. - Check the per-address rate limit, for example 10 posts per hour, counted in PostgreSQL. Because every address is a verified person, a per-address limit is meaningful.
- If
parentId != 0, check that the parent exists and is published, and deriveroot_idanddepth. - Moderate (see below). On rejection, log it and return the reason to the author.
- Insert the post with its signature and the CID of the rules it was judged against.
Reading
GET /api/feed?cursor=returns top-level posts in reverse chronological order, with keyset pagination on(created_at, id).GET /api/thread/:rootIdreturns the whole thread in one query onroot_id. The client builds the tree fromparent_id.- Reads are public and need no signature.
Nesting is unlimited in the data. The UI indents up to depth 3 and shows deeper replies flat, with an "in reply to" link.
Schema
CREATE TABLE posts (
id BIGSERIAL PRIMARY KEY,
author CHAR(42) NOT NULL, -- lowercase 0x address
parent_id BIGINT REFERENCES posts(id),
root_id BIGINT REFERENCES posts(id), -- null for top-level posts
depth SMALLINT NOT NULL DEFAULT 0,
body TEXT NOT NULL CHECK (char_length(body) BETWEEN 1 AND 1000),
signature TEXT NOT NULL UNIQUE,
signed_at TIMESTAMPTZ NOT NULL,
rules_cid TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'published'
CHECK (status IN ('published', 'removed')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX posts_feed ON posts (created_at DESC, id DESC) WHERE parent_id IS NULL;
CREATE INDEX posts_thread ON posts (root_id, created_at);
CREATE INDEX posts_author ON posts (author, created_at DESC);
CREATE TABLE moderation_log (
id BIGSERIAL PRIMARY KEY,
author CHAR(42) NOT NULL,
body_hash CHAR(66) NOT NULL, -- keccak256 of the submitted body
post_id BIGINT REFERENCES posts(id), -- null when rejected
decision TEXT NOT NULL CHECK (decision IN ('allow', 'reject', 'error')),
rule_ids TEXT[],
reason TEXT,
rules_cid TEXT NOT NULL,
model TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);Rejected bodies are stored only as a hash: the log proves a decision was made without keeping content that was refused. A removed status exists for takedowns after publication, such as legal orders or rule changes applied retroactively by vote. Removed posts stay in the table and are hidden from the API.
Moderation
- Read
Gov.manifesto(), an IPFS CID. Fetch the rules document and cache it by CID, since a given CID never changes. - Send the LLM a fixed system prompt, the rules, the parent post for context when replying, and the candidate text, clearly delimited as untrusted input.
- Require strict JSON in return:
{ "decision": "allow" | "reject", "rule_ids": ["R3"], "reason": "…" }. - Fail closed. A timeout, malformed output or provider error means the post is not published. Log it as
errorand ask the user to retry.
The rules document is Markdown with numbered rules (R1, R2…), so decisions can cite them and proposals can amend specific rules.
Users try to manipulate LLM moderators, for example with "ignore your instructions" inside a post. Keep the candidate text in a delimited block and tell the model it is data. Test against a small adversarial set before launch and after every rules change.
3. Governing the moderation rules
- A proposal calls
Gov.setManifesto(newCid). Once it executes, the next moderation call uses the new rules automatically. - Members vote with the membership NFT, one vote each. Self-delegation happens at mint.
- Gas for voting. Voting is an onchain transaction, so members need CELO. To avoid that, a Vercel route can relay
castVoteBySig: the user signs the vote, and the relayer pays. The relayer can't forge or alter votes, only refuse to relay them, and users can always vote directly. - Quorum.
Govuses a percentage of total supply. As membership grows and most posters never vote, a high percentage becomes unreachable. Start low (for example 5–10%) and let governance adjust it. - Suggested starting parameters: voting delay 1 day, voting period 5 days, proposal threshold 1.
The founding rules should at minimum cover illegal content under French law, harassment, doxxing, spam and impersonation of candidates.
4. Infrastructure
Vercel
- Pin functions to the
cdg1(Paris) region, close to the database. - Environment variables:
DATABASE_URL(through PgBouncer),CELO_RPC_URL,NFT_ADDRESS,GOV_ADDRESS,LLM_API_KEY,IPFS_GATEWAY, andRELAYER_PRIVATE_KEYif vote relaying is enabled. Keep only a small balance on the relayer. - Moderation adds latency to each post. Set the function timeout accordingly, and show a "being reviewed" state in the UI.
PostgreSQL on Infomaniak
- Connection pooling. Serverless functions open many short connections. Run PgBouncer in transaction mode in front of PostgreSQL, and connect only through it.
- Exposure. Vercel has no fixed outbound IPs, so the pooler port is reachable from the internet. Require TLS, use a dedicated application role with only the needed privileges, disable password logins for superusers, and add fail2ban.
- Backups. Daily
pg_dumpto separate storage, with restores tested. Losing the database means losing people's political speech. - Threads can also be queried with recursive CTEs if
root_idever proves insufficient.
5. Threat model
| Threat | Mitigation | Residual risk |
|---|---|---|
| One person, many accounts | Nullifier map in CitizenVerifier and one NFT per address | A person holding both a passport and an ID card, or a renewed passport, has two documents and two nullifiers. Accepting passports only reduces this but excludes ID-card-only citizens. |
| Operator mints fake members | The verifier contract is the only operator, and it is changeable only by vote | A malicious governance vote could install a new operator |
| Forged or altered posts | Each post stores the author's EIP-712 signature | None for authorship. Deletion is still possible (see section 7). |
| Prompt injection against moderation | Delimited input, strict JSON output, fail closed, adversarial tests | LLMs remain fallible, so appeals are needed |
| Spam from real members | Per-address rate limits, plus rules and moderation | Low, since each address is a verified person |
| Database compromise | TLS, least privilege, backups | The operator can still alter data silently until anchoring exists |
| Moderator bias | Rules are public and voted, and every decision is logged with its rules CID | The model choice is still made by the operator |
6. Compliance notes
This section is not legal advice. Have it reviewed before launch.
- Hosting obligations. As a host of user content, the site needs a notice-and-action mechanism and a public point of contact under the Digital Services Act and French law. The
removedstatus supports this. - Personal data. Political posts tied to a verified citizen reveal political opinions, a special category under GDPR article 9. Keep data minimal: an address, posts and signatures, and nothing from the passport except what the contract checks. Document what is stored and for how long.
- Hosting location. Infomaniak runs its datacenters in Switzerland, which the EU recognizes as providing adequate data protection. Mention it in the privacy policy.
- Election period. French rules on political communication tighten as the vote approaches. Check whether any apply to a citizen forum.
7. Improving the storage layer
PostgreSQL makes the operator a trusted party for availability and integrity. These steps reduce that trust, in increasing order of effort:
- Signed posts. Included from day one. Anyone holding a post can verify its author and content, and the operator can't fabricate posts.
- Onchain anchoring. Every N minutes, publish on Celo a Merkle root of new posts and moderation decisions. Silent deletion, edits or rollbacks then become detectable, and users can request inclusion proofs.
- Public exports. Publish each anchored batch (posts, signatures and Merkle proofs) as a daily dump, pinned on IPFS. Anyone can mirror and audit the platform.
- Federation. Let others run read replicas or alternative frontends from the exports. PostgreSQL then becomes a cache rather than the source of truth, and the community could even replace the operator.
8. Open questions
- Verification gas. In onchain mode, does the Self app relay the proof transaction, or does the user pay CELO? Check this before designing the onboarding.
- Document types. Passports only (stronger Sybil resistance) or passports and ID cards (wider reach)?
- Minimum age. Require 18+ through the Self config, matching voting age?
- Initial members. An empty start or seeded founders?
- Appeals. Should a rejected author be able to request a second review, and should appeals be visible to members?
- LLM provider and model. Record the model in every log entry, and announce changes publicly, since a model change can shift moderation outcomes as much as a rules change.