Privacy-preserving credential system built with Semaphore zero-knowledge proofs. Users register credentials via verifier-signed attestations, then prove membership without revealing their identity.
Contract addresses are identical on both chains (same deployer, same nonce).
| Contract | Address |
|---|---|
| Semaphore | 0x8A1fd199516489B0Fb7153EB5f075cDAC83c693D |
| CredentialRegistry | 0x17a22f130d4e1c4ba5C20a679a5a29F227083A62 |
| DefaultScorer | 0x6791B588dAdeb4323bc1C3d987130bC13cBe3625 |
| ScorerFactory | 0x016bC46169533a8d3284c5D8DD590C91783C8C06 |
| Contract | Address |
|---|---|
| Semaphore | 0x8A1fd199516489B0Fb7153EB5f075cDAC83c693D |
| CredentialRegistry | 0x17a22f130d4e1c4ba5C20a679a5a29F227083A62 |
| DefaultScorer | 0x6791B588dAdeb4323bc1C3d987130bC13cBe3625 |
| ScorerFactory | 0x016bC46169533a8d3284c5D8DD590C91783C8C06 |
| ID | Credential | Group | Family | Default Score | Validity Duration |
|---|---|---|---|---|---|
| 1 | Farcaster | Low | 1 | 2 | 30 days |
| 2 | Farcaster | Medium | 1 | 5 | 60 days |
| 3 | Farcaster | High | 1 | 10 | 90 days |
| 4 | GitHub | Low | 2 | 2 | 30 days |
| 5 | GitHub | Medium | 2 | 5 | 60 days |
| 6 | GitHub | High | 2 | 10 | 90 days |
| 7 | X (Twitter) | Low | 3 | 2 | 30 days |
| 8 | X (Twitter) | Medium | 3 | 5 | 60 days |
| 9 | X (Twitter) | High | 3 | 10 | 90 days |
| 10 | zkPassport | — | — | 20 | 180 days |
| 11 | Self | — | — | 20 | 180 days |
| 12 | Uber Rides | — | — | 10 | 180 days |
| 13 | Apple Subs | — | — | 10 | 180 days |
| 14 | Binance KYC | — | — | 20 | 180 days |
| 15 | OKX KYC | — | — | 20 | 180 days |
When a smart contract consumes BringID proofs on-chain (e.g. an airdrop or gating contract), the Semaphore scope is bound to msg.sender + context. This means any transaction routed through the same contract shares the same scope — an attacker can copy proofs from the mempool and front-run the original caller.
Solution: Bind the Semaphore message field to the intended recipient. The @bringid/contracts package provides BringIDGated — an abstract base that handles app ID validation, message binding, and proof submission. Your contract only needs to check the returned score.
import {BringIDGated} from "@bringid/contracts/BringIDGated.sol";
import {CredentialProof} from "@bringid/contracts/interfaces/Types.sol";
contract MyAirdrop is BringIDGated {
uint256 constant MIN_SCORE = 100; // required reputation threshold
constructor(address registry_, uint256 appId_)
BringIDGated(registry_, appId_)
{}
// proofs_ are generated off-chain via BringID SDK.
// Each proof is a zero-knowledge attestation of a verified credential
// (e.g. GitHub account, Farcaster profile, KYC) — proving ownership
// without revealing the underlying identity.
function claim(
address recipient_,
CredentialProof[] calldata proofs_
) external {
// Validates proofs, prevents reuse, aggregates score from registry
uint256 bringIDScore = _submitProofsForRecipient(recipient_, proofs_);
// Enforce a minimum score
if (bringIDScore < MIN_SCORE) revert InsufficientScore();
// Transfer tokens
...
}
}When generating proofs for a message-binding-aware contract, set the message to keccak256(abi.encodePacked(recipientAddress)):
import { generateProof } from "@semaphore-protocol/core";
import { ethers } from "ethers";
const recipient = "0x1234...";
const message = ethers.solidityPackedKeccak256(["address"], [recipient]);
const proof = await generateProof(identity, group, message, scope);See docs/proof-message-binding.md for a full explanation of scope vs. message, why putting the recipient in context breaks sybil resistance, and patterns for custom message semantics. See contracts/examples/SimpleAirdrop.sol for a complete example. For custom message semantics beyond simple recipient binding, compute your own expected message and validate manually.
This project uses yarn to install dependencies since soldeer doesn't resolve them correctly.
yarn$ forge build$ forge test$ forge snapshot$ forge --help
$ anvil --help
$ cast --help