PoP-verified (proof of personhood)
The PoP-verified trust layer (Layer 4 in the trust spectrum) elevates a Human Proof from “a wallet attested” to “a unique human attested.” Verification happens fully on-chain through Worldcoin’s IWorldID verifier; no off-chain trust in any service.
A separate resolver (FidemarkPoPResolver) decodes the attestation, recomputes the externalNullifier and signal from the same content hash + attester, and calls the Worldcoin verifier. The same nullifier cannot be reused for the same content, preventing the same human from attesting the same thing twice.
The flow
Section titled “The flow”- Hash the content client-side. Same as every other Fidemark flow.
- User runs IDKit verification. The user’s World App produces a Groth16 proof against the canonical action
actionForContent(contentHash), withsignal = (contentHash, attester)baked in. - Submit on-chain. The SDK packages the proof into the PoP schema and submits to EAS. The resolver verifies the proof on-chain; reverts if invalid.
- Verify like any other attestation. The decoded result has
type = "pop"pluspop.{root, nullifierHash, proof, ...}fields.
Helpers (off-chain to on-chain parity)
Section titled “Helpers (off-chain to on-chain parity)”All three SDKs expose identical hash-to-field helpers so frontends can pre-compute exactly what the chain will check.
import { actionForContent, externalNullifierFor, signalHashFor, hashContent } from "@fidemark/sdk";
const contentHash = hashContent(myArticle);actionForContent(contentHash); // "f1b94d27..." (30 chars)externalNullifierFor("app_fidemark_xyz", contentHash); // BigIntsignalHashFor(contentHash, signerAddress); // BigIntfrom fidemark import action_for_content, external_nullifier_for, hash_content, signal_hash_for
content_hash = hash_content(my_article)action_for_content(content_hash)external_nullifier_for("app_fidemark_xyz", content_hash)signal_hash_for(content_hash, signer_address)contentHash := fidemark.HashContent([]byte(myArticle))fidemark.ActionForContent(contentHash)fidemark.ExternalNullifierFor("app_fidemark_xyz", contentHash)fidemark.SignalHashFor(contentHash, signerAddress)Why this format? Worldcoin’s Developer Portal caps action identifiers at 32 characters. We use a 2-char prefix (f1) plus 28 hex chars of the content hash (= 14 bytes = 112 bits of entropy). Collision probability across realistic attestation volumes is negligible.
The on-chain resolver exposes actionForContent(bytes32), externalNullifierFor(bytes32), and signalHashFor(bytes32,address) view functions that return identical values, useful for cross-checking IDKit input from a block explorer.
Submit a Layer 4 attestation
Section titled “Submit a Layer 4 attestation”The proof comes from your IDKit integration; the snippet below assumes you already have it. See Configuration for every constructor option.
import { Fidemark, getNetwork } from "@fidemark/sdk";
const fidemark = new Fidemark({ network: getNetwork("base-sepolia"), privateKey: process.env.PRIVATE_KEY,});
const result = await fidemark.attestHumanWithPoP({ content: myArticle, contentType: "text/article", worldIdProof: { root: "0x...", nullifierHash: "0x...", proof: ["0x...", "0x...", "0x...", "0x...", "0x...", "0x...", "0x...", "0x..."], },});import osfrom fidemark import AttestHumanWithPoPInput, Fidemark, WorldIdProof, get_network
fidemark = Fidemark( network=get_network("base-sepolia"), private_key=os.environ["PRIVATE_KEY"],)
result = fidemark.attest_human_with_pop(AttestHumanWithPoPInput( content=my_article, content_type="text/article", world_id_proof=WorldIdProof( root="0x...", nullifier_hash="0x...", proof=("0x...","0x...","0x...","0x...","0x...","0x...","0x...","0x..."), ),))import ( "context" "log" "math/big" "os"
"github.com/fidemark/sdk-go/fidemark")
network, _ := fidemark.GetNetwork("base-sepolia")client, err := fidemark.New(fidemark.Config{ Network: network, PrivateKey: os.Getenv("PRIVATE_KEY"),})if err != nil { log.Fatal(err) }
res, err := client.AttestHumanWithPoP(context.Background(), fidemark.AttestHumanWithPoPInput{ Content: []byte(myArticle), ContentType: "text/article", WorldIDProof: fidemark.WorldIDProof{ Root: rootBigInt, NullifierHash: nullifierBigInt, Proof: [8]*big.Int{p0, p1, p2, p3, p4, p5, p6, p7}, },})The resolver checks:
creator == attestation.attester(you can only PoP-attest as yourself)proofMethodis on the resolver allowlist (pop-verified-worldidis registered at deploy)createdAtis not too far in the future (1 day drift)(externalNullifier, nullifierHash)has not been used beforeIWorldID.verifyProof(...)does not revert
If any check fails, the attestation is rejected and no UID is created.
Wire-up checklist
Section titled “Wire-up checklist”- Register a Worldcoin app at the Developer Portal. Pick Managed unless you have specific compliance reasons to self-manage signer keys.
- Allowlist actions of the form
f1<28-hex>(the first 28 hex chars of the content hash, prefixed withf1). Easiest: enable the “create actions on demand” setting if your portal tier supports it; otherwise allowlist actions per content hash you publish. - Frontend integration with IDKit:
- Configure
<IDKitRequestWidget>(v4) with yourapp_id, the canonicalaction, and thesignalderived from(contentHash, attester). - Pass the resulting proof to your SDK of choice.
- Configure
What this is not
Section titled “What this is not”- Not a humanity registry. The protocol stores the nullifier, not the human’s identity. You can prove “the same human attested these N pieces of content” via a stable (appId, action) externalNullifier, but you cannot link the nullifier to a real-world identity from the chain alone.
- Not the only PoP source. Today’s resolver supports the World ID family. Other PoP providers (Gitcoin Passport, Privado ID, etc.) need their own schema + resolver because their proof shapes differ. They can be added without breaking existing PoP attestations.
- Not Layer 3 (TEE). PoP proves identity, TEE proves code execution. They are orthogonal and can be combined via referenced attestation chains if a use case needs both.