ZK-gated registration for Megabirds. One entry per Twitter account, no on-chain identity linkage.
- OAuth — User authenticates with Twitter via OAuth 2.0 PKCE. Token exchange goes through the relay server (Twitter requires a client secret for confidential apps).
- TLSNotary — Browser runs a TLSNotary session against
api.x.com/2/users/methrough a WebSocket relay. The PSE notary co-signs the TLS session, producing an attestation with SHA256 hash commitments over the transcript. - Blind OPRF — Browser hashes the user ID to a Grumpkin curve point (try-and-increment), blinds it with a random scalar, and sends only the blinded point to the relay's OPRF endpoint. Server evaluates
T' = sk * H', returns the result with a DLEQ proof. Browser unblinds to get the OPRF output. The server never sees the user ID. - ZK Proof — Browser generates an UltraHonk proof (~10s, WASM) that verifies: notary ECDSA signature, merkle binding, hash commitment openings, HTTP endpoint pinning, hash-to-curve, DLEQ on blinded points, and nullifier/binding-tag derivation.
- On-chain — Browser submits proof to
Megabirds.register(). Contract recomputes binding tag, verifies the ZK proof via HonkVerifier, checks nullifier uniqueness, and registers the caller.
Browser Relay Chain (MegaETH)
─────── ───── ───────────────
Twitter OAuth ──┐
│
TLSNotary ──────┤
(via WS relay)│
▼
attestation
+ secrets
│
blind(H(uid)) ──────────► /evaluate-blind ────► T' = sk * H'
+ DLEQ proof
◄────────── T' + proof
unblind T │
│
build witness │
generate proof│
│
register(proof) ─────────────────────────────► Megabirds.sol
- verify ZK proof
- check nullifier
- register caller
- Sybil resistance. One mint per Twitter account. Requires BOTH the PSE notary AND the OPRF operator to be dishonest to break. All sybil enforcement is in-circuit or on-chain — server-side checks don't count.
- Server identity. A valid proof requires a TLS session with
api.x.com, verified via notary-signed data. Unsigned fields (secrets blob, serverDns) are never used for sybil-critical checks. - Nullifier uniqueness. Same account + same deployment = same nullifier. Different accounts = different nullifiers. Duplicates rejected on-chain.
- Recipient binding. Proof is bound to
msg.sender. Cannot be replayed by another address. - Privacy (best-effort). On-chain observers should not link wallets to Twitter accounts. Subordinate to 1–4.
These look reasonable but are wrong. They caused real design errors:
- "OPRF honesty protects sybil." The operator controls the server. Server-side checks are bypassable.
- "Unsigned secrets fields can be trusted." serverDns, cert chain in the secrets blob — none are notary-signed.
- "Rate limits / CORS / auth are sufficient sybil controls." Operational, not cryptographic. Don't replace in-circuit enforcement.
- "The notary verifies server identity." PSE notary is general-purpose. It does not restrict which servers the prover connects to.
- "We can modify the notary." The PSE notary is an external dependency run by EF. Solutions must work with the notary as it ships.
| Entity | Trusts | To do |
|---|---|---|
| User | PSE notary | Honestly co-sign TLS session |
| User | Relay / OPRF server | Not leak secret key (would enable nullifier precomputation for guessed IDs) |
| User | WS relay | Not tamper with TCP stream (TLS prevents reading) |
| User | Frontend JS | Not exfiltrate private data (open source, not audited in real-time) |
| Contract | ZK verifier | Proof system is sound |
| Contract | OPRF operator | Key not leaked (would enable nullifier precomputation) |
| On-chain observer | — | Cannot link wallet to Twitter account (CDH hardness on Grumpkin) |
The OPRF operator is the main trust point. They cannot forge proofs or steal funds. With blind OPRF, the server never sees user IDs — but the sk holder could offline-compute OPRF outputs for guessed IDs and match against on-chain nullifiers. Once mint closes and sk is deleted, nullifiers become permanently unlinkable.
- Single notary. One point of trust/failure. No threshold or rotation without circuit + verifier redeploy.
- OPRF deanon oracle. Public
/evaluate-blindallows targeted deanonymization. Degrades privacy, not sybil. Mitigated by rate limiting. - Twitter API changes. Hardcoded request/response format. API changes break the circuit.
- Hash-to-curve liveness. ~1 in 1M accounts can't map to a curve point. No fallback.
- Attestation staleness. No freshness check. Previous account owner can mint with old attestation.
Before implementing any change, answer these yes/no. If any is yes, the change is blocked until resolved:
- Can a malicious OPRF operator (honest notary) break sybil after this change?
- Can a mock-server attack still produce a valid proof?
- Does any sybil-critical check depend on unsigned data, server-side code, or prover-supplied commitments?
- Does this require changes to the PSE notary? (instant reject)
- Does this introduce a new trust assumption not listed above?
- Is there a domain/version migration that needs a compatibility test?
circuits/twitter-mint/ Noir circuit (38 tests pass, proof gen works)
src/main.nr Entry — full verification chain
src/dleq.nr Try-and-increment h2c + Grumpkin DLEQ
src/merkle.nr BLAKE3 merkle proof
src/nullifier.nr Poseidon2 nullifier + keccak256 binding tag
src/endpoint.nr HTTP endpoint pinning + userId extraction
src/hash_commit.nr SHA256 hash commitment verification
src/tlsn.nr Notary ECDSA secp256k1 sig verification
contracts/src/
Megabirds.sol Game contract with integrated ZK register()
SybilMint.sol Standalone ZK-gated entry (isolated testing)
HonkVerifier.sol Generated Solidity verifier (ZK variant, library-linked)
frontend/src/
zk-mint.ts State machine: idle → oauth → notarize → oprf → prove → mint
oprf.ts Blind OPRF client (blinds locally, unblinds result)
adapter.ts Re-exports from tlsn-adapter
tlsn-notarize.ts TLSNotary WASM integration (hash commitment fork)
twitter-oauth.ts OAuth 2.0 PKCE flow
relay/
src/index.ts Node.js server: WS→TCP relay + blind OPRF + token exchange
DEPLOY.md GCR deployment instructions
tlsn-vendor/
adapter/ Attestation parser + witness builder (80+ tests)
tlsn-fork/ Patched TLSNotary v0.1.0-alpha.12 (hash commitments)
scripts/
deploy-all.ts Full Megabirds deploy (renderer + art + ZK verifier + game)
deploy-sybil.sh Deploy standalone SybilMint
test-e2e-mint.ts Full e2e: fixture → blind OPRF → prove → on-chain register
| Dependency | Version | Notes |
|---|---|---|
| nargo | 1.0.0-beta.16 | Must match exactly — circuit won't compile on other versions |
| bb (Barretenberg) | 3.0.0-nightly.20251104 | Must match exactly — proof format is version-specific |
| Foundry / forge | latest | Solidity compilation and testing |
| Node.js | 22+ | Required for WASM and ESM support |
| pnpm | latest | Package management |
| solc | 0.8.28 | Set in contracts/foundry.toml |
Version pins are critical. Noir and Barretenberg are pre-1.0 and break between releases. The circuit, prover, and on-chain verifier must all use the exact same versions.
# 1. Install circuit toolchain
noirup -v 1.0.0-beta.16
bbup -v 3.0.0-nightly.20251104
# 2. Compile circuit + run tests
cd circuits/twitter-mint
nargo compile && nargo test
cd ../..
# 3. Build contracts
cd contracts && forge build && forge test && cd ..
# 4. Install JS deps
cd relay && npm install && cd ..
cd tlsn-vendor/adapter && npm install && cd ../..
cd frontend && pnpm install && cd ..
# 5. Configure env files
# relay/.env needs: OPRF_SECRET_KEY, TWITTER_CLIENT_ID, TWITTER_CLIENT_SECRET
# .env needs: PRIVATE_KEY (deployer wallet)
# 6. Deploy contracts (testnet)
npx tsx scripts/deploy-all.ts
# 7. Start relay (WS relay + OPRF + token exchange)
cd relay && npm run dev
# 8. Start frontend (separate terminal)
cd frontend && npx vite
# Open http://localhost:4000After any circuit change (new inputs, modified constraints, etc.), run these steps in order.
cd circuits/twitter-mint
nargo compile
nargo testAll tests must pass before proceeding.
Skip this if you already have a valid fixture (one captured with the current WASM build).
Two terminals:
# Terminal 1: relay (combined WS relay + OPRF server)
cd relay && npm run dev
# Terminal 2: test page
cd tlsn-test && npx viteGet an OAuth token:
node tlsn-test/get-token.mjsThen in the browser:
- Open http://localhost:5174
- Paste the access token
- Click "Run TLSNotary Test (Hash Commitments)"
- Wait for "NOTARIZATION SUCCESSFUL"
- Click "Download Result" → save as
hash-commitment-result.jsonin project root
Requires relay running (it serves OPRF at /evaluate-blind):
npx tsx tlsn-vendor/adapter/scripts/gen-prover-toml.ts \
hash-commitment-result.json \
circuits/twitter-mint/Prover.tomlShould print: userId, htc counter, body_start, id_start, id_len, sent_len, recv_len.
Without the OPRF server running, the Prover.toml gets dummy OPRF values and nargo execute will fail with y_hint squared != rhs at target.
cd circuits/twitter-mint
# Solve witness (produces target/zk_twitter_mint.gz)
nargo execute
# Generate proof + VK (bb write_vk alone is broken for ECDSA circuits)
bb prove -b target/zk_twitter_mint.json -w target/zk_twitter_mint.gz \
-o target/proof_keccak --write_vk -k target/vk_keccak --oracle_hash keccakCRITICAL: --oracle_hash keccak must be used consistently on prove/verify. Mixing flags causes on_curve assertion failures.
bb write_solidity_verifier -k target/proof_keccak/vk -o ../../contracts/src/HonkVerifier.solNote: NO --oracle_hash flag on this command.
# Primary (Megabirds + ZK verifier)
npx tsx scripts/deploy-all.ts
# Secondary (standalone SybilMint for isolated ZK testing)
bash scripts/deploy-sybil.shdeploy-all.ts deploys BirdRenderer + art + ZKTranscriptLib + HonkVerifier + Megabirds. Updates testnet-output/deploy.json and auto-syncs frontend/src/deployments.json.
npx tsx scripts/test-e2e-mint.tsRequires: relay running, testnet-output/deploy.json with current addresses, PRIVATE_KEY in .env.
# Terminal 1: relay
cd relay && npm run dev
# Terminal 2: frontend
cd frontend && npx viteOpen http://localhost:4000:
- Connect wallet (MetaMask / WalletConnect)
- Click "Mint" → redirects to Twitter OAuth
- Authorize → redirects back
- App runs: notarize → OPRF → prove → submit register tx
Requirements: frontend/src/deployments.json has correct Megabirds address, relay running (WS on port 55688 + OPRF on port 3001), wallet on MegaETH testnet (chain 6343) with ETH for gas.
After redeploying contracts, restart the Vite dev server (caches old addresses).
The patched TLSNotary source is vendored at tlsn-vendor/tlsn-fork/ (v0.1.0-alpha.12 with hash commitment mode enabled). The pre-built WASM output is already in frontend/public/tlsn/. You only need to rebuild if you change the fork.
cd tlsn-vendor/tlsn-fork/crates/wasm
RUSTC_WRAPPER="" \
CC_wasm32_unknown_unknown="/opt/homebrew/opt/llvm/bin/clang" \
AR_wasm32_unknown_unknown="/opt/homebrew/opt/llvm/bin/llvm-ar" \
bash build.shRequires: nightly Rust, wasm32-unknown-unknown target, rust-src component, wasm-pack, Homebrew LLVM (Xcode clang lacks wasm32 backend).
Then copy to frontend + test harness:
# Frontend
cp tlsn-vendor/tlsn-fork/crates/wasm/pkg/tlsn_wasm.js frontend/public/tlsn/
cp tlsn-vendor/tlsn-fork/crates/wasm/pkg/tlsn_wasm_bg.wasm frontend/public/tlsn/
cp -R tlsn-vendor/tlsn-fork/crates/wasm/pkg/snippets/ frontend/public/tlsn/snippets/
# Test harness
cp frontend/public/tlsn/tlsn_wasm.js frontend/public/tlsn/tlsn_wasm_bg.wasm tlsn-test/tlsn/
cp -R frontend/public/tlsn/snippets tlsn-test/tlsn/After rebuilding WASM, you need a fresh fixture (pipeline Step 2) since the old one was captured with different cipher suites.
| Script | What it does |
|---|---|
tlsn-vendor/adapter/scripts/gen-prover-toml.ts |
Fixture + OPRF → Prover.toml for nargo |
scripts/test-e2e-mint.ts |
Full e2e: fixture → blind OPRF → prove → on-chain register |
scripts/deploy-all.ts |
Full Megabirds deploy (renderer + art + ZK verifier + game) |
scripts/deploy-sybil.sh |
Deploy standalone SybilMint |
scripts/test-constants.ts |
Cross-check APP_TAG, OPRF domain, notary pubkey across JS/Solidity/circuit |
scripts/gen-witness.ts |
Fixture → .gz witness for bb CLI |
scripts/gen-vk-and-verifier.ts |
Generate VK + Solidity verifier via bb.js WASM (workaround for bb CLI bug) |
| Script | What it does |
|---|---|
tlsn-test/get-token.mjs |
OAuth token helper (CLI) |
tlsn-test/relay.mjs |
WS→TCP relay (local, for browser notarization) |
tlsn-test/parse-attestation.mjs |
Parse and dump attestation blob |
tlsn-test/verify-hash-commitment.mjs |
Verify hash commitments from fixture |
Fixture files (hash-commitment-result.json) are gitignored — they're generated locally via pipeline Step 2. test-e2e-mint.ts loads from tlsn-test/hash-commitment-result.json by default. Override with FIXTURE=path/to/fixture.json.
Script mode (uses fixture data, no browser needed):
# Requires relay running (serves OPRF)
cd relay && npm run dev
# In another terminal:
npx tsx scripts/test-e2e-mint.tsFull pipeline: fixture → blind OPRF → build witness → prove → on-chain register().
Override fixture path: FIXTURE=path/to/fixture.json npx tsx scripts/test-e2e-mint.ts
Browser mode (full user flow):
- Start relay + frontend (see Setup steps 7-8)
- Open http://localhost:4000
- Connect wallet (MegaETH testnet, chain 6343)
- Click "Mint" → Twitter OAuth → notarize → OPRF → prove → submit tx
| Component | Version |
|---|---|
| Noir (nargo) | 1.0.0-beta.16 |
| Barretenberg (bb) | 3.0.0-nightly.20251104 |
| @noir-lang/noir_js | 1.0.0-beta.16 |
| @aztec/bb.js | 3.0.0-nightly.20251104 |
| TLSNotary | v0.1.0-alpha.12 |
| tlsn-js | 0.1.0-alpha.12.0 (vendored fork, hash commitment patch) |
| solc | 0.8.28 |
| Solady | ERC721 (matches Megabirds convention) |
| @noble/curves | ^2.0.0 |
-
bb write_vkis broken for ECDSA circuits. Dummy witnesses cause aremainder_1024bignum assertion failure. Usebb prove --write_vkwith a real witness instead:nargo execute bb prove -b target/zk_twitter_mint.json -w target/zk_twitter_mint.gz \ -o target/proof_keccak --write_vk -k target/vk_keccak --oracle_hash keccak
-
--oracle_hash keccakmust be used consistently. Required onbb proveandbb verify. Must NOT be used onbb write_solidity_verifier. Mixing flags causeson_curveassertion failures. -
Use
keccakZK: true, notkeccak: truein bb.js. The generated Solidity verifier isBaseZKHonkVerifier(ZK variant). Using{ keccak: true }generates non-ZK proofs that fail on-chain.backend.generateProof(witness, { keccakZK: true })
-
WASM backend must be
'wasm', not'wasm-worker'. The worker backend silently hangs trying to init WASM. Direct WASM works at the same speed.new UltraHonkBackend(circuit, { backend: 'wasm' })
-
Vite optimizer breaks Noir WASM. Exclude these from
optimizeDeps:optimizeDeps: { exclude: ['@noir-lang/noir_js', '@noir-lang/noirc_abi', '@noir-lang/acvm_js'] }
Without this, Vite serves HTML 404 pages instead of
.wasmfiles. -
No COOP/COEP headers needed. With
backend: 'wasm'(single-threaded), SharedArrayBuffer isn't required. COEP actually breaks things by blocking SRS download fromcrs.aztec.network. -
Dummy OPRF values break witness generation. If Prover.toml is generated without the relay running, you get dummy OPRF values and
nargo executefails withy_hint squared != rhs at target. Regenerate with relay running. -
Stale WASM after rebuilding tlsn-fork. After rebuilding TLSNotary WASM, you must copy the output to both
frontend/public/tlsn/andtlsn-test/tlsn/. Without this, you getexpected magic word 00 61 73 6d(Vite serves stale HTML instead of the.wasmbinary). -
Noir beta.16 removed
keccak256andsha256from stdlib. They're now external packages:keccak256 = { tag = "v0.1.3", git = "https://github.com/noir-lang/keccak256" } sha256 = { tag = "v0.3.0", git = "https://github.com/noir-lang/sha256" }
-
Proof verifies in
bbbut reverts on-chain? The HonkVerifier.sol is stale. Regenerate from the current VK (bb write_solidity_verifier) and redeploy. -
@noble/curvesv2 test vectors fail in Noir ECDSA. Use Noir's upstream test vectors from their repo instead. -
After redeploying contracts, restart the Vite dev server. Vite caches the old contract addresses from
deployments.json.