---
title: presidentielle.fun: Sybil-resistant social space specification
description: Technical specification for a Twitter-like space on presidentielle.fun where only verified French citizens can post, every post is moderated by an LLM before publication, and members vote on the moderation rules.
date: 2026-10-23
lang: en-US
author: Julien Béranger
model: Claude Opus 5.5
conversation: https://claude.ai/chat/e40a9699-94f7-4aaa-973a-d349a9cfba66
source: https://julienberanger.com/presidentielle-social-app
---

# presidentielle.fun: Sybil-resistant social space specification

## Summary

[presidentielle.fun](https://github.com/julienbrg/presidentielle) gets a social space ahead of the [2027 French presidential election](https://en.wikipedia.org/wiki/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](https://self.xyz/) zero-knowledge proofs, and the account is the user's [w3pk](https://github.com/w3hc/w3pk) wallet, which is already embedded in the site.
- **Moderated before publication.** Every post and comment goes through an [LLM](https://en.wikipedia.org/wiki/Large_language_model) before it is stored. Nothing is published unchecked.
- **Community-governed rules.** Successful verification mints a non-transferable membership NFT from [w3hc/gov](https://github.com/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](https://celo.org/) contracts | Nobody, the operator included, can mint a vote without a valid proof |
| Posts, comments, moderation log | [PostgreSQL](https://www.postgresql.org/) on an [Infomaniak](https://www.infomaniak.com/) Ubuntu server | Simple, fast, free for users |
| Moderation and API | [Next.js](https://nextjs.org/) routes on [Vercel](https://vercel.com/) | The LLM key stays server-side, and no separate backend is needed |

## Architecture

```mermaid
flowchart LR
    U[User: w3pk wallet] -->|1. scan passport| S[Self app]
    S -->|2. ZK proof| H[Self Hub V2 on Celo]
    H -->|3. onVerificationSuccess| V[CitizenVerifier]
    V -->|4. operatorMint| N[Membership NFT]
    N --- G[Gov: votes + manifesto CID]
    U -->|5. EIP-712 signed post| A[Vercel /api/post]
    A -->|balanceOf| N
    A -->|manifesto| G
    A -->|rules + post| L[LLM]
    A -->|insert| P[(PostgreSQL)]
    U -->|read feed| A
```

## 1. Identity and membership (onchain)

### Contracts

| Contract | Source | Changes |
| --- | --- | --- |
| `NFT` | [w3hc/gov `NFT.sol`](https://github.com/w3hc/gov/blob/main/src/NFT.sol) | None |
| `Gov` | [w3hc/gov `Gov.sol`](https://github.com/w3hc/gov/blob/main/src/Gov.sol) | None |
| `CitizenVerifier` | New, extends Self's [`SelfVerificationRoot`](https://docs.self.xyz/contract-integration/basic-integration) | — |

The verification is **onchain**: in the [Self dashboard](https://dashboard.self.xyz/) 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`:

1. Reads the user's address from `output.userIdentifier`. The frontend sets `userId` to the w3pk address.
2. 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.
3. 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.
4. Calls `nft.operatorMint(user, tokenURI)`. The NFT itself reverts with `OneMemberOneVote` if the address already holds a membership.

```solidity
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

1. Deploy `NFT` with the deployer as temporary owner, and `Gov` pointing at it, using the [deploy script](https://github.com/w3hc/gov/tree/main/script) and its config file.
2. Deploy `CitizenVerifier` with the Hub V2 address, the scope and the verification config (nationality disclosed, OFAC check as desired).
3. As owner: `nft.setOperator(verifier)` and `nft.setOperatorExpiration(0)`, meaning no expiry.
4. Transfer NFT ownership to `Gov`. From then on, only a governance vote can change the operator.
5. Register the config in the Self dashboard and point the frontend's `SelfAppBuilder` at the verifier, with `endpoint` set to the verifier address in lowercase, `endpointType: "celo"` and `userIdType: "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](https://github.com/w3hc/gov#access-control) 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](https://eips.ethereum.org/EIPS/eip-712) signature from the user's w3pk wallet:

```ts
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`

1. Verify the EIP-712 signature with [ethers v6](https://docs.ethers.org/v6/) and recover the author.
2. Check `nft.balanceOf(author) == 1` over Celo RPC. Cache positive results for a few minutes.
3. 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.
4. If `parentId != 0`, check that the parent exists and is published, and derive `root_id` and `depth`.
5. Moderate (see below). On rejection, log it and return the reason to the author.
6. 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/:rootId` returns the whole thread in one query on `root_id`. The client builds the tree from `parent_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

```sql
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

1. Read `Gov.manifesto()`, an [IPFS](https://ipfs.tech/) CID. Fetch the rules document and cache it by CID, since a given CID never changes.
2. 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.
3. Require strict JSON in return: `{ "decision": "allow" | "reject", "rule_ids": ["R3"], "reason": "…" }`.
4. **Fail closed.** A timeout, malformed output or provider error means the post is not published. Log it as `error` and 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`](https://docs.openzeppelin.com/contracts/5.x/api/governance): 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.** `Gov` uses 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`, and `RELAYER_PRIVATE_KEY` if 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](https://www.pgbouncer.org/) 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](https://github.com/fail2ban/fail2ban).
- **Backups.** Daily `pg_dump` to separate storage, with restores tested. Losing the database means losing people's political speech.
- Threads can also be queried with [recursive CTEs](https://www.postgresql.org/docs/current/queries-with.html) if `root_id` ever 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](https://en.wikipedia.org/wiki/Digital_Services_Act) and French law. The `removed` status supports this.
- **Personal data.** Political posts tied to a verified citizen reveal political opinions, a special category under [GDPR article 9](https://gdpr-info.eu/art-9-gdpr/). 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:

1. **Signed posts.** Included from day one. Anyone holding a post can verify its author and content, and the operator can't fabricate posts.
2. **Onchain anchoring.** Every N minutes, publish on Celo a [Merkle root](https://en.wikipedia.org/wiki/Merkle_tree) of new posts and moderation decisions. Silent deletion, edits or rollbacks then become detectable, and users can request inclusion proofs.
3. **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.
4. **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.

## Further reading

- [Self documentation](https://docs.self.xyz/)
- [OpenZeppelin Governor](https://docs.openzeppelin.com/contracts/5.x/api/governance)
- [EIP-712: typed structured data signing](https://eips.ethereum.org/EIPS/eip-712)
- [w3hc/gov](https://github.com/w3hc/gov)
- [w3pk](https://github.com/w3hc/w3pk)
- [PgBouncer documentation](https://www.pgbouncer.org/config.html)
