Skip to content

0xFacet/megabirds

Repository files navigation

Megabirds ZK Twitter Mint

ZK-gated registration for Megabirds. One entry per Twitter account, no on-chain identity linkage.

How It Works

  1. 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).
  2. TLSNotary — Browser runs a TLSNotary session against api.x.com/2/users/me through a WebSocket relay. The PSE notary co-signs the TLS session, producing an attestation with SHA256 hash commitments over the transcript.
  3. 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.
  4. 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.
  5. 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

Security Model

Invariants (by priority)

  1. 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.
  2. 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.
  3. Nullifier uniqueness. Same account + same deployment = same nullifier. Different accounts = different nullifiers. Duplicates rejected on-chain.
  4. Recipient binding. Proof is bound to msg.sender. Cannot be replayed by another address.
  5. Privacy (best-effort). On-chain observers should not link wallets to Twitter accounts. Subordinate to 1–4.

Disallowed assumptions

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.

Trust model

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.

Accepted risks

  • Single notary. One point of trust/failure. No threshold or rotation without circuit + verifier redeploy.
  • OPRF deanon oracle. Public /evaluate-blind allows 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.

Plan gate

Before implementing any change, answer these yes/no. If any is yes, the change is blocked until resolved:

  1. Can a malicious OPRF operator (honest notary) break sybil after this change?
  2. Can a mock-server attack still produce a valid proof?
  3. Does any sybil-critical check depend on unsigned data, server-side code, or prover-supplied commitments?
  4. Does this require changes to the PSE notary? (instant reject)
  5. Does this introduce a new trust assumption not listed above?
  6. Is there a domain/version migration that needs a compatibility test?

Directory Structure

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

Prerequisites

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.

Setup

# 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:4000

Full Pipeline: Circuit Change → Redeploy

After any circuit change (new inputs, modified constraints, etc.), run these steps in order.

Step 1: Compile circuit + run tests

cd circuits/twitter-mint
nargo compile
nargo test

All tests must pass before proceeding.

Step 2: Generate a fresh TLSNotary fixture

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 vite

Get an OAuth token:

node tlsn-test/get-token.mjs

Then in the browser:

  1. Open http://localhost:5174
  2. Paste the access token
  3. Click "Run TLSNotary Test (Hash Commitments)"
  4. Wait for "NOTARIZATION SUCCESSFUL"
  5. Click "Download Result" → save as hash-commitment-result.json in project root

Step 3: Generate Prover.toml from fixture

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

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

Step 4: Execute witness + generate VK + proof

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 keccak

CRITICAL: --oracle_hash keccak must be used consistently on prove/verify. Mixing flags causes on_curve assertion failures.

Step 5: Generate Solidity verifier

bb write_solidity_verifier -k target/proof_keccak/vk -o ../../contracts/src/HonkVerifier.sol

Note: NO --oracle_hash flag on this command.

Step 6: Deploy contracts

# Primary (Megabirds + ZK verifier)
npx tsx scripts/deploy-all.ts

# Secondary (standalone SybilMint for isolated ZK testing)
bash scripts/deploy-sybil.sh

deploy-all.ts deploys BirdRenderer + art + ZKTranscriptLib + HonkVerifier + Megabirds. Updates testnet-output/deploy.json and auto-syncs frontend/src/deployments.json.

Step 7: E2E test (script)

npx tsx scripts/test-e2e-mint.ts

Requires: relay running, testnet-output/deploy.json with current addresses, PRIVATE_KEY in .env.

Step 8: Browser E2E test

# Terminal 1: relay
cd relay && npm run dev

# Terminal 2: frontend
cd frontend && npx vite

Open http://localhost:4000:

  1. Connect wallet (MetaMask / WalletConnect)
  2. Click "Mint" → redirects to Twitter OAuth
  3. Authorize → redirects back
  4. 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).

Rebuilding TLSNotary WASM

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

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

Scripts

ZK Mint Pipeline

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)

TLSNotary Test Harness

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

Fixtures

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.

E2E Testing

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

Full 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):

  1. Start relay + frontend (see Setup steps 7-8)
  2. Open http://localhost:4000
  3. Connect wallet (MegaETH testnet, chain 6343)
  4. Click "Mint" → Twitter OAuth → notarize → OPRF → prove → submit tx

Version Pins

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

Gotchas

  1. bb write_vk is broken for ECDSA circuits. Dummy witnesses cause a remainder_1024 bignum assertion failure. Use bb prove --write_vk with 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
  2. --oracle_hash keccak must be used consistently. Required on bb prove and bb verify. Must NOT be used on bb write_solidity_verifier. Mixing flags causes on_curve assertion failures.

  3. Use keccakZK: true, not keccak: true in bb.js. The generated Solidity verifier is BaseZKHonkVerifier (ZK variant). Using { keccak: true } generates non-ZK proofs that fail on-chain.

    backend.generateProof(witness, { keccakZK: true })
  4. 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' })
  5. 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 .wasm files.

  6. No COOP/COEP headers needed. With backend: 'wasm' (single-threaded), SharedArrayBuffer isn't required. COEP actually breaks things by blocking SRS download from crs.aztec.network.

  7. Dummy OPRF values break witness generation. If Prover.toml is generated without the relay running, you get dummy OPRF values and nargo execute fails with y_hint squared != rhs at target. Regenerate with relay running.

  8. Stale WASM after rebuilding tlsn-fork. After rebuilding TLSNotary WASM, you must copy the output to both frontend/public/tlsn/ and tlsn-test/tlsn/. Without this, you get expected magic word 00 61 73 6d (Vite serves stale HTML instead of the .wasm binary).

  9. Noir beta.16 removed keccak256 and sha256 from 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" }
  10. Proof verifies in bb but reverts on-chain? The HonkVerifier.sol is stale. Regenerate from the current VK (bb write_solidity_verifier) and redeploy.

  11. @noble/curves v2 test vectors fail in Noir ECDSA. Use Noir's upstream test vectors from their repo instead.

  12. After redeploying contracts, restart the Vite dev server. Vite caches the old contract addresses from deployments.json.

About

No description, website, or topics provided.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors