presidentielle.fun: Sybil-resistant social space specification

Julien Béranger

+ Claude Opus 5.5

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:

ConcernWhereWhy
Citizenship check, membership NFT, votes, moderation rulesCelo contractsNobody, the operator included, can mint a vote without a valid proof
Posts, comments, moderation logPostgreSQL on an Infomaniak Ubuntu serverSimple, fast, free for users
Moderation and APINext.js routes on VercelThe LLM key stays server-side, and no separate backend is needed

Architecture

1. Identity and membership (onchain)

Contracts

ContractSourceChanges
NFTw3hc/gov NFT.solNone
Govw3hc/gov Gov.solNone
CitizenVerifierNew, 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:

  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.
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 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 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

  1. Verify the EIP-712 signature with ethers 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

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 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: 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 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_dump to separate storage, with restores tested. Losing the database means losing people's political speech.
  • Threads can also be queried with recursive CTEs if root_id ever proves insufficient.

5. Threat model

ThreatMitigationResidual risk
One person, many accountsNullifier map in CitizenVerifier and one NFT per addressA 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 membersThe verifier contract is the only operator, and it is changeable only by voteA malicious governance vote could install a new operator
Forged or altered postsEach post stores the author's EIP-712 signatureNone for authorship. Deletion is still possible (see section 7).
Prompt injection against moderationDelimited input, strict JSON output, fail closed, adversarial testsLLMs remain fallible, so appeals are needed
Spam from real membersPer-address rate limits, plus rules and moderationLow, since each address is a verified person
Database compromiseTLS, least privilege, backupsThe operator can still alter data silently until anchoring exists
Moderator biasRules are public and voted, and every decision is logged with its rules CIDThe 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 removed status 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:

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

presidentielle.fun: Sybil-resistant social space specification — Julien Beranger