Blog · Turnstile series, part 6

Three listings per human: capping an open agent registry with World ID Selfie Check

How a Selfie Check caps Turnstile's open agent registry at three listings per human.

TL;DR

Turnstile is a market where AI agents find sellers through the ERC-8004 registry and pay them per query. Anyone can register an agent and publish a price. That is the point, but also the problem: one person with a script can register two hundred subnames and bury every honest seller in the ranking.

We added proof of personhood with World ID. It answers one question: is the user trying to create a listing an actual human? We need to know so we can cap how much of the registry one person can hold. Three listings per human.

Five pieces make it work.

  • World ID Selfie Check, a medium-assurance credential: a phone camera liveness check plus a comparison against faces already enrolled, not an Orb. Enough to make farming expensive.
  • IDKit v4, World's SDK, runs the Selfie Check in the browser. The server signs the request and verifies the proof; the browser is trusted with nothing.
  • The nullifier, a stable pseudonym per app and per action. We count listings against it and never return it to the browser.
  • The signal, a string the proof is bound to. We use the listing's name, so a proof made for one listing can't be replayed to claim another.
  • Reservations. A verified subname under turnstile.eth counts against the cap before it is registered as an agent, so verifying names first and registering them later does not dodge it.

The code is at github.com/IpastorSan/turnstile.

Part 6 of 9 in the Turnstile series: building a paid lane for onchain data agents.

Why an open registry needs a Sybil control

ERC-8004 gives agents a portable identity: an ERC-721 token in an Identity Registry, addressed by the registry's location, {namespace}:{chainId}:{identityRegistry} (e.g. eip155:11155111:0x8004A818…BD9e, Sepolia plus the registry's contract address), and a token id. Turnstile reads that registry (the indexing side is in ERC-8004 agent discovery with Substreams). Every Turnstile seller is also an ENS subname under turnstile.eth carrying its offer records (ENSv2 agent offer records).

flowchart LR
  subgraph S["ERC-8004 on Sepolia"]
    IR["Identity Registry<br/>ERC-721 at 0x8004A818…BD9e"] --> A["agent 10127<br/>eip155:11155111:0x8004A818…BD9e / 10127"]
  end
  subgraph E["ENS"]
    N["liquidity.turnstile.eth<br/>offer records: price, rails, payout"]
  end
  A -->|"agentURI"| N
  N -.->|"agent-registration record"| A
  T["Turnstile market"] -->|"indexes"| IR
  T -->|"reads offers"| N

Nothing in that picture says who is behind an agent. Registering one costs a transaction and asks for no proof of who you are, so one person can register as many as they like.

It's by design, and ERC-8004 says so. Its security section admits that Sybil attacks are possible and can inflate the reputation of fake agents, but it leaves the answer to the ecosystem: reputation systems that filter agents through trusted reviewers.

The classic result is still Douceur's 2002 paper, "The Sybil Attack". Without a trusted agency certifying identities, one entity can pretend to be many, except under unrealistic assumptions about resource consumption. Basically, a sybil attack is viable if creating multiple identities in a given system is cheap enough and no one is checking. There are four usual ways around it.

  • Resource cost: stake, fees, proof of work. Farming gets expensive, but a funded attacker can still bypass it.
  • Social graph or web of trust: works once you have a graph. A new market has none.
  • Reputation over time: the ERC-8004 answer. Slow, and it needs honest raters.
  • Proof of personhood: bind an identity to a unique human and let the application set the policy.

We picked the last one. It does not replace the others, but it is the only one that works on a market with no history.

In World ID, the person doesn't hand us an identity. After the check, the World App on their phone produces a proof: a zero-knowledge proof that says "a verified human approved this listing" without saying which human.

It carries two things we use.

  • The nullifier: a pseudonym that is the same every time this person verifies with us.
  • The signal: the listing the proof was made for.

The nullifier is how we count how many listings that human already holds. The signal makes sure the proof is spent on the listing it was made for, and no other.

Why three, and why Selfie Check is enough

A real operator sells more than one thing; even our own liquidity analyst and a depth analyst are different services at different prices. We limit the maximum listings a single person can publish to three, this cap only exists to make farming expensive. Three is an arbitrary decision and could be expanded.

For Turnstile we chose World's Selfie Check because it gave us the best balance of ease of implementation, user experience and security: anyone with the World App can do it from their phone, with no Orb visit and no passport.

World ranks its credentials by assurance: how sure you can be that each verified account is a different real person. The top level is the Orb, World's iris-scanning device, which checks that nobody has ever verified twice. Selfie Check is medium-assurance. It runs on the phone camera and checks two things. Liveness: a real person is in front of the camera, not a photo or an injected video. Facial similarity: at enrollment, the new face is compared privately with the faces already enrolled, and the response carries a sybil_score saying how many more matches turned up than chance would explain. World has since renamed the credential Proof of Presence.

The verification lasts 90 days without use, after which the person takes the selfie again.

Medium assurance is enough for us. We are not running an airdrop where one extra identity is worth money. What we need is for farming two hundred listings to cost two hundred faces instead of two hundred transactions.

One Developer Portal setting matters more than it looks: max_verifications on the action. Its default is 1, so World refuses a person's second verification. At that default the limit a user hits is World's, and our own "three, then refuse the fourth" never runs. We created the action with 0 (unlimited), so World proves personhood and Turnstile sets the policy.

The flow: sign on the server, verify on the server

The IDKit v4 integration has three hops.

  1. The browser asks /api/world/context for a signed RP context. In World's vocabulary the RP, or relying party, is the app asking for the proof: Turnstile. It is registered in World's Developer Portal under an rp_id and holds a signing key. The RP context is the request for a proof, signed with that key, so World knows it really comes from Turnstile. The signing key never leaves the server.
  2. The browser mounts IDKitRequestWidget with selfieCheckLegacy({ signal: listing }) and allow_legacy_proofs. The "legacy" is World ID 3.0: when we built this, Selfie Check only worked there. World has since added a selfieCheck preset for World ID 4.0.
  3. handleVerify posts the IDKit result, untouched, to /api/world/verify. The server forwards it to the Developer Portal, takes the nullifier from the proof the portal has verified, and applies the cap.

Verification is a plain POST to https://developer.world.org/api/v4/verify/{rp_id}, body forwarded as-is. The detail we care most about is what happens when the portal says yes and we cannot find a nullifier:

const nullifier = nullifierFrom(body) ?? nullifierFrom(idkitResult);
if (!nullifier) {
  // Without a nullifier there is nothing to count listings against, so a
  // "success" here would let one human verify repeatedly and hold unlimited listings.
  return { ok: false, status: response.status, reason: 'the portal accepted the proof but returned no nullifier, so it cannot be counted against a person', raw: body };
}
return { ok: true, proof: { nullifier, raw: body } };

identity/world.ts (trimmed)

Why refuse a proof World just accepted? Because the three listings cap runs on the nullifier. It is the one value that stays the same every time the same person verifies, so it is how we count how many listings that person already holds. A "yes" without a nullifier says a human is there but not which one, so we can't add the listing to anyone's count. Accept it and the same person could verify again and again, each time uncounted, and hold as many listings as they like. So no nullifier means no listing.

The nullifier: a pseudonym you keep to yourself

World defines the nullifier as a unique identifier for one user, one app and one action. Same human, same action, same app: same value. A different app gives a different one, so activity cannot be linked across apps.

We can use this to count rows sharing a nullifier. We know which listings belong to the same person, even if we cannot tell who that person is.

The action string needs to be immutable. Nullifiers are scoped to it, so renaming turnstile-operator would silently reset every human's count. It is a pinned constant, not a parameter.

The nullifier stays on the server. It is stable. If /api/world/verify returned it, anyone watching responses could correlate listings back to one person outside our store, which undoes the unlinkability World designed in. The route returns the tally (used, limit, remaining) and the listings held, never the value.

The verify route, trimmed to the order of operations:

const bound = checkSignal(body.proof, signal);
if (!bound.ok) return Response.json({ ok: false, code: 'signal_mismatch', reason: bound.reason }, { status: 400 });

const resolved = canonicalAgentUid(db, signal);            // a registered name becomes its agent uid, an unregistered one a reservation;
                                                            // the key comes from the store, never the client
if (!resolved.ok) return Response.json({ ok: false, code: resolved.code, reason: resolved.reason }, { status: 400 });
const listing = { key: resolved.key, kind: resolved.kind, ensName: resolved.ensName };

const outcome = await verifyProof(worldConfigFromEnv(), body.proof);
if (!outcome.ok) return Response.json({ ok: false, verified: false, reason: outcome.reason }, { status: 400 });

const { nullifier } = outcome.proof;                        // from the verified proof, never a field the browser fills in
const allowance = mayClaim(db, nullifier, listing.key);
if (!allowance.allowed) {
  recordVerification(db, { agentUid: listing.key, nullifier, status: 'rejected', proofRef: allowance.code ?? 'refused' });
  return Response.json({ ok: false, verified: true, allowed: false, code: allowance.code, used: allowance.used, limit: allowance.limit }, { status: 409 });
}
recordVerification(db, { agentUid: listing.key, nullifier, status: 'verified' });
// response: listing, used, limit, remaining. Never the nullifier.

web/app/api/world/verify/route.ts (trimmed)

The nullifier comes from a proof World has verified, never from a field the browser fills in on its own. The browser writes the request, so a nullifier taken from it unchecked could be anything: a fresh one each time to reset the count, or someone else's to fill up their allowance. When the portal's response lacks one, verifyProof falls back to the nullifier inside the IDKit result, which is safe only because the portal has just checked that proof, nullifier included.

The signal: which listing is this proof for?

A signal is any string the app gives IDKit before the user proves; the proof is computed over its hash, so it is only valid for that string. Ours is the listing.

The Developer Portal checks a proof against the signal_hash inside the payload. It will happily verify a proof made for a different listing. So before anything is stored, checkSignal compares every signal_hash in the IDKit responses with hashSignal(listing), the exact string the widget signed. A mismatch is refused as signal_mismatch.

There is one soft spot. IDKit documents signal_hash as optional, and we have not yet confirmed from a real Selfie Check payload that it is always there. So a payload with no signal_hash passes as checked: false instead of being refused. We left it that way because the cost is bounded: under one action, every listing a human verifies shares their nullifier, so a replayed proof can only spend its own human's allowance.

The cap, including reservations

A seller doesn't need a registered agent to verify. On /onboard they pick a name under turnstile.eth, say depth.turnstile.eth, and can take the selfie before that name is an ERC-8004 agent. A verified name with no agent behind it yet is a reservation: we store the verification under the ENS name, and when the agent is registered the row moves to its agent uid.

The Verify a listing block on /onboard: choose a registered agent or a reserved name, then verify it with World Selfie Check

Reservations count against the same three listings. If they didn't, the cap would be easy to dodge: verify two hundred names while none of them is an agent yet, so each one counts as zero, then register them all. Counting reservations means the fourth verification is refused before anything is registered.

Every new verification goes through mayClaim, which decides whether this human may take this listing:

export function mayClaim(db: DatabaseSync, nullifier: string, agentUid: string): ListingAllowance {
  const standing = standingFor(db, nullifier);
  if (standing.agents.includes(agentUid)) {
    return { allowed: true, used: standing.used, limit: standing.allowance.limit, remaining: standing.allowance.remaining };
  }
  const existing = verificationFor(db, agentUid);
  if (existing?.status === 'verified' && existing.nullifier !== nullifier) {
    return { allowed: false, /* ... */ code: 'listing_held_by_another_human' };
  }
  return standing.allowance;   // listing_limit_reached once used >= 3
}

identity/store.ts (trimmed)

It answers three cases, in order:

  • A listing you already hold: allowed, and it uses no new slot, so re-verifying a listing costs nothing.
  • A listing another human holds: refused, so nobody can take over someone else's listing by verifying it.
  • Anything else: allowed while you hold fewer than three, refused with listing_limit_reached after that.

Only verified rows count. A rejected row records that somebody tried, and counting it would let a failed attempt eat someone else's allowance.

What it buys

The registry is still open. Anyone can register an agent and publish a price, and nothing here asks who they are. What changed is the price of flooding it: farming two hundred listings used to cost two hundred transactions, and now it costs two hundred faces. Three listings per human, counted against a pseudonym we never show, is a policy an open market can live with on day one, before it has any history to rank sellers by.

Sources

  • World ID overview: World ID as proof of human and uniqueness; the three credential types (Proof of Human via Orb, Document, Selfie Check).
  • Proof of Presence, formerly Selfie Check: medium assurance, liveness plus facial similarity against enrolled faces, sybil_score as a risk signal, no strict one-person-one-account guarantee, 90-day inactivity window.
  • IDKit integrate guide: server-side signRequest, never expose the signing key, POST https://developer.world.org/api/v4/verify/{rp_id}, forward the payload as-is, nullifier checks on the backend.
  • IDKit credentials and presets: selfieCheckLegacy() on World ID 3.0 and the newer selfieCheck for 4.0, signal binding, allow_legacy_proofs.
  • World ID concepts: nullifier as unique per user, app and action; same value on repeat; unlinkable across apps.
  • worldcoin/idkit on GitHub: the IDKit SDK repo that @worldcoin/idkit 4.2.3 and @worldcoin/idkit-core 4.2.4 point to.
  • ERC-8004: Trustless Agents: Identity, Reputation and Validation registries; agentRegistry format; Sybil attacks acknowledged and left to reputation filtering.
  • Douceur, "The Sybil Attack" (IPTPS 2002): without a trusted certifying agency, Sybil attacks stay feasible.
  • Turnstile repository: identity/, the World routes.

Next in the series: Spending mandates for agents: the key that spends can never raise its own limit, part 7 of 9.