Status: Draft · Canonical circuit version: V2 (stealth_reputation, depth 20) · License: CC0-1.0
Reference implementation: opaquecash/solana (schema-registry, attestation-engine-v2, reputation-verifier, groth16-verifier). Both chains run the full V2 stack: Ethereum (OpaqueSchemaRegistry, OpaqueAttestationRegistry, OpaqueReputationVerifierV2, Groth16VerifierV2) and Solana (schema-registry, attestation-engine-v2, reputation-verifier, groth16-verifier, all V2 as of 2026-06-10).
PSR is Opaque's privacy-preserving reputation layer: a stealth identity can hold schema-bound attestations and prove possession of one — to a contract or a service — without revealing the stealth address, the wallet behind it, or any other attestation. This document specifies the canonical V2 design, which both chains now implement (project decision D3). V1 was retired 2026-06-10; see
changelog.md.
PSR composes three pieces over the CSAP stealth-address layer (CSAP.md): a schema registry that defines attestation types and who may issue them; an attestation engine that issues schema-bound, optionally-revocable attestations against a stealth identity; and a Groth16 ZK circuit + on-chain verifier that lets the holder prove "I own a stealth address carrying a valid attestation under schema X, for action Y" while revealing nothing else, with a nullifier that prevents reusing the proof for the same action. The scheme uses the BN254 curve and Poseidon hashing so the identical circuit verifies on Solana (via alt_bn128 syscalls) and Ethereum (via the ecPairing precompile, EIP-197).
Reputation systems force a choice between public transparency (verifiable, not private) and private identity (private, not composable). PSR provides both: a verifier learns only "this party holds trait X for action Y," not the stealth address, the funding wallet, the transaction graph, or any unrelated trait. No comparable open primitive exists on either Ethereum or Solana — it is Opaque's primary differentiator and the basis for compliance-preserving privacy (a KYC provider can issue an attestation that a user later proves to a gate without doxxing).
V2 exists because V1 had two defects this spec fixes:
- Open issuance. In V1 any wallet could attach any trait to any announcement — no notion of an authorized issuer. V2 introduces schemas with an authority and delegates.
- A circuit
is_validoutput. V1 exposed a validity bit as a public output. A sound circuit must be unsatisfiable for an invalid witness, not emit a self-asserted validity flag. V2 removes it.
Key words per RFC 2119. The PSR layer assumes CSAP key material and stealth identities.
| Term | Definition |
|---|---|
| Authority | The wallet that registers a schema and permanently controls it. |
| Delegate | A pubkey the authority authorizes to issue under a schema (≤ 10 per schema). |
| Issuer | Authority or a delegate; the only parties that may attest. |
schema_id |
SHA-256(authority_pubkey ‖ name_utf8 ‖ [version]), 32 bytes. version is currently 1. |
attestation uid |
SHA-256(schema_id ‖ issuer_pubkey ‖ stealth_address_hash ‖ slot_le), 32 bytes. |
stealth_address_hash |
A 32-byte hash binding an attestation to a CSAP stealth identity without storing the address. |
external_nullifier |
A caller-chosen domain separator scoping a proof to one action context (e.g. "vote #42"). |
nullifier_hash |
Poseidon(stealth_pk, external_nullifier) — consumed once on-chain per action. |
A schema declares an attestation type and its issuance rules.
State (SchemaPDA, seeds ["schema", authority, schema_id]): authority, optional resolver program (default = none), revocable (immutable after creation), name (≤ 64 chars), field_definitions (≤ 256 chars, ABI-style e.g. "bool passed, u64 score"), version, delegates (≤ 10), created_at, schema_expiry_slot (0 = none), deprecated.
Instructions:
register_schema(schema_id, name, field_definitions, revocable, resolver, schema_expiry_slot)— the providedschema_idMUST equalSHA-256(authority ‖ name ‖ [1])(enforced on-chain).add_delegate(delegate)/remove_delegate(delegate)— authority only; ≤ 10 delegates.update_resolver(new_resolver)— authority only;defaultremoves it.deprecate_schema()— authority only, irreversible; blocks new attestations, leaves existing ones valid.
Predicates: is_authorized_issuer(p) = (p == authority) ∨ (p ∈ delegates); is_active(slot) = ¬deprecated ∧ (schema_expiry_slot == 0 ∨ slot < schema_expiry_slot).
Instruction attest(stealth_address_hash, data, expiration_slot, ref_uid) (issuer signs):
- REQUIRE
data.len ≤ 512. - REQUIRE
schema.is_authorized_issuer(issuer)— the core V2 invariant. - REQUIRE
schema.is_active(current_slot). uid = SHA-256(schema_id ‖ issuer ‖ stealth_address_hash ‖ slot_le).- Write
AttestationPDAat seeds["attestation_v2", schema_id, issuer, stealth_address_hash]with:uid,schema_pda,schema_id,issuer,stealth_address_hash,data(ABI-encoded perfield_definitions),created_at,expiration_slot(0 = none),revocation_slot = 0,ref_uid(chained-credential pointer; 0 = none). - If
schema.resolver ≠ default: CPI into the resolver'son_attesthook (discriminator0x01, thenschema_id ‖ issuer ‖ stealth_address_hash ‖ uid ‖ len(data) ‖ data); any resolver error reverts issuance. This is the programmable gate (payments, allowlists, NFT ownership, …). - Emit
AttestationCreated.
Instruction revoke(attestation_uid): authority only (not delegates); REQUIRE schema.revocable; sets revocation_slot = current_slot (data is preserved for auditability, not deleted); fires the resolver on_revoke hook (discriminator 0x02) if configured; emits AttestationRevoked.
Validity: is_valid(slot) = (revocation_slot == 0) ∧ (expiration_slot == 0 ∨ slot < expiration_slot).
The resolver interface uses fixed 8-byte discriminators so any program can implement it with no IDL dependency.
Curve BN254, hash Poseidon. Tree depth 20 (~1M leaves).
Private inputs: stealth_pk (BN254 field element of the stealth private scalar), schema_id (the 32-byte id packed into a field element), issuer_pk_x (the issuer/authority BabyJubJub x-coordinate), trait_data_hash (Poseidon of the ABI-encoded attestation data), nonce (random, prevents leaf enumeration), merkle_path[20], merkle_path_indices[20].
Public inputs (in this order): merkle_root, attestation_id, external_nullifier, nullifier_hash.
Field encoding (normative). Every public signal is a BN254 scalar-field element and MUST be reduced into
[0, r)before it is used as a proof input or submitted on-chain — the witness signal, the on-chain public input, and any Poseidon preimage over it MUST be the identical reduced value. In particular a scope-derivedexternal_nullifier = keccak256(scope) mod rand aattestation_idderived from a SHA-256schema_idare reduced at a single source of truth; passing the un-reduced 256-bit value makes the snarkjs-generated verifier'scheckFieldreject it. Both chains carryattestation_id/external_nullifieras full 32-byte big-endian field elements (Ethereumuint256, Solana[u8; 32]), so any canonical SHA-256/Keccak-derived id is representable.
Constraints:
leaf = Poseidon(stealth_pk, schema_id, issuer_pk_x, trait_data_hash, nonce).- Merkle inclusion: hashing
leafupmerkle_pathwithmerkle_path_indices(each constrained binary) yieldsmerkle_root. - Schema binding:
schema_id == attestation_id(so a leaf built for schema A cannot satisfy a request for schema B). - Nullifier binding:
Poseidon(stealth_pk, external_nullifier) == nullifier_hash.
Keeping issuer_pk_x, trait_data_hash, and nonce private prevents an observer from enumerating leaves or learning who attested to whom; the proof reveals only the schema (attestation_id), the action scope (external_nullifier), and the one-time nullifier_hash.
V2 vs the retired V1 (historical). V1's circuit committed a leaf of
Poseidon(address_commitment, attestation_id)and exposed five public signals withnullifierandis_validas outputs. V2 has four public signals withnullifier_hashas a public input and nois_valid. The layouts are not interchangeable; no deployed verifier accepts V1 proofs.
Solana — reputation-verifier (program BSnkCDoTpgNVN5BbF3aN5L5EJPiaYUkqqj9MHp8kaqWM):
initialize()→VerifierConfig(admin,groth16_verifierprogram id) at seeds["verifier_config"].update_merkle_root(root)— admin only. WritesMerkleRootEntry(root,timestamp, seeds["merkle_root", root]) and appends toRootHistory(lastMAX_ROOT_HISTORY = 100).verify_reputation(proof_a[64], proof_b[128], proof_c[64], root, attestation_id: [u8; 32], external_nullifier: [u8; 32], nullifier_hash)— REQUIRE the root entry is ≤ROOT_EXPIRY_SECS = 3600old; CPI intogroth16_verifier::verify_proof_v2(A, B, C, {merkle_root, attestation_id, external_nullifier, nullifier_hash}); on success initialize aNullifierEntryat seeds["nullifier", nullifier_hash](theinitfails if it already exists → one-time use); emitReputationVerified.attestation_id/external_nullifierare 32-byte big-endian field elements (they wereu64, which capped the domain at 2^64 — OPQ-008).initializeis gated to the program's upgrade authority (OPQ-007).verify_reputation_view(...)— same check without consuming the nullifier (returnsbool).transfer_admin(new_admin)—defaultrenounces.
Solana — groth16-verifier (program 6mFaKyp7F4NqNeoiBLEWSqy5wJSk7rWf1EYumVXgHvhQ): verify_proof_v2 checks a Groth16 proof (A[64], B[128], C[64]) against the four 32-byte big-endian V2 public signals and the hard-coded production verification key using Solana's alt_bn128 (BN254) syscalls; returns validity. The key is pinned by the committed proof fixture in circuits/test/fixtures/v2/.
Ethereum — OpaqueReputationVerifierV2.sol + Groth16VerifierV2.sol: perform the V2 check against the four V2 public signals via the ecPairing precompile (0x08, EIP-197) and ecAdd/ecMul (0x06/0x07, EIP-196), with an equivalent root-history + nullifier registry. Schemas and attestations live in OpaqueSchemaRegistry.sol / OpaqueAttestationRegistry.sol (block numbers stand in for slots). An optional admin-set schema-registry binding requires the proof's attestation_id to reference a live registered schema before the pairing (OPQ-006), so a proof cannot assert reputation under a never-registered schema; binding the issuer (that an authorized key attested) additionally requires committing the schema authority in-circuit and is the tracked full remediation.
✅ Solana's on-chain V2 upgrade is complete (2026-06-10). The devnet
groth16-verifiercarries the real V2 verifying key (pinned by the committed proof fixture incircuits/test/fixtures/v2/; its earlier swapped BN254 field constants are fixed), andreputation-verifier::verify_reputationis on the V2 layout: it CPIsverify_proof_v2with the four signals[merkle_root, attestation_id, external_nullifier, nullifier_hash], takingnullifier_hashas an instruction input. A real V2 proof has been verified on devnet through the full path (root registration → verification → nullifier consumption → replay rejection). Ethereum completed the same upgrade earlier (OpaqueReputationVerifierV2+Groth16VerifierV2). The TypeScript prover (@opaquecash/psr-prover) builds V2 witnesses and a freshly client-generated proof has been verified on devnet through the SDK path, closing the loop. V1 is fully retired.
Revocation and the proof path (OPQ-018). The ZK leaf commits no revocation/expiry field and the verifier does not read the attestation's
revocation_slot/expiration, so a leaf already inside a registered root keeps proving valid after the credential is revoked or expires — bounded by the ~1 hROOT_EXPIRY, though a stale root can be re-registered. Binding a revocation-aware commitment into the leaf is the circuit-level fix, gated on a new trusted-setup ceremony. Until then the indexer MUST rebuild and re-register the Merkle root excluding revoked/expired leaves on every revocation event, so a fresh root never contains a dead credential.
nullifier_hash = Poseidon(stealth_pk, external_nullifier) is deterministic per (stealth identity, action). The on-chain NullifierEntry PDA keyed by the nullifier makes a second proof for the same action fail (the account already exists). Choosing a fresh external_nullifier per action scope (vote id, campaign id, loan id) is how an application controls anti-replay granularity.
Public: the schema being proven (attestation_id), the external_nullifier action scope, the nullifier_hash, proof success/failure, and the verifier event trail. Also public, by storage: a schema's name/field_definitions and each attestation's data and issuer live in readable on-chain accounts. Private: the stealth private key, the stealth address, the wallet behind it, the transaction graph, and any unrelated attestation.
Privacy comes from stealth-address unlinkability + the ZK proof, not from hiding attestation contents. Issuers that place sensitive values in
dataMUST encrypt or hash them; onlystealth_address_hash(never the address) is stored, butdatais otherwise plaintext on-chain.
- Schemas with authority/delegates close V1's open-issuance gap, which otherwise lets anyone mint arbitrary reputation (spam + Sybil). The model deliberately mirrors the Ethereum Attestation Service vocabulary (schema → attestation → resolver) so the mental model and tooling transfer, while adding stealth + ZK privacy that EAS lacks.
- BN254 + Poseidon so one circuit verifies on Solana (
alt_bn128syscalls) and Ethereum (ecPairing), and so Merkle hashing is cheap in-circuit. nullifier_hashas a public input (V2) lets the program handle the value directly while the circuit's constraintPoseidon(stealth_pk, external_nullifier) == nullifier_hashguarantees the proof actually binds it.- Resolver CPI hooks make issuance programmable (payment-gated KYC, allowlists) without changing the engine.
stealth_address_hash, not the address binds an attestation to a stealth identity without publishing it.
PSR is an Opaque-original primitive, not an existing EIP/SIMD. It builds on CSAP (so it inherits scheme id 1 and the stealth identity model) and is conceptually compatible with EAS-style schema/attestation/resolver tooling. Cross-chain: schema_id, uid, and the ZK statement are hash- and curve-defined and therefore chain-portable; both chains run the same V2 stack against the same trusted setup, so reputation proofs are verifier-compatible across chains (modulo each chain's own Merkle roots).
Provide, in opaquecash/circuits/test/, at least:
- Schema id: fixed
authority,name,version=1→ expectedSHA-256schema_id. - Attestation uid: fixed
schema_id,issuer,stealth_address_hash,slot→ expecteduid. - Circuit: a full witness (private inputs + path) and the four public signals, with a proof that verifies, plus negative cases — wrong
attestation_id(schema-binding fails), tamperedmerkle_root(inclusion fails), and a second proof reusing the samenullifier_hash(on-chain rejection). Outputs markedGENERATEare produced by running the reference circuit + verifier and cross-validated (Task 0.5).
- Verifying-key/circuit binding. The on-chain verification keys are pinned to the production trusted setup by a committed real-proof fixture exercised in CI on three implementations (snarkjs, EVM, Solana); any drift fails the build. Never verify proofs against a different setup's key.
- Admin-controlled roots.
update_merkle_rootis admin-only and roots expire after 1 hour, so root publication is a liveness/censorship dependency: a faulty admin can withhold or censor roots but cannot forge a proof or otherwise affect integrity. Mitigation: holdadminin a multisig (Squads / Gnosis Safe); the roadmap moves root computation to a permissionless/verifiable process. Proofs MUST be pinned to their generation root and resubmitted against that root on retry. This is the canonical statement of the admin-root and keeper root-publication liveness dependency; other documents cross-reference it. - On-chain
datais public. See §7 — privacy is from unlinkability + ZK, not from the attestation payload. Encrypt sensitive fields. - Issuer trust. A schema's reputation is only as good as its authority/delegates; a compromised delegate can mint attestations under that schema until removed. Keep delegate sets small; prefer revocable schemas for sensitive credentials.
- Nullifier scope. Reusing an
external_nullifieracross contexts links those actions for one identity; using a fresh scope per action preserves unlinkability. - Soundness. Relies on the Groth16 trusted setup (a published ceremony, e.g. Hermez Powers of Tau) and on circuit correctness. Mainnet is gated on a production multi-party ceremony and a ZK-specific audit (Veridise / zkSecurity / Hexens).
- Field encoding.
attestation_id/external_nullifiercross the program boundary asu64(u64_to_be32, big-endian into 32 bytes) but are field elements in-circuit; provers MUST pack them identically when building witnesses, or proofs silently fail to bind.
Copyright and related rights waived via CC0-1.0.
| Chain | Program / Contract | Address / Program ID | Version |
|---|---|---|---|
| Solana Devnet | schema_registry |
FbgMJYGWnLKLcrKYS1NxM5uER1ihQkYLMTLs4STuDMWB |
V2 |
| Solana Devnet | attestation_engine_v2 |
4T9kPCVCFGdEuLpEqRJihsPCbEEo2LWWDEPFvUESEqtM |
V2 |
| Solana Devnet | reputation_verifier |
BSnkCDoTpgNVN5BbF3aN5L5EJPiaYUkqqj9MHp8kaqWM |
V2 |
| Solana Devnet | groth16_verifier |
6mFaKyp7F4NqNeoiBLEWSqy5wJSk7rWf1EYumVXgHvhQ |
V2 vkey |
| Ethereum Sepolia | OpaqueSchemaRegistry |
0xAA5F3942117bD48E7Cd81A500A8b7Bbb122ae80f |
V2 |
| Ethereum Sepolia | OpaqueAttestationRegistry |
0x049aF9CBB62387034CDd5403794a94E9c000ACCc |
V2 |
| Ethereum Sepolia | OpaqueReputationVerifierV2 |
0x18cEc2812953c2E9bcADE20CbF6415BD36aEb44f |
V2 |
| Ethereum Sepolia | Groth16VerifierV2 |
0x49A212bdbc52F1cb6C93623FC7814a61Fc71ddB5 |
V2 vkey |