A standard interface for on-chain verification of regulatory compliance (anti-money laundering, sanctions screening, anti-structuring) using zero-knowledge proofs. Users generate proofs client-side that attest to compliance with jurisdiction-specific thresholds without revealing transaction amounts, counterparty identities, or screening details. Verifiers confirm proof validity on-chain. No trusted third party or trusted execution environment (TEE) is required.
Motivation
Public blockchains force a binary choice between transparency and privacy. Transparent execution exposes trades to billions in cumulative MEV extraction. Privacy tools have been sanctioned for lacking compliance mechanisms.
Existing approaches to compliant privacy fall short:
View keys (various): Trade privately, then reveal raw transaction data to auditors on request. This leaks the data: it is delayed transparency.
TEE-based compliance (various): Rely on hardware trust assumptions that have been broken by side-channel attacks and key extraction.
Compliance-by-exclusion (Privacy Pools): Prove you’re NOT in a bad set. Doesn’t prove you ARE compliant with specific jurisdiction rules.
This ERC defines a standard where compliance is proven cryptographically at transaction time. The proof commits to screening results, jurisdiction thresholds, and provider attestations. Regulators verify a proof. They never see the underlying data.
Specification
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.
Terminology
providerSetHash: A commitment to the specific set of screening providers and their weights used for a particular compliance proof. Included in each attestation for retroactive verification.
providerConfigHash: A hash of the global provider weight configuration published by the oracle administrator. Versioned on-chain; weight changes push a new entry to the config history.
attestation TTL: The duration (in seconds) for which a compliance attestation remains valid after on-chain recording. Expired attestations remain queryable via getHistoricalProof() but are not considered valid by checkCompliance().
Proof System Requirements
Implementations MUST use a ZK proof system that achieves at least 128-bit security against forgery. Groth16, PLONK, and UltraHonk all meet this bar. The reference implementation uses UltraHonk, with circuits written in Noir and per-circuit verifiers generated by Barretenberg; that README lists the pinned tool versions and licenses.
Proof Types
Implementations MUST support the following proof types. Each type corresponds to a separate ZK circuit with its own verification key.
All proof types include submitter as a public input; implementations MUST enforce
submitter == msg.sender at submission time.
Attestation (0x04). The leaf in the per-provider credentials Merkle tree is leaf_hash_value(credential_hash), where credential_hash = H(DOMAIN_CREDENTIAL, provider_id, submitter, credential_type, credential_attribute, expiry_timestamp). The hash binds the credential to a specific submitter at issuance time; cross-submitter forgery is not possible without breaking Pedersen preimage resistance. credential_root references a per-provider tree registered via publishCredentialRoot; the on-chain providerId recorded against the root must match the provider_id in the proof’s public inputs.
Membership (0x05) and Non-membership (0x06). The leaf is leaf_hash_subject(value, set_id, salt). For membership, value is the submitter’s address (the leaf is computed from the public submitter input + private subject_salt). For non-membership, value is the bracketing tree entry (low_leaf / high_leaf), and the proof asserts low_leaf < submitter < high_leaf using full-width Field comparison (no u64 ceiling). Tree publishers MUST sort leaves by value; the circuit additionally requires high_index == low_index + 1 to prevent an attacker from skipping a real intermediate entry.
Pattern (0x03). The analysis_type field selects the analysis kind: 1 = anti-structuring, 2 = velocity, 3 = round-amounts. Implementations that depend on a specific analysis (e.g., a settlement registry requiring anti-structuring) MUST verify the analysis_type field; storing only the result boolean is insufficient. The settlement_root public input is opaque to the circuit (set to 0 for standalone use, or to a downstream consumer’s declarative binding value). Consumers that need to bind a pattern proof to a specific downstream state (e.g., the sub-settlements of a particular trade) MUST recompute the expected settlement_root from their own state and assert equality, and MUST mark each consumed pattern proof to prevent reuse across multiple bound contexts. The canonical computation for the sub-settlement use case is keccak256(abi.encode(uint8 subTradeCount, bytes32[] subProofHashes)) mod BN254_FR_MODULUS; the modular reduction fits the result into a BN254 scalar field element so it can be passed as a public input. Consumers SHOULD use this exact encoding. Off-by-one in field width or abi.encode byte layout produces a different root, and the proof’s equality check rejects.
Risk Score (0x02). Validators MUST reject trivially-true claims (bound_lower = 0 for direction GT, bound_lower >= MAX_RISK_SCORE_BPS for direction LT, full-domain ranges). The meetsThreshold boolean stored on the attestation reflects only the cryptographic result field; integrators querying RISK_SCORE attestations should also verify the bounds match their integration’s expectations.
Provider-signed variants (0x07 Compliance Signed, 0x08 Risk Score Signed). Identical semantics to their unsigned siblings, plus an in-circuit secp256k1 ECDSA verification of a Pedersen digest committing to (chain_id, oracle_address, provider_set_hash, signals, weights, timestamp, submitter). The provider’s pubkey commitment is exposed as signer_pubkey_hash; implementations MUST validate it against an on-chain registry. The chain_id and oracle_address public inputs MUST match block.chainid and the consuming Oracle’s address: this binds a single provider signature to one deployment so the same signed payload cannot mint attestations across chains or against alternate Oracle deployments. Strict-mode jurisdictions (see Jurisdiction Policy) reject the unsigned siblings entirely; permissive jurisdictions accept either form.
Compliance Multi-Signed (0x09). Extends the signed model to M-of-N. The circuit bundles up to five parallel signer slots; a slot is active if its public signer_pubkey_hash is non-zero. Each active slot independently verifies a secp256k1 signature over a slot-specific Pedersen digest carrying its own slot_index (under a distinct DOMAIN_MULTI_SIGNED_SIGNALS tag) and independently asserts the per-provider risk score is below the jurisdiction high-risk floor. The Oracle MUST validate each non-zero slot’s signer_pubkey_hash against the registry, MUST reject duplicate hashes across active slots, MUST enforce chain_id == block.chainid and oracle_address == address(this), and MUST enforce threshold_m >= JurisdictionConfig.minMultiProviderThreshold(jurisdictionId) (see Jurisdiction Policy for per-jurisdiction minimums). Forging an attestation under 0x09 requires compromising at least M of the N registered signing keys simultaneously.
Circuit Conventions
The following structural constants are normative. Implementations that deviate produce verifiers incompatible with other deployments and cannot share registries.
Constant
Value
Applies to
MAX_PROVIDERS
8
provider-slot count in COMPLIANCE, RISK_SCORE, signed forms
MAX_PROVIDERS_MULTI
5
signer-slot count in COMPLIANCE_MULTI_SIGNED (0x09)
MERKLE_DEPTH
20
tree depth for MEMBERSHIP, NON_MEMBERSHIP, ATTESTATION
MAX_TRANSACTIONS
16
transaction-slot count in PATTERN
MAX_WEIGHT
10000
per-provider weight ceiling (overflow guard on the score)
Value ranges. Each per-provider screening signal is a u32 in [0, 100]. Each weight is a u32 in [0, MAX_WEIGHT]. weight_sum is u32, strictly positive. num_providers is u32 in [1, MAX_PROVIDERS]. num_transactions is u32 in [1, MAX_TRANSACTIONS]. Jurisdiction IDs are u8 in [0, 4] per Jurisdiction Configuration. In COMPLIANCE_MULTI_SIGNED (0x09), per-slot signal range is not enforced in-circuit because the per-slot signature already attests to the signed values; signers MUST sign only signals in [0, 100].
Public input encoding. Every public input is a single field element of the proof system’s scalar field (a 254-bit prime field for UltraHonk over BN254). Booleans encode as field 0 (false) or 1 (true). Ethereum addresses encode as the address packed into the low 160 bits of a field element. u8, u32, and u64 values encode in the low bits with the high bits zero. Public-input arrays declared by the circuit’s main (e.g., the five signer_pubkey_hash slots in 0x09) appear as one field element per array entry, in declared order.
Active-vs-inactive slots. Circuits with a fixed slot array (MAX_PROVIDERS or MAX_TRANSACTIONS) and a runtime active count MUST enforce: for i < count, the slot carries valid data and a non-zero identifier; for i >= count, every per-slot field MUST be zero. This prevents inactive slots from contributing nonzero values to a commitment hash. In COMPLIANCE_MULTI_SIGNED, a signer slot is “active” iff its public signer_pubkey_hash is non-zero; inactive slots may carry arbitrary private witness but their constraints are gated by the active flag.
Domain-tag distinctness. The reference implementation uses eight distinct domain tags, prepended to the Pedersen hash input array: one each for internal Merkle nodes, set-bound leaves, value leaves, subject-bound leaves, credential hashes, signed payload, multi-signed slot payload, and signer pubkey commitment. Three other commitments (provider set, config, transaction set) are fixed-arity Pedersen hashes over a single context and do not carry a separate domain tag; the input layout itself is unique to each context. Implementations MAY choose different field values for the eight tags but MUST keep them pairwise distinct and distinct from any field value reachable as a circuit input.
Jurisdiction threshold lookup. Define highThreshold(jurisdictionId) to return the high-risk threshold (in basis points) from the table in Jurisdiction Configuration: EU=7100, US=6600, UK=7100, SG=7600, UAE=7100.
Commitment Layouts
All commitment hashes use the same Pedersen hash primitive over the proof system’s scalar field. Each layout below lists field positions left-to-right; “||” denotes concatenation into the input array.
H_provider_set(provider_ids[N], weights[N]) with N = MAX_PROVIDERS = 8. Input is a 16-entry array: [provider_ids[0], weights[0], provider_ids[1], weights[1], ..., provider_ids[7], weights[7]]. Inactive slots contribute (0, 0) pairs.
H_config(weights[N]) with N = MAX_PROVIDERS = 8. Input is the 8-entry weights array, with u32 weights packed into field elements.
H_tx_set(amounts[16], timestamps[16]). Input is a 32-entry array [amounts[0], timestamps[0], ..., amounts[15], timestamps[15]] with u64 values packed into field elements. Inactive slots contribute (0, 0).
H_credential(provider_id, submitter, credential_type, attribute, expiry). Input is [DOMAIN_CREDENTIAL, provider_id, submitter, credential_type, attribute, expiry] (6 fields). Binds the credential to a specific submitter at issuance time.
Merkle leaves use one of three domain-tagged layouts per Merkle Tree Domain Separation: leaf_hash_set(element, set_id) = H(DOMAIN_LEAF_SET, element, set_id); leaf_hash_value(value) = H(DOMAIN_LEAF_VALUE, value); leaf_hash_subject(value, set_id, salt) = H(DOMAIN_LEAF_SUBJECT, value, set_id, salt). Internal nodes are H(DOMAIN_INTERNAL, left, right).
H_signed(chain_id, oracle_address, provider_set_hash, signals[8], weights[8], timestamp, submitter). Used by COMPLIANCE_SIGNED (0x07) and RISK_SCORE_SIGNED (0x08). Input is a 22-entry array: [DOMAIN_SIGNED_SIGNALS, chain_id, oracle_address, provider_set_hash, signals[0..8], weights[0..8], timestamp, submitter]. The provider’s secp256k1 ECDSA signature is over the 32-byte big-endian serialization of this hash.
H_multi_slot(slot_index, chain_id, oracle_address, jurisdiction_id, provider_set_hash, config_hash, signals[8], weights[8], timestamp, submitter). Used by COMPLIANCE_MULTI_SIGNED (0x09). Input is a 25-entry array: [DOMAIN_MULTI_SIGNED_SIGNALS, slot_index, chain_id, oracle_address, jurisdiction_id, provider_set_hash, config_hash, signals[0..8], weights[0..8], timestamp, submitter]. Each active slot’s signature is over this digest with its own slot_index. The distinct domain tag prevents a 0x07 signature from satisfying a 0x09 slot.
H_signer_pubkey(pubkey_x, pubkey_y). Used to commit to a secp256k1 signing key. Each 32-byte coordinate splits into a high 16-byte half and a low 16-byte half (because the BN254 scalar field cannot represent an arbitrary 256-bit integer). Input is [DOMAIN_SIGNER_PUBKEY, x_hi, x_lo, y_hi, y_lo] (5 fields). The result is what Oracle administrators register via registerSignerPubkeyHash.
Per-Type Circuit Specifications
Each circuit’s main function declares the public and private inputs below and enforces the listed constraints. Constraint numbers are normative; their order is for readability. Cross-cutting requirements (active-slot invariants, timestamp bounds, submitter != 0) are stated in Circuit Conventions and Circuit Constraints.
COMPLIANCE (0x01)
Public inputs (6, in order): jurisdiction_id (u8), provider_set_hash, config_hash, timestamp (u64), meets_threshold (bool), submitter (uint160 packed).
Private inputs: signals[8] (u32 each in [0, 100]), weights[8] (u32 each <= MAX_WEIGHT), weight_sum (u32 > 0), provider_ids[8] (Field), num_providers (u32 in [1, 8]).
Public inputs (8, in order): proof_type (u8, 1 = threshold, 2 = range), direction (u8, 1 = GT, 2 = LT; ignored for range), bound_lower (u32 bps), bound_upper (u32 bps; 0 for threshold), result (bool), config_hash, provider_set_hash, submitter.
Private inputs: same as COMPLIANCE.
Constraints: 1-5 identical to COMPLIANCE (substituting provider_set_hash and config_hash from this circuit’s public inputs). Then:
If proof_type == 1 and direction == 1: result == (s > bound_lower).
If proof_type == 1 and direction == 2: result == (s < bound_lower).
If proof_type == 2: bound_upper >= bound_lower and result == (bound_lower <= s <= bound_upper).
Reject any other (proof_type, direction) combination.
The Oracle also rejects trivially-true claims (bound_lower == 0 for direction GT, bound_lower >= 10000 for direction LT, full-domain ranges) per Public Input Validation.
PATTERN (0x03)
Public inputs (7, in order): analysis_type (u8, 1 = anti-structuring, 2 = velocity, 3 = round-amount), result (bool, true = clean), reporting_threshold (u64), time_window (u64), tx_set_hash, submitter, settlement_root.
result == P(analysis_type, amounts, timestamps, num_transactions, reporting_threshold, time_window) for an implementation-defined deterministic predicate P that is one of three families:
analysis_type == 1 (anti-structuring): predicate over amounts and reporting_threshold.
analysis_type == 2 (velocity): predicate over timestamps, num_transactions, and time_window.
analysis_type == 3 (round-amount): predicate over amounts and num_transactions.
Reject any other analysis_type.
settlement_root is opaque: the circuit MUST NOT constrain it. Downstream consumers recompute the expected value and assert equality off-circuit.
Implementations MUST publish the exact predicate parameters they use (e.g., the structuring floor percentage, the velocity max, the round divisor) so verifiers across deployments can be compared.
ATTESTATION (0x04)
Public inputs (6, in order): provider_id, credential_type (u8 in [1, 4]; 1 = KYC basic, 4 = institutional, 2-3 reserved), is_valid (bool), credential_root, current_timestamp (u64), submitter.
Public inputs (9, in order): the 6 COMPLIANCE inputs in the same order, then signer_pubkey_hash, chain_id, oracle_address.
Private inputs: the COMPLIANCE private inputs, plus signature (64 bytes; secp256k1 ECDSA in raw r || s form), pubkey_x (32 bytes), pubkey_y (32 bytes).
Constraints: all COMPLIANCE constraints (1-7), plus:
ecdsa_secp256k1_verify(pubkey_x, pubkey_y, signature, digest_be_bytes) == true, where digest_be_bytes is the 32-byte big-endian serialization of digest.
The Oracle also validates signer_pubkey_hash is registered, chain_id == block.chainid, and oracle_address == address(this) per Public Input Validation.
RISK_SCORE_SIGNED (0x08)
Public inputs (11, in order): the 8 RISK_SCORE inputs, then signer_pubkey_hash, chain_id, oracle_address.
Private inputs: the RISK_SCORE private inputs, plus signature, pubkey_x, pubkey_y (as in 0x07), plus signed_timestamp (Field) — used in the signed digest because RISK_SCORE has no public timestamp.
Private inputs: per-slot arrays of size 5, each with signals[8], weights[8], weight_sum, pubkey_x[32], pubkey_y[32], signature[64]. Inactive slots set weight_sum = 1, weights = [1, 0, ...], signals = [0; 8] so the per-slot score arithmetic remains well-defined.
Constraints: for each slot i in 0..MAX_PROVIDERS_MULTI:
Let active_i = (signer_pubkey_hash_i != 0).
weight_sum_i > 0 and weight_sum_i == sum_j weights_i[j]. Enforced for all slots (active and inactive) so the per-slot score arithmetic is well-defined and the denominator cannot be inflated.
Inactive slot constraints (signature check, score floor check) are gated on !active_i and accept arbitrary witness.
Cross-slot:
count(active_i for i in 0..5) >= threshold_m (and threshold_m in [1, MAX_PROVIDERS_MULTI]).
All non-zero signer_pubkey_hash_i MUST be pairwise distinct (no signer fills two slots).
meets_threshold == true (encoded as field 1). A valid proof cannot be produced with meets_threshold = false: the active-slot floor checks and signature checks in step 5 are hard asserts, so any failure prevents the proof from existing. The public field exists for layout parity with 0x07 and so the Oracle can route on it.
The Oracle also validates threshold_m >= JurisdictionConfig.minMultiProviderThreshold(jurisdiction_id), each non-zero signer_pubkey_hash_i is registered, chain_id == block.chainid, and oracle_address == address(this) per Public Input Validation.
Verifier Interface
The verifier routes proof verification to per-proof-type verification contracts. Each circuit produces a separate verifier via the ZK backend (e.g., bb write_solidity_verifier for Barretenberg’s UltraHonk; see Reference Implementation).
interfaceIERC8262Verifier{/// @notice Verify a zero-knowledge compliance proof
/// @param proofType The type of proof (0x01-0x09)
/// @param proof The encoded proof data
/// @param publicInputs The public inputs to the verification circuit (packed bytes32 values)
/// @return valid Whether the proof is valid
functionverifyProof(uint8proofType,bytescalldataproof,bytescalldatapublicInputs)externalviewreturns(boolvalid);/// @notice Verify a batch of proofs atomically
/// @param proofTypes Array of proof types
/// @param proofs Array of encoded proofs
/// @param publicInputs Array of public input sets
/// @return valid Whether ALL proofs are valid
functionverifyProofBatch(uint8[]calldataproofTypes,bytes[]calldataproofs,bytes[]calldatapublicInputs)externalviewreturns(boolvalid);/// @notice Get the current verifier address for a proof type
/// @param proofType The proof type (0x01-0x09)
/// @return verifier The verifier contract address (address(0) if not set)
functiongetVerifier(uint8proofType)externalviewreturns(addressverifier);/// @notice Verify a proof against a specific historical verifier version
/// @dev Required for retroactive verification: a proof generated under a prior
/// verifier version must remain checkable after the current verifier has
/// been upgraded. Revoked versions (see Verifier Versioning) MUST revert.
/// @param proofType The proof type (0x01-0x09)
/// @param version The 1-indexed verifier version
/// @param proof The encoded proof data
/// @param publicInputs The public inputs
/// @return valid Whether the proof is valid
functionverifyProofAtVersion(uint8proofType,uint256version,bytescalldataproof,bytescalldatapublicInputs)externalviewreturns(boolvalid);/// @notice Get the verifier address for a specific historical version
/// @param proofType The proof type (0x01-0x09)
/// @param version The 1-indexed verifier version
/// @return verifier The verifier contract address
functiongetVerifierAtVersion(uint8proofType,uint256version)externalviewreturns(addressverifier);/// @notice Get the current verifier version for a proof type
/// @param proofType The proof type (0x01-0x09)
/// @return version The current version (0 if no verifier set)
functiongetVerifierVersion(uint8proofType)externalviewreturns(uint256version);}
Implementations MUST also implement ERC-165. supportsInterface(bytes4) MUST return true for type(IERC8262Verifier).interfaceId and for type(IERC165).interfaceId, and false for 0xffffffff.
Each per-circuit verifier’s verify(bytes, bytes32[]) function MUST be declared view so that the EVM uses STATICCALL when the router invokes it. Implementations MUST NOT invoke verifiers via interfaces that omit the view modifier; a non-view verifier could reenter the calling Oracle and mutate attestation state mid-verification.
Batch Verification Limits
Implementations MUST enforce a maximum batch size for verifyProofBatch and submitComplianceBatch to bound worst-case gas consumption. The cap MUST be chosen so a full batch fits comfortably within the target chain’s block gas limit with headroom for the submission overhead (registry lookups, replay-guard SSTORE, event emission). The reference implementation caps both at 10, sized for the 30 M-gas mainnet ceiling; deployments on chains with larger or smaller block budgets MUST recalibrate.
Oracle Interface
interfaceIERC8262Oracle{structComplianceAttestation{addresssubject;// address that proved compliance (msg.sender at submission)
uint8jurisdictionId;// jurisdiction (0=EU, 1=US, 2=UK, 3=SG)
uint8proofType;// which proof type produced this attestation (0x01-0x09)
boolmeetsThreshold;// whether the rule was satisfied
uint256timestamp;// block.timestamp at submission
uint256expiresAt;// block.timestamp + attestationTTL
bytes32proofHash;// keccak256(proof, proofType, chainId, oracleAddr) -- see Proof Hash Computation
bytes32providerSetHash;// hash of providers + weights (COMPLIANCE/COMPLIANCE_SIGNED only; bytes32(0) otherwise)
bytes32publicInputsHash;// keccak256(publicInputs)
addressverifierUsed;// verifier contract address at submission time (TOCTOU-safe)
}eventComplianceVerified(addressindexedsubject,uint8indexedjurisdictionId,boolmeetsThreshold,bytes32indexedproofHash,uint256expiresAt,uint256previousExpiresAt);eventProviderWeightsUpdated(bytes32indexedconfigHash,uint256timestamp,stringmetadataURI);eventAttestationTTLUpdated(uint256oldTTL,uint256newTTL);eventConfigRevoked(bytes32indexedconfigHash);eventMerkleRootRegistered(bytes32indexedmerkleRoot);eventMerkleRootRevoked(bytes32indexedmerkleRoot);eventReportingThresholdRegistered(bytes32indexedthreshold);eventReportingThresholdRevoked(bytes32indexedthreshold);/// @notice Submit a compliance proof and record the attestation
/// @param jurisdictionId Target jurisdiction (0=EU, 1=US, 2=UK, 3=SG)
/// @param proofType The proof type for verifier routing (0x01-0x09)
/// @param proof The ZK proof data
/// @param publicInputs Public inputs matching the circuit's pub parameters
/// @param providerSetHash Hash of provider IDs and weights used for screening
/// @return attestation The recorded compliance attestation
functionsubmitCompliance(uint8jurisdictionId,uint8proofType,bytescalldataproof,bytescalldatapublicInputs,bytes32providerSetHash)externalreturns(ComplianceAttestationmemoryattestation);/// @notice Submit a batch of compliance proofs atomically
/// @dev All entries share `jurisdictionId`. The batch reverts if ANY entry fails
/// verification, validation, or replay checks. Implementations MUST cap the
/// batch size (see Batch verification limits).
/// @param jurisdictionId Target jurisdiction for all entries (0=EU, 1=US, 2=UK, 3=SG)
/// @param proofTypes Proof type for each entry (0x01-0x09)
/// @param proofs ZK proof data for each entry
/// @param publicInputs Public inputs for each entry
/// @param providerSetHashes Provider set hash for each entry
/// @return attestations The recorded compliance attestations, in input order
functionsubmitComplianceBatch(uint8jurisdictionId,uint8[]calldataproofTypes,bytes[]calldataproofs,bytes[]calldatapublicInputs,bytes32[]calldataproviderSetHashes)externalreturns(ComplianceAttestation[]memoryattestations);/// @notice Check if an address has a valid (non-expired) compliance attestation
/// @param subject The address to check
/// @param jurisdictionId The jurisdiction to check against
/// @return valid Whether a valid, non-expired attestation exists
/// @return attestation The attestation if valid
functioncheckCompliance(addresssubject,uint8jurisdictionId)externalviewreturns(boolvalid,ComplianceAttestationmemoryattestation);/// @notice Check compliance filtered by proof type
/// @dev Integrators that require a specific proof family (e.g. only signed variants,
/// or only ATTESTATION-backed) MUST use this rather than `checkCompliance()`,
/// since the latest attestation per (subject, jurisdiction) may have been
/// produced by any supported proof type.
/// @param subject The address to check
/// @param jurisdictionId The jurisdiction
/// @param proofType The required proof type (0x01-0x09)
/// @return valid Whether a valid attestation of the specified type exists
/// @return attestation The attestation if valid
functioncheckComplianceByType(addresssubject,uint8jurisdictionId,uint8proofType)externalviewreturns(boolvalid,ComplianceAttestationmemoryattestation);/// @notice Retrieve a proof for retroactive verification (proof-of-innocence)
/// @param proofHash The hash of the original compliance proof
/// @return attestation The original attestation record
functiongetHistoricalProof(bytes32proofHash)externalviewreturns(ComplianceAttestationmemoryattestation);/// @notice Get the proof type that produced an attestation
/// @dev Equivalent to `getHistoricalProof(proofHash).proofType` but cheaper.
/// @param proofHash The hash of the original proof
/// @return proofType The proof type identifier (0x01-0x09)
functiongetProofType(bytes32proofHash)externalviewreturns(uint8proofType);/// @notice Get all attestation hashes for a subject in a jurisdiction
/// @dev Returns an unbounded array. Implementations SHOULD also expose a
/// paginated variant for subjects with large histories.
/// @param subject The address to query
/// @param jurisdictionId The jurisdiction
/// @return proofHashes Array of proof hashes for historical lookup
functiongetAttestationHistory(addresssubject,uint8jurisdictionId)externalviewreturns(bytes32[]memoryproofHashes);/// @notice Get the current provider weight configuration hash
/// @return configHash Hash of current provider weights
functionproviderConfigHash()externalviewreturns(bytes32configHash);/// @notice Get the current attestation time-to-live
/// @return ttl Duration in seconds that attestations remain valid
functionattestationTTL()externalviewreturns(uint256ttl);}
Implementations MUST also implement ERC-165. supportsInterface(bytes4) MUST return true for type(IERC8262Oracle).interfaceId and for type(IERC165).interfaceId, and false for 0xffffffff.
Proof Hash Computation
Implementations MUST compute the proofHash field of ComplianceAttestation as:
Including proofType scopes uniqueness per proof type, so identical proof bytes submitted under different types are treated as distinct. Including block.chainid and address(this) prevents on-chain replay across forks or alternate Oracle deployments. This is the on-chain replay guard only; in-circuit chain binding is provided by the signed variants (see Public Input Validation).
Jurisdiction Configuration
Implementations MUST publish jurisdiction thresholds openly. Risk scores are expressed in basis points (0-10000 = 0.00%-100.00%).
ID
Jurisdiction
Low (bps)
Medium (bps)
High / Filing trigger (bps)
0
EU (AMLD6)
0-3099
3100-7099
>=7100
1
US (BSA)
0-2599
2600-6599
>=6600
2
UK (MLR)
0-3099
3100-7099
>=7100
3
Singapore (MAS)
0-3599
3600-7599
>=7600
4
UAE (VARA)
0-3099
3100-7099
>=7100
Jurisdiction Policy
Implementations MUST publish two per-jurisdiction policy values alongside the
threshold table: whether unsigned screening proofs (COMPLIANCE 0x01, RISK_SCORE
0x02) are accepted, and the minimum threshold_m for COMPLIANCE_MULTI_SIGNED
(0x09). The reference values are:
ID
Jurisdiction
Accepts unsigned (0x01, 0x02)
Min threshold_m (0x09)
0
EU (AMLD6)
yes
1
1
US (BSA)
no
2
2
UK (MLR)
yes
1
3
Singapore (MAS)
no
2
4
UAE (VARA)
no
2
Compliant implementations MUST reject submissions of unsigned screening proofs
for any jurisdiction whose “Accepts unsigned” column is no, and MUST reject
COMPLIANCE_MULTI_SIGNED submissions whose threshold_m is below the per-jurisdiction
minimum. The reference enforces both via JurisdictionConfig.requireSignedSignals(uint8)
and JurisdictionConfig.minMultiProviderThreshold(uint8).
Implementations targeting a jurisdiction not enumerated above SHOULD use
signed-only + M >= 2 if the regulator requires provider attestation, and the
EU defaults (accepts unsigned + M = 1) otherwise. The policy MUST be
hard-coded or governance-mutable under the same time-delay guarantees as
verifier upgrades; it MUST NOT be settable per-submission.
Attestation Lifecycle
Compliance attestations have a configurable time-to-live (TTL):
Default TTL: 24 hours
Minimum TTL: 1 hour
Maximum TTL: 30 days
expiresAt = block.timestamp + attestationTTL at submission time
checkCompliance() MUST return false for expired attestations. Expired attestations MUST remain retrievable via getHistoricalProof() for proof-of-innocence purposes. The TTL is updatable by the oracle administrator via updateAttestationTTL().
Provider Weight Publication
Implementations SHOULD publish provider weights as an on-chain configuration hash. Weight changes MUST emit ProviderWeightsUpdated with the new configuration hash, timestamp, and an optional metadataURI pointing to the full configuration (e.g., on IPFS or Arweave).
Provider configuration MUST be versioned. Implementations SHOULD maintain a history of configuration hashes to support retroactive verification: determining which weights were active when a particular proof was generated. Implementations SHOULD support revoking historical configuration hashes when a configuration is discovered to be flawed. The currently active configuration MUST NOT be revocable. Implementations MUST permanently retain revocation status: a previously-revoked configuration hash MUST NOT be re-registrable, to prevent silent un-revocation.
Configuration history SHOULD be bounded to prevent unbounded storage growth (e.g., 256 entries, with FIFO eviction of the oldest non-current entries).
Proof Type Routing
Implementations MUST maintain a registry mapping each proof type to a per-circuit verifier contract. Each ZK circuit (compiled separately) produces its own verification key and verifier contract. The main verifier contract acts as a router:
Caller specifies proofType (0x01-0x09)
Router looks up the registered verifier for that type
Public inputs are decoded from packed bytes to bytes32[]
The per-circuit verifier’s verify(bytes, bytes32[]) is called
Verifier addresses are updatable to allow circuit upgrades. Implementations SHOULD use a two-step ownership transfer pattern for administrative operations.
Verifier Versioning
Verifier upgrades MUST NOT invalidate proofs that were valid under a prior verifier. An on-chain attestation produced under version $v_n$ records verifierUsed at submission time, but a counterparty months later may need to re-run the verification — for example, to recompute proof-of-innocence after a discovered circuit bug or to independently audit a historical attestation. This is impossible if the contract retains only the latest verifier address.
Implementations MUST maintain an append-only version history per proof type and expose three operations:
getVerifierVersion(proofType) returns the current version count (1-indexed).
getVerifierAtVersion(proofType, version) returns the verifier contract address at that version.
verifyProofAtVersion(proofType, version, proof, publicInputs) re-runs verification through the historical verifier.
Implementations MUST support revoking a specific historical version when a verifier is discovered to be unsound. Revocation MUST NOT delete the entry from history (the address remains recoverable via getVerifierAtVersion), but verifyProofAtVersion against a revoked version MUST revert. Revoking the current (latest) version MUST be forbidden — current revocation must instead proceed by proposing a replacement verifier through the upgrade timelock and then revoking the prior version.
Revocation MAY have two paths: a delayed path (default) and an immediate path gated behind the GUARDIAN role for cases where a paused proof type needs the revocation locked in before the timelock elapses. The reference implementation uses a 6 h delay for the routine path.
Implementations MUST resolve the verifier address once per submission and use that resolved address for both proof verification and the verifierUsed field of the recorded attestation. A time-of-check / time-of-use gap between resolution and verification would allow the recorded verifierUsed to diverge from the verifier that actually validated the proof, breaking retroactive verification guarantees.
Public Input Validation
Implementations MUST validate public inputs semantically for each proof type before forwarding to the per-circuit verifier. The ZK proof guarantees internal consistency (e.g., that the score was correctly computed from the committed inputs), but the oracle MUST verify that those committed inputs match the expected context (e.g., that the config hash is a known configuration, that the merkle root belongs to a registered set). Without this validation, a valid proof generated for one context can be replayed in a different context.
Public inputs MUST be 32-byte aligned. Implementations MUST reject publicInputs where length % 32 != 0.
The following validation MUST be performed per proof type:
Each proof type includes a boolean result field (meets_threshold, result, is_valid, is_member, is_non_member) in its public inputs. A valid ZK proof with a false result means the prover proved they do NOT satisfy the condition (e.g., non-compliant, not a member). Implementations MUST reject proofs where the result field is not true (encoded as bytes32(uint256(1))). Without this check, a user could submit a cryptographically valid proof of non-compliance and receive a compliant attestation.
The providerSetHash parameter in submitCompliance() is semantically meaningful for COMPLIANCE proofs, which include it as a caller-supplied public input. RISK_SCORE proofs also commit to a provider_set_hash in their circuit public inputs, but this value is embedded in the proof itself and does not come from the caller parameter. For all non-COMPLIANCE proof types, implementations MUST ignore the caller-supplied providerSetHash and store bytes32(0) in the attestation to prevent injection of arbitrary values.
Validation Registries
Implementations MUST maintain on-chain registries for values that public inputs are validated against. These registries prevent context-spoofing attacks where a proof generated for one context is submitted in a different context.
Config hash registry. Tracks valid provider weight configuration hashes. New hashes are added when the administrator updates the configuration. Historical hashes SHOULD be revocable (see Provider Weight Publication). The currently active configuration MUST NOT be revocable. Implementations MUST permanently retain revocation status: a previously-revoked config hash MUST NOT be re-registrable, to prevent silent un-revocation.
Merkle root registry. Tracks valid merkle roots for MEMBERSHIP and NON_MEMBERSHIP proofs (typically managed sets such as sanctions lists or whitelists). Roots MUST be registered by the administrator before proofs referencing them can be accepted. Roots SHOULD be revocable when the underlying set is superseded or compromised.
Credential root registry (per-provider). Tracks valid credentials Merkle roots for ATTESTATION proofs, keyed by provider_id. Each provider has an authorized publisher EOA, set by the administrator via a separate registration step. The publisher SHOULD publish new credential roots periodically (replacing prior ones). Roots SHOULD have a finite TTL window during which they are accepted; this window allows users with paths against an outgoing root to continue submitting proofs while a new root propagates. Implementations MUST verify the proof’s provider_id matches the registered providerId for the credential root being referenced; otherwise an attacker could reuse another provider’s root with a forged provider_id.
Reporting threshold registry. Tracks valid reporting thresholds for PATTERN (anti-structuring) proofs. Each jurisdiction defines its own reporting threshold (e.g., $10,000 for the United States Bank Secrecy Act (BSA)). Thresholds MUST be registered before proofs referencing them can be accepted.
Registry idempotency. Across all registries, re-registering an already-registered value MUST revert, and revoking a value that is not registered MUST revert. This prevents accidental double-registration and silent no-op revocations from masking a misconfigured admin flow.
Credential publisher rotation. Each per-provider credential root binds to a publisher EOA recorded on-chain. Implementations SHOULD support rotating the publisher EOA under a delayed administrative path (e.g., 6 h timelock) to limit damage from a compromised publisher key, and SHOULD support immediate credential root revocation (no delay) so a malicious root discovered after publication can be removed before its TTL elapses. The currently registered publisher MUST be replaceable only by the owner (not by the publisher itself).
Risk Score Computation
The risk score formula MUST be deterministic and publicly verifiable:
where $\text{signal}i \in [0, 100]$ are provider screening results, $\text{weight}_i$ are published provider weights, $W = \sum{i=1}^{N} \text{weight}_i$ is the weight sum, and $N \leq 8$ is the number of active providers. The result is in basis points ($0$-$10000$, i.e., $0.00\%$-$100.00\%$).
Circuits that accept weight_sum as a private input MUST constrain it to equal the actual sum of the weights array. Without this constraint, a malicious prover could pass an arbitrary denominator to inflate or deflate the computed score.
The ZK proof commits to:
Signal values (hidden)
Weights used (public via config_hash, must match published config)
Resulting score (hidden)
Whether jurisdiction threshold was crossed (revealed as boolean)
The risk score’s integrity depends on the independence of the contributing providers. Implementations whose threat model includes collusion among a subset of providers SHOULD require attestations from multiple independent providers (e.g., via COMPLIANCE_MULTI_SIGNED with threshold_m >= 2) and SHOULD weight providers based on enforcement track record so a single compromised or coerced provider cannot drive the score across a jurisdiction threshold.
Circuit Constraints
Circuits MUST enforce realistic timestamp bounds on the public timestamp inputs they consume. A timestamp before 2021-01-01 (UNIX 1609459200) or after a far-future bound (e.g., UNIX 0xFFFFFFFF, ~year 2106) MUST be rejected in-circuit. This applies to COMPLIANCE, COMPLIANCE_SIGNED, COMPLIANCE_MULTI_SIGNED, MEMBERSHIP, NON_MEMBERSHIP, and to each active per-transaction timestamp in PATTERN. ATTESTATION’s current_timestamp and expiry_timestamp are not range-bounded in-circuit; freshness is the consumer’s responsibility. RISK_SCORE has no timestamp input, and RISK_SCORE_SIGNED’s signed_timestamp is committed to the provider’s signature rather than range-checked. Without these bounds where they apply, a malicious prover could backdate or far-future-date a proof to bypass attestation TTL checks downstream.
Hash Function Requirements
Circuits MUST use a collision-resistant hash function for all commitments (provider set hashes, config hashes, Merkle trees, credential hashes). The reference implementation uses Pedersen hash, which is efficient in ZK circuits and available in the Noir standard library.
Pedersen commitments are additively homomorphic over the underlying elliptic curve. This is safe provided:
Hash outputs are used only as opaque commitments compared via equality.
No circuit composes hash outputs arithmetically (e.g., H(x) + H(y)).
All hash calls use fixed-arity inputs to prevent length-extension reinterpretation.
Implementations MAY migrate to Poseidon2 when high-level APIs stabilize in the circuit language, as Poseidon2 provides stronger random-oracle properties.
Merkle Tree Domain Separation
Implementations MUST use distinct domain tags for leaf and internal-node hashes to prevent the second-preimage attack where an attacker crafts a leaf whose hash collides with an internal node. The reference implementation uses three explicit tags: one for internal nodes, one for set-style leaves bound to (element, set_id), and one for value-style leaves committing a single value (e.g., credential_hash in the attestation circuit).
The fixed-arity Pedersen hash used in the reference implementation does NOT achieve domain separation by input arity alone (e.g., H([a, b, 0]) == H([a, b]) for the standard pedersen_hash without an explicit length tag). Implementations MUST therefore include an explicit domain tag in the input array.
Non-Membership Proof Security
The non-membership circuit proves that the SUBMITTER is NOT in a sorted Merkle tree by demonstrating adjacency: there exist two consecutive leaves $l$ and $h$ in the tree such that $l < \text{submitter} < h$ AND $\text{high_index} = \text{low_index} + 1$.
The adjacency requirement is critical. Without it, an attacker could pick two non-adjacent tree entries that bracket the submitter, hiding any real intermediate entry that contains the submitter’s address. Tree publishers MUST sort leaves by their raw value (the value argument to leaf_hash_subject). Implementations SHOULD insert sentinel boundary leaves at $0$ and $p-1$ (BN254 prime minus 1) so every submitter has well-defined neighbors.
Comparison MUST be performed over the full Field range using bit-decomposition (e.g., the Field::lt comparison provided by Noir; see Reference Implementation). Earlier designs that cast to u64 and compared as fixed-width integers required additional range checks on all values; the reference implementation uses Field-level comparison to support arbitrary-width identifiers (Ethereum addresses, hashes, etc.) without truncation risk.
Submitter Binding
Implementations MUST bind every proof to its submitter. Each proof type includes submitter as a public input that the on-chain validator enforces equal to msg.sender. For proofs that prove a fact about a specific party (membership, non-membership, attestation), the proof’s leaf format MUST also bind to submitter in-circuit so the proof is meaningful only for that submitter:
Membership / non-membership: leaf_hash_subject(value, set_id, salt) where value derives from the relevant party (e.g., submitter for membership; the bracketing tree entries for non-membership ordering).
Without this binding, an unauthorized party could submit a proof asserting facts about an arbitrary value and claim the resulting attestation as their own.
Retroactive Flagging
Each compliance proof MUST commit to:
Provider IDs used for screening (committed via providerSetHash)
Results returned by each provider at proof time (hidden)
The oracle’s clearing decision (revealed as meetsThreshold boolean)
A timestamp binding the proof to a specific block
This enables proof-of-innocence: counterparties to retroactively flagged addresses can present the original attestation (retrieved via getHistoricalProof()) demonstrating the address was clean at transaction time. The on-chain record is immutable and independently verifiable.
Pause Mechanism
Implementations SHOULD support pausing proof submission so a discovered circuit or verifier vulnerability can be contained without redeploying. Pause MUST NOT block read access to existing attestations (checkCompliance, checkComplianceByType, getHistoricalProof, getAttestationHistory): retroactive verification (proof-of-innocence) depends on those endpoints remaining live during an incident. Implementations SHOULD support per-proof-type pause in addition to global pause so unrelated proof types remain available during a scoped incident response.
Administrative Operations
Verifier replacement, weight updates, registry mutations, TTL changes, and publisher rotations are privileged operations. Implementations MUST use a two-step ownership transfer pattern (transferOwnership + acceptOwnership) for owner handover to prevent accidental transfer to an incorrect address. Implementations SHOULD timelock critical operations (verifier replacement, TTL changes, weight updates) in production. Implementations SHOULD split administrative authority into role classes with bounded blast radius (for example, a pause-only “guardian” role distinct from registry-mutating and config-mutating roles) so that a single compromised key cannot both pause the Oracle and rewrite its registries. The reference implementation uses a three-role split (GUARDIAN, REGISTRAR, CONFIG) under a 2-tier selector-gated timelock.
Trust Tier Disclosure
Implementations SHOULD publish, in deployment-facing documentation, the trust tier (self-attested, provider-attested, or credential-attested) they accept per jurisdiction, together with the on-chain addresses of the Verifier and Oracle and the list of registered providers and signer pubkey hashes. Integrators can then match a deployment against their threat model without inspecting on-chain state, and external auditors can confirm that the published policy matches the on-chain configuration.
Rationale
Why client-side computation? Server-side or TEE-based compliance creates a trusted party that can be coerced, compromised, or surveilled. Client-side ZK proof generation means the raw data never leaves the user’s device. The verifier learns only the boolean result.
Why published weights? “Black box” compliance algorithms invite regulatory skepticism and legal challenge. Publishing weights and thresholds makes the system auditable without compromising individual privacy. When enforcement data reveals a provider consistently misses bad actors, the weight adjustment is transparent.
Why on-chain attestations? Off-chain attestations can be forged, lost, or denied. On-chain records are immutable, timestamped, and independently verifiable. This is critical for proof-of-innocence: the proof must be retrievable months or years after the original transaction.
Why not Privacy Pools inclusion/exclusion proofs? Privacy Pools prove set membership (“I’m not in the OFAC set”). This ERC proves compliance with specific rules (“my risk score under jurisdiction X is below threshold Y using providers A, B, C”). Set membership is a subset of what’s needed for regulatory compliance.
Why attestation TTL? Compliance status is not permanent. A user who was compliant yesterday may not be compliant today. Screening providers update their data continuously. The TTL forces periodic re-attestation while keeping the window configurable per deployment context.
Why nine proof types? Each proof type maps to a separate ZK circuit with distinct constraint logic. Compliance handles the core risk score check. Risk Score provides standalone threshold/range proofs. Pattern detects structuring behaviors. Attestation verifies credentials from authorized providers. Membership proves inclusion in an authorized set (whitelist). Non-membership proves exclusion from a sanctions list via sorted Merkle tree adjacency. The two single-signer _signed variants (Compliance Signed, Risk Score Signed) shadow their unsigned siblings but additionally verify one provider’s secp256k1 ECDSA signature over the screening payload in-circuit and bind to (chain_id, oracle_address). The Compliance Multi-Signed variant (0x09) extends this further to M-of-N: up to five parallel signer slots, each independently signature- and floor-checked, with a runtime threshold_m and a per-jurisdiction floor for M. They are separate circuits rather than an oracle-side flag because the signature check materially changes the constraint set: an unsigned proof has no provenance for its signals[] private witness, while a signed proof cryptographically attests them. Strict-mode jurisdictions (see Jurisdiction Policy) accept only the signed forms. This separation keeps individual circuits small and auditable, and lets unsigned-tolerant jurisdictions deploy without paying the signature-verification gas overhead.
What this standard does NOT prove
The single most important caveat for adopters: the cryptographic guarantees in this ERC are about correct computation, not about honest inputs. Three trust tiers exist across the proof types, and integrators have to pick the tier that matches their threat model.
Tier
Proof types
Who attests the screening signals?
Self-attested
COMPLIANCE, RISK_SCORE
The submitter. The circuit accepts signals[] as a private witness with no signature.
Provider-attested
COMPLIANCE_SIGNED, RISK_SCORE_SIGNED
A registered provider, via in-circuit secp256k1 ECDSA over the screening payload.
Credential-attested
ATTESTATION (composed with the above)
A registered credential-tree publisher EOA, via Merkle inclusion against a published root.
The self-attested tier is useful for jurisdictions that explicitly permit user-asserted compliance (some EU and UK contexts), for fast-path flows where a downstream system performs the honest-signal check, and as a building block in larger composed proofs. A user submitting a self-attested COMPLIANCE proof could in principle pass signals = [0, ...] and produce a valid “low-risk” proof regardless of their true screening result; provider_set_hash and config_hash commit to which providers and weights were used, not to what those providers returned. This is documented as an explicit design tradeoff, not a bug.
The strict-mode-jurisdiction policy and the integrator guidance above are mirrored normatively in the Specification (see Public Input Validation and Trust Tier Disclosure); this Rationale section explains the why. Adopters benefit from publishing which tier they accept per jurisdiction so integrators can match the deployment’s trust posture against their own threat model without inspecting on-chain state.
Related Work
Several existing and emerging standards address compliance, privacy, or on-chain ZK verification. This ERC differs from each in scope, architecture, or trust model.
ERC-3643 (T-REX). The ratified compliance token standard for regulated securities, with $32B+ in tokenized assets. ERC-3643 requires identity revelation via ONCHAINID claims verified by trusted issuers. This ERC proves compliance without revealing identity data, provider signals, or transaction amounts. The two standards are complementary: this ERC could serve as a ZK-enhanced identity provider within an ERC-3643 deployment.
Privacy Pools (0xbow). Live on Ethereum mainnet since March 2025. Users prove their withdrawal originates from a “clean” deposit set using ZK proofs, with Association Set Providers (ASPs) maintaining approved deposit lists. The Privacy Pools protocol validates the “prove compliance without revealing data” model. However, set membership is a subset of what regulatory compliance requires. This ERC extends the approach to multi-dimensional compliance: risk scoring, anti-structuring detection, credential verification, and membership/non-membership proofs.
RISC Zero token oracle. A draft EIP proposes an oracle-permissioned ERC-20 that validates token transfers via ZK proofs against off-chain payment instructions (ISO 20022 format), using RISC Zero as the proof system. That proposal gates a single token’s transfers through a single oracle with a single proof type. This ERC provides standalone compliance attestations with nine proof types, usable by any contract, and is not gated to token operations.
ERC-7812. A ZK identity registry using a singleton Sparse Merkle Tree (80-level, Poseidon on BN128) with custom registrars for business logic. Deployed on Ethereum mainnet. ERC-7812 provides a general-purpose private statement registry. This ERC could operate as a compliance-specific registrar within ERC-7812, storing compliance commitments in its Merkle tree.
Smart-account ZK verifier interface. A draft ERC proposes a proof-system-agnostic ZK verification interface for smart accounts (verifyProof(bytes,bytes) returns (bytes4)), standardizing per-relation verifier contracts with a non-reverting return pattern (following ERC-1271). This ERC’s per-proof-type verifier routing serves a similar verification role but with domain-specific semantics (proof type routing, batch verification, version history). Each generated UltraHonk verifier in this ERC could be wrapped behind such an adapter for smart account integration.
EIP-7702. Account abstraction via temporary delegation: an EOA can authorize a contract to execute code on its behalf for a single transaction. EIP-7702 interacts with the submitter == msg.sender rule in two ways. First, when a 7702-delegated EOA calls submitCompliance, msg.sender is the EOA address (not the delegated contract), so the attestation correctly binds to the EOA and the submitter public input must equal that EOA. Second, a smart-account batcher (using 7702 to wrap multiple operations) can call submitComplianceBatch provided every entry’s submitter public input equals the delegating EOA. Account-abstraction wallets should surface the bound submitter address to the user before submission, since a malicious dApp could otherwise solicit proofs bound to the wrong address. The same considerations apply to ERC-4337 paymasters and ERC-1271 contract signers when used as compliance subjects.
MultiTrust Credential. Companion draft ERCs propose non-transferable credential anchors with ZK presentation via fixed Groth16 ABI, supporting predicate proofs (“score >= threshold”) without revealing raw data. The predicate-proving pattern parallels this ERC’s RISK_SCORE proof type. MultiTrust focuses on credential issuance and presentation; this ERC focuses on compliance attestation and retroactive verification.
ERC-1922. The original zk-SNARK verifier standard (2019, stagnant). ERC-1922 defines a generic interface for on-chain ZK verification with dynamic arrays for cross-scheme compatibility. This ERC supersedes ERC-1922’s approach with per-proof-type routing, UltraHonk support, and domain-specific input validation.
Backwards Compatibility
This ERC introduces new interfaces and does not modify existing standards. It is designed to complement ERC-5564 (stealth addresses) and ERC-6538 (stealth meta-address registry) for privacy-preserving settlement, but does not depend on them.
Test Cases
Binary proof fixtures for the six unsigned proof types are published in the reference implementation (see Reference Implementation below) under test/fixtures/. Static fixtures are not provided for the three signed variants (COMPLIANCE_SIGNED, RISK_SCORE_SIGNED, COMPLIANCE_MULTI_SIGNED) because each requires a fresh secp256k1 ECDSA witness; those are exercised end-to-end in the TypeScript SDK consumer tests instead. Each unsigned fixture contains:
proof: the raw UltraHonk proof bytes (8640 bytes each)
All fixtures use Pedersen hash (Noir stdlib) for in-circuit commitments and Merkle tree construction. Fixtures can be regenerated via scripts/generate-fixtures.sh in the reference implementation.
Witness Annex
The exact Prover.toml inputs used to produce the binary fixtures are reproduced below so other implementations can cross-validate against the same witness. All submitter values are 0xdead and all timestamp values are 1700000000 (UNIX seconds, 2023-11-14). Address-style values are packed as field elements; Pedersen hashes on BN254 are reproduced verbatim.
# circuits/membership/Prover.toml (submitter is a member of set 1)subject_salt="0"# 0 = public setmerkle_index="0"merkle_path=["0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0"]merkle_root="0x1d7de002251083fdc312a329d46abde0680cbccc27935c33815c18b1beb3da8c"set_id="1"timestamp="1700000000"is_member=truesubmitter="0xdead"# leaf = leaf_hash_subject(submitter=0xdead, set_id=1, salt=0)
# circuits/non_membership/Prover.toml (submitter NOT in set {0x100, 0x10000})low_leaf="0x100"low_leaf_salt="0"low_index="0"low_path=["0x2e3a62a21fa1706df17be5649ad62e45a4dbdbe9a9ce3923058d940cdc6b929d","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0"]high_leaf="0x10000"high_leaf_salt="0"high_index="1"# adjacent to low_indexhigh_path=["0x0c57a3ac2ba9abef99b6ab714e307311687782f270b6517717e181e5cd50cce5","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0","0"]merkle_root="0x138f818fd4f2eec91e4fd93e14bcc47bc06a3ba333e5a2e7795d0beb752d247c"set_id="1"timestamp="1700000000"is_non_member=truesubmitter="0xdead"# Adjacency check: low_leaf (0x100) < submitter (0xdead) < high_leaf (0x10000)# AND high_index == low_index + 1.
Signed-variant witnesses (COMPLIANCE_SIGNED, RISK_SCORE_SIGNED) are identical to their unsigned siblings in the screening payload, plus pubkey_x, pubkey_y, signature, signer_pubkey_hash, chain_id, and oracle_address. The signature is computed off-chain by the provider over H_pedersen(chain_id, oracle_address, provider_set_hash, signals, weights, timestamp, submitter). COMPLIANCE_MULTI_SIGNED (0x09) extends this to five parallel signer slots: each active slot supplies its own (signals, weights, weight_sum, pubkey_x, pubkey_y, signature) and a non-zero signer_pubkey_hash, where each signature commits to a slot-specific Pedersen digest under the DOMAIN_MULTI_SIGNED_SIGNALS tag (with embedded slot_index). Implementations producing fresh fixtures MUST sample fresh nonces — the reference implementation does this in its SDK tests (test/sdk/) rather than committing a static witness.
Reference Implementation
A reference implementation is provided in the assets directory. It covers the verifier router, the standard’s interfaces, and the COMPLIANCE (0x01) circuit; the other eight circuits follow the Per-Type Circuit Specifications. Non-link paths in the Test Cases and Security Considerations sections refer to the full reference implementation. It consists of:
Router: ERC8262Verifier.sol – per-proof-type verifier registry, append-only version history, timelocked upgrades, and batch verification (Foundry, Solidity 0.8.28, Cancun EVM).
Circuit: the COMPLIANCE (0x01) circuit main.nr, written in Noir and compiled to an UltraHonk verifier by Barretenberg. Pinned tool versions and licenses are listed in the reference implementation README.
Each per-circuit UltraHonk verifier is a build artifact (~100 KB of Solidity per proof type), reproducible from the circuits; the generated verifiers are omitted here and registered into the router by address at deploy time.
Raw bb-generated UltraHonk verifiers exceed the EIP-170 24,576-byte runtime size limit for some of the nine proof types. Rewriting the pairing() free function in inline Yul saves ~186 bytes per verifier (and ~800 gas per verifyProof call as a bonus) while staying byte-identical to the bb-generated semantics on the pairing precompile (address(0x08)) input layout.
Security Considerations
This section discusses the threat model behind the normative requirements in the Specification. Each subsection names the threat, identifies the affected proof types, points at the Spec subsection that mandates the mitigation, and discusses residual risk.
Proof soundness. End-to-end security collapses to the soundness of the underlying ZK proof system. A weak proof system would let a prover forge a passing attestation without satisfying the circuit constraints; no other defense in this standard saves it. The 128-bit-security floor in Proof System Requirements is set by the strongest practical attack on Groth16/PLONK/UltraHonk over BN254-class curves.
Provider collusion. If a majority of weighted providers collude (or if a single provider is the sole source under unsigned COMPLIANCE), they can issue false clean signals and the protocol cannot detect it. Risk Score Computation recommends multi-provider attestation; COMPLIANCE_MULTI_SIGNED (0x09) with threshold_m >= 2 is the strongest in-protocol defense, and per-jurisdiction minMultiProviderThreshold provides a deployment-level floor.
Timestamp manipulation. Block proposers control block.timestamp within the parent-block constraint. This is acceptable for compliance windows measured in days but unacceptable as a fine-grained source of ordering or liveness. Circuit Constraints requires realistic bounds on every timestamp input. Integrators that need finer-grained ordering must use an explicit nonce or sequence number, not the timestamp.
Regulatory acceptance. This standard provides a technical mechanism for ZK compliance. Whether specific jurisdictions accept ZK proofs as sufficient compliance evidence is a legal question, not a technical one. The VARA (Dubai) definition of “anonymity-enhanced crypto” excludes assets with “mitigating technologies” for traceability. This standard provides exactly that technology.
Front-running the oracle. Compliance proofs are generated before settlement. An adversary who observes a proof submission in the mempool can infer that a trade is about to occur, even though the trade details remain hidden. Integrators that want to minimize this information leakage can batch proof submissions or piggyback proof submission on the settlement transaction itself; this is a deployment-policy choice, not a normative requirement of the standard.
Administrative operations. Verifier replacement, weight updates, registry mutations, TTL changes, and publisher rotations are the most consequential privileged operations. A single compromised admin key could pause the Oracle and rewrite its registries in one transaction. Administrative Operations mandates two-step ownership transfer and recommends splitting authority into guardian, registrar, and config roles behind a timelock. The reference implementation’s per-role capability matrix and timelock-tier mapping is in docs/THREAT_MODEL.md in the reference implementation.
Public input validation. Without validating that each public input matches a registered on-chain context, a prover can replay a proof produced for a lenient context (e.g., a permissive jurisdiction’s reporting threshold) into a stricter one. Public Input Validation defines the per-proof-type validation matrix. The most subtle case is the boolean result field: a ZK proof carrying meets_threshold = false (or result = false, is_valid = false, is_member = false, is_non_member = false) is a valid proof of non-compliance. Accepting it without checking the result would record a compliant attestation for a non-compliant subject. Proof Result Validation covers this explicitly.
Proof replay prevention. The proof-hash formula in Proof Hash Computation scopes attestation storage to a single (chain, Oracle, proofType) triple. Identical proof bytes submitted under different proof types or against different Oracle deployments are treated as independent, and the _usedProofs guard inside one Oracle prevents the same proof from being re-submitted. This guard is on-chain only; in-circuit chain/Oracle binding for the signed variants is handled by Public Input Validation.
Config and root revocation. Without revocation, a flawed configuration or a compromised merkle tree discovered after publication remains accepted forever. Provider Weight Publication and Validation Registries define the revocation rules: the currently active provider configuration cannot be revoked (revocation must proceed by registering a replacement first); revocation status is permanent (a previously-revoked hash cannot be re-registered); and configuration history is bounded so storage cannot grow without bound.
Verifier TOCTOU.Verifier Versioning requires verifier-address resolution to happen exactly once per submission, with the resolved address used for both verification and the recorded verifierUsed field. Without this, a verifier upgrade landing mid-transaction could record a different verifier than the one that actually validated the proof, breaking retroactive verification guarantees.
Batch verification limits. Batched verification multiplies the per-proof cost (~2.4 M gas for UltraHonk) by the batch size. Batch Verification Limits requires an enforced cap sized for the target chain’s block gas limit. The reference figures below show the linear cost growth on Cancun EVM with real proofs (see test/GasBenchmark.t.sol in the reference implementation); other proof systems and circuit revisions will differ.
Operation
Approx. gas
verifyProof (any of the 6 unsigned types)
~2.43M
submitCompliance (any of the 6 unsigned types)
~2.83-2.90M
verifyProofBatch / submitComplianceBatch, 1 entry
~2.88M
… 2 entries
~4.84M
… 5 entries
~12.05M
… 10 entries (max batch)
~24.08M
Signed-variant gas (COMPLIANCE_SIGNED, RISK_SCORE_SIGNED) is dominated by in-circuit ECDSA-secp256k1 verification, which roughly doubles proving time off-chain but only modestly increases the verifier byte size; on-chain verifyProof for the signed variants is in the same order of magnitude as the unsigned variants. Implementations targeting L2s with larger block budgets can raise the batch cap proportionally; implementations on chains with lower budgets must lower it. Submission overhead beyond verification (~400-470 k gas per attestation) covers public-input validation, registry lookups, the replay-guard SSTORE, attestation storage, and event emission. Integrators submitting many attestations per user can amortize the per-entry fixed cost via submitComplianceBatch.
Registry idempotency. A silent no-op on a duplicate registration or a missing revocation could mask a misconfigured admin flow and leave operators believing a state change landed when it did not. Validation Registries requires both operations to revert in those cases.
Emergency circuit break. A discovered circuit or verifier vulnerability needs to be contained without redeploying. Pause Mechanism mandates a pause that halts submission while keeping reads available, since proof-of-innocence depends on getHistoricalProof and checkCompliance staying live. Per-proof-type pause limits incident blast radius to the affected circuit.
Trust model and signal honesty. See What this standard does NOT prove in Rationale for the full discussion. In short: the unsigned variants (COMPLIANCE, RISK_SCORE) are self-attested. The circuit verifies the score formula but not the screening signals themselves. The per-jurisdiction policy in Public Input Validation rejects the unsigned siblings for strict-mode jurisdictions (US BSA, Singapore, and UAE VARA in the reference implementation). Integrators in permissive jurisdictions that need signal honesty should require the signed variants, optionally composed with ATTESTATION proofs against an independently-published credential tree. Trust Tier Disclosure lets integrators read the deployment’s accepted tier without inspecting on-chain state.
ATTESTATION authority root. ATTESTATION proofs verify Merkle inclusion of a credential leaf in a per-provider credentials tree. The leaf is H(DOMAIN_CREDENTIAL, provider_id, submitter, type, attribute, expiry), which binds the credential to the submitter cryptographically; a forged credential leaf cannot be constructed without breaking Pedersen preimage resistance. However, the circuit does not verify a provider signature over the credential leaf or root in-circuit. Authority resolves to the registered publisher EOA, with rotation and revocation paths defined in Validation Registries. A compromised publisher EOA can publish a tree containing arbitrary (submitter, attribute) pairs until the owner rotates the publisher or revokes the root. Implementations whose threat model includes a compromised publisher key can layer an in-circuit signature scheme over the credential root or credential leaf; this is tracked as future work and is intentionally not required by this specification.
Cross-chain replay. The unsigned proof types (COMPLIANCE, RISK_SCORE, PATTERN, ATTESTATION, MEMBERSHIP, NON_MEMBERSHIP) do not include a chain identifier as a circuit public input. The same proof bytes may be replayed against the same Oracle on a different chain (or against an alternate Oracle deployment on the same chain), producing independent attestations on each. The on-chain proof-hash guard from Proof Hash Computation prevents replay-into-storage within a given (chain, Oracle) pair, but provides no in-circuit binding. The signed variants close this gap in-circuit: their Pedersen digest commits to (chain_id, oracle_address, provider_set_hash, signals, weights, timestamp, submitter), and Public Input Validation requires chain_id == block.chainid and oracle_address == address(this) at the submission boundary. Replaying a signed proof against a different chain or Oracle requires forging a new ECDSA signature under the registered provider’s key. Implementations whose threat model includes cross-deployment replay of unsigned proofs can fork the unsigned circuits to add chain_id and oracle_address as public inputs at the cost of a new verifier per chain.
Verifier-layer reentrancy. A non-view verifier interface would expose the calling Oracle to reentrancy from a malicious or compromised verifier: the verifier could call back into the Oracle and mutate attestation state mid-verification. Verifier Interface requires the verifier function to be view, forcing the EVM to use STATICCALL, which prohibits state mutation in the callee.