Zero-Knowledge Compliance Oracle
Abstract
A standard interface for on-chain verification of regulatory compliance (anti-money laundering (AML), sanctions screening, anti-structuring) using zero-knowledge (ZK) 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 choice between transparency and privacy. Transparent execution exposes trades to maximal extractable value (MEV) extraction. Privacy tools have been sanctioned for lacking compliance mechanisms.
Existing approaches fall short:
- View keys: reveal raw transaction data to auditors on request. This is delayed transparency.
- TEE-based compliance: relies on hardware trust assumptions broken by side-channel attacks and key extraction.
- Compliance-by-exclusion (privacy pools): proves absence from a bad set, not compliance with jurisdiction rules.
This Ethereum Request for Comment (ERC) proves compliance cryptographically at transaction time. The proof commits to screening results, jurisdiction thresholds, and provider attestations. Regulators verify the proof without seeing the underlying data.
Specification
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “NOT RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119 and RFC 8174.
Terminology
providerSetHash: A commitment to the specific set of screening providers and theirweightsused 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 time-to-live (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 bycheckCompliance().
Proof System Requirements
Implementations MUST use a ZK proof system that achieves at least 128-bit security against forgery (e.g., Groth16, PLONK, UltraHonk). The reference implementation uses UltraHonk with Noir circuits and Barretenberg-generated verifiers; its README lists pinned tool versions and licenses.
Proof Types
Implementations MUST support the following proof types. Each type is a separate ZK circuit with its own verification key.
Every proof type includes submitter as a public input. Checks on all public inputs are in Public Input Validation.
| ID | Name | Circuit | Public inputs | Private inputs |
|---|---|---|---|---|
| 0x01 | Compliance | compliance |
jurisdiction_id, provider_set_hash, config_hash, timestamp, meets_threshold, submitter |
signals, weights, weight_sum, provider_ids, num_providers |
| 0x02 | Risk Score | risk_score |
proof_type, direction, bound_lower, bound_upper, result, config_hash, provider_set_hash, submitter |
signals, weights, weight_sum, provider_ids, num_providers |
| 0x03 | Pattern | pattern |
analysis_type, result, reporting_threshold, time_window, tx_set_hash, submitter, settlement_root |
amounts, timestamps, num_transactions |
| 0x04 | Attestation | attestation |
provider_id, credential_type, is_valid, credential_root, current_timestamp, submitter |
credential_attribute, expiry_timestamp, merkle_index, merkle_path |
| 0x05 | Membership | membership |
merkle_root, set_id, timestamp, is_member, submitter |
subject_salt, merkle_index, merkle_path |
| 0x06 | Non-Membership | non_membership |
merkle_root, set_id, timestamp, is_non_member, submitter |
low_leaf, low_leaf_salt, low_index, low_path, high_leaf, high_leaf_salt, high_index, high_path |
| 0x07 | Compliance Signed | compliance_signed |
jurisdiction_id, provider_set_hash, config_hash, timestamp, meets_threshold, signer_pubkey_hash, chain_id, oracle_address, submitter |
signals, weights, weight_sum, provider_ids, num_providers, signature, pubkey_x, pubkey_y |
| 0x08 | Risk Score Signed | risk_score_signed |
proof_type, direction, bound_lower, bound_upper, result, config_hash, provider_set_hash, timestamp, signer_pubkey_hash, chain_id, oracle_address, submitter |
signals, weights, weight_sum, provider_ids, num_providers, signature, pubkey_x, pubkey_y |
| 0x09 | Compliance Multi-Signed | compliance_multi_signed |
jurisdiction_id, provider_set_hash, config_hash, timestamp, meets_threshold, threshold_m, signer_pubkey_hash_i, chain_id, oracle_address, submitter |
(per-signer-slot; 5 parallel slots per proof): signals, weights, weight_sum, pubkey_x, pubkey_y, signature |
-
Compliance (0x01). Computes a weighted risk score from up to
MAX_PROVIDERSprovider signals;meets_thresholdistruewhen the score is strictly belowhighThreshold(jurisdictionId).config_hashandprovider_set_hashcommit to the weight configuration and provider set; the oracle validates both against its registries. Strict-mode jurisdictions (Jurisdiction Thresholds & Policies) reject 0x01 and require 0x07 or 0x09. -
Risk Score (0x02). The stored
meetsThresholdreflects onlyresult; integrators should also check that the bounds match their expectations. -
Pattern (0x03).
analysis_type:1 = anti-structuring,2 = velocity,3 = round-amounts. Implementations that depend on a specific analysis (e.g., a settlement registry requiring anti-structuring) MUST verifyanalysis_type; storing onlyresultis insufficient.settlement_rootis opaque to the circuit (0for stand-alone use, else a downstream consumer’s binding value). Consumers that need to bind a pattern proof to a specific downstream state (e.g., the sub-settlements of a trade) MUST recompute the expectedsettlement_rootfrom their own state and assert equality, and MUST mark each consumed pattern proof to prevent reuse across multiple bound contexts. The canonical sub-settlement computation iskeccak256(abi.encode(uint8 subTradeCount, bytes32[] subProofHashes)) mod BN254_FR_MODULUS, reduced to fit a BN254 scalar; consumers SHOULD use this exact encoding. -
Attestation (0x04). The leaf is
leaf_hash_value(credential_hash)withcredential_hashperH_credential(Commitment Layouts), binding the credential tosubmitterat issuance.credential_rootreferences a per-provider tree registered viapublishCredentialRoot; the root’s on-chainproviderIdmust match the proof’sprovider_id. -
Membership (0x05) and Non-Membership (0x06). The leaf is
leaf_hash_subject(value, set_id, salt). For 0x05,valueis the publicsubmitter(with privatesubject_salt). For 0x06,valueis the bracketing entry (low_leaf/high_leaf); the proof assertslow_leaf < submitter < high_leafby full-width field comparison andhigh_index == low_index + 1. Leaf ordering is specified in Non-Membership Proof Security. -
Compliance Signed (0x07) and Risk Score Signed (0x08). Unsigned-sibling semantics plus in-circuit secp256k1 ECDSA verification of
H_signed(Commitment Layouts) over(domain, chain_id, oracle_address, provider_set_hash, signals, weights, timestamp, submitter). Each variant has its owndomaintag, so asignaturefor one cannot satisfy the other. Both expose the signedtimestamp; the oracle applies freshness and the ratchet to it (Public Input Validation), bounding how long asignaturecan mint attestations. Implementations MUST validatesigner_pubkey_hashagainst an on-chain registry.chain_idandoracle_addressMUST matchblock.chainidand the consuming oracle’s address, binding asignatureto one deployment. Strict-mode jurisdictions reject the unsigned siblings; permissive jurisdictions accept either form. -
Compliance Multi-Signed (0x09). M-of-N extension of 0x07 with five signer slots; a slot is active if its public
signer_pubkey_hash_iis non-zero. Each active slot verifies a secp256k1 signature over its ownH_multi_slotdigest (carryingslot_index, underDOMAIN_MULTI_SIGNED_SIGNALS) and asserts its per-provider risk score is below the jurisdiction high-risk floor. The oracle MUST validate each non-zero slot’ssigner_pubkey_hash_iagainst the registry, MUST reject duplicate hashes across active slots, MUST enforcechain_id == block.chainidandoracle_address == address(this), and MUST enforcethreshold_m >= JurisdictionConfig.minMultiProviderThreshold(jurisdictionId)(Jurisdiction Thresholds & Policies). Forgery requires compromising M registered signing keys.
Circuit Conventions
These constants are normative. Deviating implementations produce incompatible verifiers and cannot share registries.
| Constant | Value | Applies to |
|---|---|---|
MAX_WEIGHT |
10000 |
per-provider weight ceiling (overflow guard on the score) |
MAX_PROVIDERS |
8 |
provider-slot count in Compliance (0x01), Risk Score (0x02), Compliance Signed (0x07), Risk Score Signed (0x08) |
MAX_TRANSACTIONS |
16 |
transaction-slot count in Pattern (0x03) |
MERKLE_DEPTH |
20 |
tree depth for Attestation (0x04), Membership (0x05), Non-Membership (0x06) |
MAX_PROVIDERS_MULTI |
5 |
signer-slot count in Compliance Multi-Signed (0x09) |
Value ranges. Each of the signals is a u32 in [0, 100]. Each of the weights is a u32 in [0, MAX_WEIGHT]. weight_sum is a u32, > 0. num_providers is a u32 in [1, MAX_PROVIDERS]. num_transactions is a u32 in [1, MAX_TRANSACTIONS]. jurisdiction_id is a u8 in [0, 4] (Jurisdiction Thresholds & Policies). In Compliance Multi-Signed (0x09) the per-slot signals range is not enforced in-circuit, since the slot signature attests the values; signers MUST sign only signals in [0, 100].
Public input encoding. Every public input is one element of the proof system’s scalar field (254-bit for UltraHonk over BN254). Booleans encode as 0 or 1. Addresses occupy the low 160 bits. u8, u32, and u64 values occupy the low bits with high bits zero. Public-input arrays declared by main() (e.g., the five signer_pubkey_hash_i of 0x09) take one field element per 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, so inactive slots add nothing to commitment hashes. In Compliance Multi-Signed (0x09), a signer slot is active if its public signer_pubkey_hash_i is non-zero; inactive slots carry no signature but still satisfy constraint 2 of Compliance Multi-Signed (0x09).
Domain tags. Every tagged Pedersen commitment prepends one of these field constants to its input array. The values are normative: signer registrations and provider signatures are computed against them. The nine values MUST be pairwise distinct. The provider-set, config, and transaction-set commitments are untagged fixed-arity hashes whose layouts differ from every tagged layout.
| Constant | Value | ASCII | Used by |
|---|---|---|---|
DOMAIN_INTERNAL |
0x4d4b4c5f494e54 |
MKL_INT |
Merkle internal nodes |
DOMAIN_LEAF_SET |
0x4d4b4c5f4c5354 |
MKL_LST |
leaf_hash_set |
DOMAIN_LEAF_VALUE |
0x4d4b4c5f56414c |
MKL_VAL |
leaf_hash_value |
DOMAIN_LEAF_SUBJECT |
0x4d4b4c5f535542 |
MKL_SUB |
leaf_hash_subject |
DOMAIN_CREDENTIAL |
0x435245445f48 |
CRED_H |
H_credential |
DOMAIN_SIGNED_SIGNALS |
0x5349475f53494753 |
SIG_SIGS |
H_signed for Compliance Signed (0x07) |
DOMAIN_RISK_SIGNED_SIGNALS |
0x52534b5f53494753 |
RSK_SIGS |
H_signed for Risk Score Signed (0x08) |
DOMAIN_MULTI_SIGNED_SIGNALS |
0x4d554c54495f5349 |
MULTI_SI |
H_multi_slot for Compliance Multi-Signed (0x09) |
DOMAIN_SIGNER_PUBKEY |
0x5349475f504b |
SIG_PK |
H_signer_pubkey |
Jurisdiction threshold lookup. Define highThreshold(jurisdictionId) to return the high-risk threshold, in basis points (bps), from the first table in Jurisdiction Thresholds & Policies.
Commitment Layouts
All commitments use the same Pedersen hash over the proof system’s scalar field. Fields are listed left to right.
H_provider_set(provider_ids[N], weights[N])withN = MAX_PROVIDERS. Input is a 16-entry array:[provider_ids[0], weights[0], provider_ids[1], weights[1], ..., provider_ids[7], weights[7]]. Inactive provider slots contribute(0, 0)pairs.H_config(weights[N])withN = MAX_PROVIDERS. Input is the 8-entryweightsarray, withu32weightspacked into field elements.H_tx_set(amounts[16], timestamps[16]). Input is a 32-entry array[amounts[0], timestamps[0], ..., amounts[15], timestamps[15]]withu64values packed into field elements. Inactive transaction 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 specificsubmitterat 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 areH(DOMAIN_INTERNAL, left, right). H_signed(domain, chain_id, oracle_address, provider_set_hash, signals[8], weights[8], timestamp, submitter). Used by Compliance Signed (0x07) withdomain = DOMAIN_SIGNED_SIGNALSand by Risk Score Signed (0x08) withdomain = DOMAIN_RISK_SIGNED_SIGNALS. Input is a 22-entry array:[domain, chain_id, oracle_address, provider_set_hash, signals[0..7], weights[0..7], timestamp, submitter]. The provider’s secp256k1 ECDSAsignatureis over the 32-byte big-endian serialization of this hash. Every signed proof type MUST use its own signing domain tag (Circuit Conventions); the tag binds asignatureto one proof type.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..7], weights[0..7], timestamp, submitter]. Each active slot’ssignatureis over this digest with its ownslot_index. The distinct tag stops a 0x07signaturefrom satisfying a 0x09 slot.H_signer_pubkey(pubkey_x, pubkey_y). Commits to a secp256k1 signing key. Each 32-byte coordinate splits into high and low 16-byte halves, since the BN254 scalar field cannot hold an arbitrary 256-bit integer. Input is[DOMAIN_SIGNER_PUBKEY, x_hi, x_lo, y_hi, y_lo](5 fields). Administrators register the result viaregisterSignerPubkeyHash.
Per-Type Circuit Specifications
Each circuit’s main() declares the inputs below and enforces the listed constraints. Constraint numbers are normative. Cross-cutting requirements (active-slot invariants, timestamp bounds, submitter != 0) are in Circuit Conventions and Circuit Constraints.
Compliance (0x01)
Public inputs (6, in order): jurisdiction_id (u8 in [0, 4]), provider_set_hash (Field), config_hash (Field), timestamp (u64), meets_threshold (bool), submitter (uint160, packed).
Private inputs: signals[8] (u32 each in [0, 100]), weights[8] (u32 each in [0, MAX_WEIGHT]), weight_sum (u32, > 0), provider_ids[8] (Field), num_providers (u32 in [1, MAX_PROVIDERS]).
Constraints:
- Active-slot invariant, per Circuit Conventions.
weight_sum == sum(weights[0..7])(sum over the full array; inactive provider slots contribute zero).provider_set_hash == H_provider_set(provider_ids, weights).config_hash == H_config(weights).- Risk score
s = floor( (sum_{i=0..7} signals[i] * weights[i]) * 100 / weight_sum ). meets_threshold == (s < highThreshold(jurisdiction_id)).timestampsatisfies Circuit Constraints.
Risk Score (0x02)
Public inputs (8, in order): proof_type (u8 in [1, 2], where 1 = threshold and 2 = range), direction (u8 in [1, 2], where 1 = greater than (GT) and 2 = less than (LT); ignored for range proofs), bound_lower (u32, in bps), bound_upper (u32, in bps; 0 for threshold proofs), result (bool), config_hash (Field), provider_set_hash (Field), submitter (uint160, packed).
Private inputs: same as Compliance (0x01).
Constraints: (1-5) identical to Compliance (0x01), substituting provider_set_hash and config_hash from this circuit’s public inputs. Then:
- If
proof_type == 1anddirection == 1:result == (s > bound_lower). - If
proof_type == 1anddirection == 2:result == (s < bound_lower). - If
proof_type == 2:bound_upper >= bound_lowerandresult == (bound_lower <= s <= bound_upper). - Reject any other
(proof_type, direction)combination.
The oracle MUST also reject trivially-true or unsatisfiable bounds (trivial-bound rule under RISK_SCORE (0x02) in Public Input Validation).
Pattern (0x03)
Public inputs (7, in order): analysis_type (u8 in [1, 3], where 1 = anti-structuring, 2 = velocity, and 3 = round-amounts), result (bool, where true = clean and false = structuring detected), reporting_threshold (u64), time_window (u64), tx_set_hash (Field), submitter (uint160, packed), settlement_root (Field).
Private inputs: amounts[16] (u64), timestamps[16] (u64), num_transactions (u32 in [1, MAX_TRANSACTIONS]).
Constraints:
- Active-slot invariant, per Circuit Conventions (inactive transaction slots:
amount = timestamp = 0). tx_set_hash == H_tx_set(amounts, timestamps).reporting_threshold > 0and bounded to prevent overflow of any per-analysis arithmetic.time_window > 0.- For each active slot
i,timestamps[i]satisfies Circuit Constraints. result == P(analysis_type, amounts, timestamps, num_transactions, reporting_threshold, time_window)for an implementation-defined deterministic predicatePthat is one of three families:analysis_type == 1: predicate overamountsandreporting_threshold.analysis_type == 2: predicate overtimestamps,num_transactions, andtime_window.analysis_type == 3: predicate overamountsandnum_transactions.
- Reject any other
analysis_type. settlement_rootis 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 (Field), credential_type (u8 in [1, 4], where 1 = KYC basic, 4 = institutional, 2 and 3 are reserved), is_valid (bool), credential_root (Field), current_timestamp (u64), submitter (uint160, packed).
Private inputs: credential_attribute (Field), expiry_timestamp (u64), merkle_index (Field), merkle_path[MERKLE_DEPTH] (Field).
Constraints:
credential_hash = H_credential(provider_id, submitter, credential_type, credential_attribute, expiry_timestamp).leaf = leaf_hash_value(credential_hash).compute_merkle_root(leaf, merkle_index, merkle_path) == credential_root.is_valid == (current_timestamp < expiry_timestamp) AND (credential_type >= 1 AND credential_type <= 4).
The oracle also validates that credential_root is registered against the proof’s provider_id, per Validation Registries.
Membership (0x05)
Public inputs (5, in order): merkle_root (Field), set_id (Field), timestamp (u64), is_member (bool), submitter (uint160, packed).
Private inputs: subject_salt (Field; 0 for public sets), merkle_index (Field), merkle_path[MERKLE_DEPTH] (Field).
Constraints:
leaf = leaf_hash_subject(submitter, set_id, subject_salt).is_member == (compute_merkle_root(leaf, merkle_index, merkle_path) == merkle_root).timestampsatisfies Circuit Constraints.
Non-Membership (0x06)
Public inputs (5, in order): merkle_root (Field), set_id (Field), timestamp (u64), is_non_member (bool), submitter (uint160, packed).
Private inputs: low_leaf (Field), low_leaf_salt (Field), low_index (Field), low_path[MERKLE_DEPTH] (Field), high_leaf (Field), high_leaf_salt (Field), high_index (Field), high_path[MERKLE_DEPTH] (Field).
Constraints:
low_leaf_hash = leaf_hash_subject(low_leaf, set_id, low_leaf_salt)andhigh_leaf_hash = leaf_hash_subject(high_leaf, set_id, high_leaf_salt).compute_merkle_root(low_leaf_hash, low_index, low_path) == merkle_root.compute_merkle_root(high_leaf_hash, high_index, high_path) == merkle_root.low_leaf < submitter < high_leaf(full-field comparison via bit-decomposition; see Non-Membership Proof Security).high_index == low_index + 1(adjacency).is_non_member == (clause 2 AND clause 3 AND clause 4 AND clause 5).timestampsatisfies Circuit Constraints.
Compliance Signed (0x07)
Public inputs (9, in order): jurisdiction_id, provider_set_hash, config_hash, timestamp, meets_threshold (the first five Compliance (0x01) inputs, same types), then signer_pubkey_hash (Field), chain_id (Field), oracle_address (Field), submitter (uint160, packed).
Private inputs: the Compliance (0x01) private inputs, plus signature (u8[64], secp256k1 ECDSA in raw r || s format), pubkey_x[32] (u8), pubkey_y[32] (u8).
Constraints: all Compliance (0x01) constraints (1-7), plus:
H_signer_pubkey(pubkey_x, pubkey_y) == signer_pubkey_hash.digest = H_signed(DOMAIN_SIGNED_SIGNALS, chain_id, oracle_address, provider_set_hash, signals, weights, timestamp, submitter).ecdsa_secp256k1_verify(pubkey_x, pubkey_y, signature, digest_be_bytes) == true, wheredigest_be_bytesis the 32-byte big-endian serialization ofdigest.
The oracle also validates signer_pubkey_hash is registered, chain_id == block.chainid, oracle_address == address(this), and timestamp freshness, per Public Input Validation.
Risk Score Signed (0x08)
Public inputs (12, in order): proof_type (u8), direction (u8), bound_lower (u32), bound_upper (u32), result (bool), config_hash (Field), provider_set_hash (Field), timestamp (Field), signer_pubkey_hash (Field), chain_id (Field), oracle_address (Field), submitter (uint160, packed). The first seven match Risk Score (0x02). timestamp is the provider’s signing time.
Private inputs: the Risk Score (0x02) private inputs, plus signature (u8[64], secp256k1 ECDSA in raw r || s format), pubkey_x[32] (u8), pubkey_y[32] (u8) as in Compliance Signed (0x07).
Constraints: Risk Score (0x02) constraints (1-9), plus:
timestampsatisfies Circuit Constraints.H_signer_pubkey(pubkey_x, pubkey_y) == signer_pubkey_hash.digest = H_signed(DOMAIN_RISK_SIGNED_SIGNALS, chain_id, oracle_address, provider_set_hash, signals, weights, timestamp, submitter).ecdsa_secp256k1_verify(pubkey_x, pubkey_y, signature, digest_be_bytes) == true.
The oracle also validates signer_pubkey_hash is registered, chain_id == block.chainid, oracle_address == address(this), and timestamp freshness, per Public Input Validation.
Compliance Multi-Signed (0x09)
Public inputs (14, in order): jurisdiction_id (u8 in [0, 4]), provider_set_hash (Field), config_hash (Field), timestamp (u64), meets_threshold (bool), threshold_m (u8), signer_pubkey_hash_i (where i = 0, ..., MAX_PROVIDERS_MULTI - 1), chain_id (Field), oracle_address (Field), submitter (uint160, packed).
Private inputs: per-slot arrays of size 5, each with signals[8] (u32 each in [0, 100]), weights[8] (u32 each <= MAX_WEIGHT), weight_sum (u32 > 0), pubkey_x[32] (u8), pubkey_y[32] (u8), signature (u8[64]; secp256k1 ECDSA in raw r || s format). Inactive signer 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 - 1:
- Let
active_i = (signer_pubkey_hash_i != 0). weight_sum_i > 0andweight_sum_i == sum_j weights_i[j], for all slots, so the denominator cannot be inflated.slot_score_i = floor( (sum_j signals_i[j] * weights_i[j]) * 100 / weight_sum_i ).slot_digest_i = H_multi_slot(i, chain_id, oracle_address, jurisdiction_id, provider_set_hash, config_hash, signals_i, weights_i, timestamp, submitter).- If
active_i:H_signer_pubkey(pubkey_x_i, pubkey_y_i) == signer_pubkey_hash_i;ecdsa_secp256k1_verify(pubkey_x_i, pubkey_y_i, signature_i, slot_digest_i_be_bytes) == true;slot_score_i < highThreshold(jurisdiction_id). - Only constraint 5 is gated by
active_i. On inactive slotspubkey_x_i,pubkey_y_iandsignature_iare unconstrained; constraints 2-4 apply to every slot.
Cross-slot:
count(active_i for i in 0..MAX_PROVIDERS_MULTI - 1) >= threshold_m(andthreshold_m in [1, MAX_PROVIDERS_MULTI]).- All non-zero
signer_pubkey_hash_iMUST be pairwise distinct (no signer fills two slots). meets_threshold == true(field1). The step 5 checks are hard asserts, so no proof exists withmeets_threshold = false; the field exists for layout parity with Compliance Signed (0x07) and oracle routing.timestampsatisfies Circuit Constraints.
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 verification to per-proof-type verifier contracts generated by the ZK backend (e.g., bb write_solidity_verifier for UltraHonk; see Reference Implementation).
interface IERC8262Verifier {
/// @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
function verifyProof(
uint8 proofType,
bytes calldata proof,
bytes calldata publicInputs
) external view returns (bool valid);
/// @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
function verifyProofBatch(
uint8[] calldata proofTypes,
bytes[] calldata proofs,
bytes[] calldata publicInputs
) external view returns (bool valid);
/// @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)
function getVerifier(uint8 proofType) external view returns (address verifier);
/// @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
function verifyProofAtVersion(
uint8 proofType,
uint256 version,
bytes calldata proof,
bytes calldata publicInputs
) external view returns (bool valid);
/// @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
function getVerifierAtVersion(uint8 proofType, uint256 version) external view returns (address verifier);
/// @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)
function getVerifierVersion(uint8 proofType) external view returns (uint256 version);
}
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 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 oracle and mutate attestation state.
Batch Verification Limits
Implementations MUST enforce a maximum batch size for verifyProofBatch and submitComplianceBatch. The cap MUST be chosen so a full batch fits within the target chain’s block gas limit with headroom for submission overhead (registry lookups, replay-guard SSTORE, event emission). The reference caps both at 10; a full 10-entry submitComplianceBatch costs about 24.1 M gas, within a 30 M-gas block. Deployments on chains with larger or smaller block budgets MUST recalibrate.
Oracle Interface
interface IERC8262Oracle {
struct ComplianceAttestation {
address subject; // address that proved compliance (msg.sender at submission)
uint8 jurisdictionId; // jurisdiction (0=EU, 1=US, 2=UK, 3=SG, 4=UAE)
uint8 proofType; // which proof type produced this attestation (0x01-0x09)
bool meetsThreshold; // whether the rule was satisfied
uint256 timestamp; // block.timestamp at submission
uint256 expiresAt; // block.timestamp + attestationTTL
bytes32 proofHash; // keccak256(proof, proofType, chainId, oracleAddr) -- see Proof Hash Computation
bytes32 providerSetHash; // hash of providers + weights (0x01/0x07/0x09 only; bytes32(0) otherwise)
bytes32 publicInputsHash; // keccak256(publicInputs)
address verifierUsed; // verifier contract address at submission time (TOCTOU-safe)
}
event ComplianceVerified(
address indexed subject,
uint8 indexed jurisdictionId,
bool meetsThreshold,
bytes32 indexed proofHash,
uint256 expiresAt,
uint256 previousExpiresAt
);
event ProviderWeightsUpdated(
bytes32 indexed configHash,
uint256 timestamp,
string metadataURI
);
event AttestationTTLUpdated(uint256 oldTTL, uint256 newTTL);
event ConfigRevoked(bytes32 indexed configHash);
event MerkleRootRegistered(uint8 indexed proofType, bytes32 indexed merkleRoot);
event MerkleRootRevoked(uint8 indexed proofType, bytes32 indexed merkleRoot);
event ReportingThresholdRegistered(bytes32 indexed threshold);
event ReportingThresholdRevoked(bytes32 indexed threshold);
/// @notice Submit a compliance proof and record the attestation
/// @param jurisdictionId Target jurisdiction (0=EU, 1=US, 2=UK, 3=SG, 4=UAE)
/// @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
function submitCompliance(
uint8 jurisdictionId,
uint8 proofType,
bytes calldata proof,
bytes calldata publicInputs,
bytes32 providerSetHash
) external returns (ComplianceAttestation memory attestation);
/// @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, 4=UAE)
/// @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
function submitComplianceBatch(
uint8 jurisdictionId,
uint8[] calldata proofTypes,
bytes[] calldata proofs,
bytes[] calldata publicInputs,
bytes32[] calldata providerSetHashes
) external returns (ComplianceAttestation[] memory attestations);
/// @notice Check if an address has a valid (non-expired) compliance attestation
/// @dev Reads the jurisdiction's compliance slot, which only COMPLIANCE_MULTI_SIGNED
/// and, where the multi-provider floor is 1, COMPLIANCE / COMPLIANCE_SIGNED write.
/// @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
function checkCompliance(
address subject,
uint8 jurisdictionId
) external view returns (bool valid, ComplianceAttestation memory attestation);
/// @notice Check the latest attestation of one proof type
/// @dev Every accepted proof writes its own (subject, jurisdiction, proofType) slot.
/// @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
function checkComplianceByType(
address subject,
uint8 jurisdictionId,
uint8 proofType
) external view returns (bool valid, ComplianceAttestation memory attestation);
/// @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
function getHistoricalProof(
bytes32 proofHash
) external view returns (ComplianceAttestation memory attestation);
/// @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)
function getProofType(bytes32 proofHash) external view returns (uint8 proofType);
/// @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
function getAttestationHistory(
address subject,
uint8 jurisdictionId
) external view returns (bytes32[] memory proofHashes);
/// @notice Get the current provider weight configuration hash
/// @return configHash Hash of current provider weights
function providerConfigHash() external view returns (bytes32 configHash);
/// @notice Get the current attestation time-to-live
/// @return ttl Duration in seconds that attestations remain valid
function attestationTTL() external view returns (uint256 ttl);
}
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:
proofHash = keccak256(abi.encodePacked(proof, proofType, block.chainid, address(this)))
proofType scopes uniqueness per proof type. block.chainid and address(this) prevent on-chain replay across forks or oracle deployments. In-circuit chain binding exists only in the signed variants (Public Input Validation).
Jurisdiction Thresholds & Policies
Values below are current at publication.
Implementations MUST publish jurisdiction thresholds openly. Risk scores are expressed in bps (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 |
Implementations MUST publish two per-jurisdiction policy values alongside the thresholds: whether unsigned screening proofs (Compliance (0x01), Risk Score (0x02)) are accepted, and the minimum threshold_m for Compliance Multi-Signed (0x09). Reference values:
| 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 unsigned screening proofs for any jurisdiction whose “Accepts unsigned” column is no, and MUST reject Compliance Multi Signed (0x09) 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 Compliance Multi Signed (0x09)-only with M >= 2 if the regulator requires provider attestation, and the EU defaults (accepts Compliance (0x01) with 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
Attestation TTL:
- Default TTL: 86400 seconds (24 hours)
- Minimum TTL: 3600 seconds (1 hour)
- Maximum TTL: 2592000 seconds (30 days)
expiresAt = block.timestamp + attestationTTLat submission time
checkCompliance() MUST return false for expired attestations. Expired attestations MUST remain retrievable via getHistoricalProof() for proof-of-innocence (POI). The oracle administrator updates the TTL via updateAttestationTTL().
Provider Weight Publication
Implementations SHOULD publish provider weights as an on-chain configuration hash. Weight changes MUST emit ProviderWeightsUpdated with the new configHash, timestamp, and an optional metadataURI pointing to the full configuration (e.g., on IPFS or Arweave).
Provider configuration MUST be versioned. Implementations SHOULD keep a history of configuration hashes, recording which weights were active when a proof was generated. Implementations SHOULD support revoking historical configuration hashes found 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 (e.g., 256 entries, first in first out (FIFO) eviction of the oldest non-current entries). An evicted configuration MUST stop being accepted, and registering a configuration hash that is already registered (current or historical) MUST revert.
Proof Type Routing
Implementations MUST maintain a registry mapping each proof type to a per-circuit verifier contract, each with its own verification key. The main verifier contract routes:
- Caller specifies
proofType(0x01–0x09) - Router looks up the registered verifier for that type
- Public inputs are decoded from packed
bytestobytes32[] - The per-circuit verifier’s
verify(bytes, bytes32[])is called
Verifier addresses are updatable to allow circuit upgrades; see Administrative Operations.
Verifier Versioning
Verifier upgrades MUST NOT invalidate proofs that were valid under a prior verifier. verifierUsed alone is not enough: a counterparty may need to re-run verification months later, e.g., to recompute the POI after a circuit bug or to audit a historical attestation.
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 found 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; instead, propose a replacement through the upgrade timelock, then revoke the prior version.
Revocation MAY have two paths: a delayed default path and an immediate path gated behind the guardian role, for locking in revocation of a paused proof type before the timelock elapses. The reference delays the routine path 6 h.
Revoking a version MUST also withdraw the attestations it verified: checkCompliance and checkComplianceByType MUST NOT report as valid an attestation whose verifierUsed belongs to a revoked version. Implementations MUST NOT revoke an older version whose address is the current verifier, and MUST NOT reinstall a revoked verifier address. Revoking a signer key, configuration, or root blocks new submissions only; implementations SHOULD give the guardian role a way to invalidate individual attestations already issued under a compromised one (the reference implementation provides invalidateAttestation(proofHash) and isAttestationValid(proofHash)).
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.
Public Input Validation
Implementations MUST validate public inputs semantically for each proof type before forwarding to the per-circuit verifier. The circuit guarantees internal consistency; the oracle MUST verify that the committed inputs match the expected context (e.g., a known config hash, a registered merkle root), or a proof generated for one context can be replayed in another.
Public inputs MUST be 32-byte aligned. Implementations MUST reject publicInputs where length % 32 != 0.
These checks apply at the submission boundary, in addition to the in-circuit constraints in Per-Type Circuit Specifications. A proof that satisfies its circuit but fails these checks MUST be rejected.
Each per-type table lists every public input at its circuit index. -- marks an input the Oracle deliberately does not check; the row states why.
Cross-cutting rules
These apply wherever the named input is present.
| Rule | Applies to | Requirement |
|---|---|---|
| Submitter binding | all proof types | submitter MUST equal msg.sender. See Submitter Binding. |
| Positive result | all proof types | The boolean result field MUST be exactly bytes32(uint256(1)). See Proof Result Validation. |
| Timestamp freshness | 0x01, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09 | The timestamp is prover-chosen and only loosely bounded in-circuit (Circuit Constraints). It MUST NOT be in the future and MUST be within an implementation-defined maximum age (reference: 1 hour). For 0x07 and 0x08 it is the provider’s signing time, committed in H_signed, so this rule also expires the provider signature. |
| Timestamp ratchet | all proof types | Implementations MUST maintain a non-decreasing timestamp per (subject, jurisdiction, proofType) and reject submissions below it, so a stale proof cannot overwrite a newer attestation of the same type. Implementations MUST also maintain one per (subject, jurisdiction) for the compliance slot, advanced only by proofs that write that slot. Equal values MUST remain acceptable. Types without a timestamp input ratchet on block.timestamp in their per-type slot only. |
| Compliance slot | 0x01, 0x07, 0x09 | checkCompliance MUST reflect only proofs that satisfy the jurisdiction’s full policy: Compliance Multi-Signed (0x09), and Compliance (0x01) or Compliance Signed (0x07) only where the jurisdiction’s minMultiProviderThreshold is 1. Every other accepted proof, including Risk Score variants, MUST be recorded only in its per-type slot, readable via checkComplianceByType. |
| Jurisdiction policy | 0x01, 0x02 (rejection), 0x09 (threshold_m) |
Unsigned screening proofs MUST be rejected for jurisdictions requiring signed signals. See Jurisdiction Thresholds & Policies. |
COMPLIANCE (0x01)
| # | Public input | Oracle validation |
|---|---|---|
| 0 | jurisdiction_id |
MUST equal the jurisdictionId argument of submitCompliance(). |
| 1 | provider_set_hash |
MUST equal the providerSetHash argument of submitCompliance(). |
| 2 | config_hash |
MUST be registered and non-revoked in the config hash registry, and MUST NOT expand to any denied provider. |
| 3 | timestamp |
Timestamp freshness; timestamp ratchet. |
| 4 | meets_threshold |
Positive result. |
| 5 | submitter |
Submitter binding. |
RISK_SCORE (0x02)
| # | Public input | Oracle validation |
|---|---|---|
| 0 | proof_type |
MUST be 1 (threshold) or 2 (range); any other value MUST be rejected. |
| 1 | direction |
For proof_type == 1, MUST be 1 (GT) or 2 (LT). Ignored for range proofs. |
| 2 | bound_lower |
MUST NOT express a trivially-true or unsatisfiable claim (see below). |
| 3 | bound_upper |
For range proofs, MUST exceed bound_lower and MUST NOT exceed 10000. |
| 4 | result |
Positive result. |
| 5 | config_hash |
MUST be registered and non-revoked in the config hash registry, and MUST NOT expand to any denied provider. |
| 6 | provider_set_hash |
-- Bound in-circuit to the committed providers and weights, but not cross-checked against any caller argument or registry. |
| 7 | submitter |
Submitter binding. |
Trivial-bound rejection: for direction == GT, bound_lower == 0 or bound_lower >= 10000; for direction == LT, bound_lower == 0 or bound_lower > 10000; for range proofs, the full domain [0, 10000].
PATTERN (0x03)
| # | Public input | Oracle validation |
|---|---|---|
| 0 | analysis_type |
MUST be 1 (anti-structuring), 2 (velocity), or 3 (round-amount). Consumers requiring a specific analysis check it themselves (see Proof Types). |
| 1 | result |
Positive result. |
| 2 | reporting_threshold |
MUST be registered in the reporting threshold registry. |
| 3 | time_window |
MUST meet an implementation-defined minimum (reference: 3600 seconds). |
| 4 | tx_set_hash |
MUST be non-zero. |
| 5 | submitter |
Submitter binding. |
| 6 | settlement_root |
-- Opaque; downstream binding is the consumer’s job (see Proof Types). |
ATTESTATION (0x04)
| # | Public input | Oracle validation |
|---|---|---|
| 0 | provider_id |
MUST be non-zero and MUST equal the providerId recorded against the referenced credential root. |
| 1 | credential_type |
-- Constrained in-circuit; the Oracle applies no additional check. |
| 2 | is_valid |
Positive result. |
| 3 | credential_root |
MUST be registered in the credential root registry, MUST NOT be revoked, and MUST be within its TTL (reference: 48 hours). |
| 4 | current_timestamp |
Timestamp freshness; timestamp ratchet. |
| 5 | submitter |
Submitter binding. |
MEMBERSHIP (0x05)
| # | Public input | Oracle validation |
|---|---|---|
| 0 | merkle_root |
MUST be registered for MEMBERSHIP, and non-revoked, in the merkle root registry. |
| 1 | set_id |
-- Bound in-circuit to the leaf hash; the Oracle applies no additional check. |
| 2 | timestamp |
Timestamp freshness; timestamp ratchet. |
| 3 | is_member |
Positive result. |
| 4 | submitter |
Submitter binding. |
NON_MEMBERSHIP (0x06)
| # | Public input | Oracle validation |
|---|---|---|
| 0 | merkle_root |
MUST be registered for NON_MEMBERSHIP, and non-revoked, in the merkle root registry. |
| 1 | set_id |
-- Bound in-circuit to the leaf hash; the Oracle applies no additional check. |
| 2 | timestamp |
Timestamp freshness; timestamp ratchet. |
| 3 | is_non_member |
Positive result. |
| 4 | submitter |
Submitter binding. |
COMPLIANCE_SIGNED (0x07)
| # | Public input | Oracle validation |
|---|---|---|
| 0 | jurisdiction_id |
MUST equal the jurisdictionId argument of submitCompliance(). |
| 1 | provider_set_hash |
MUST equal the providerSetHash argument of submitCompliance(). |
| 2 | config_hash |
MUST be registered and non-revoked in the config hash registry, and MUST NOT expand to any denied provider. |
| 3 | timestamp |
Timestamp freshness; timestamp ratchet. |
| 4 | meets_threshold |
Positive result. |
| 5 | signer_pubkey_hash |
MUST be registered in the signer pubkey hash registry, to a provider that is not denied. |
| 6 | chain_id |
MUST equal block.chainid. |
| 7 | oracle_address |
MUST equal address(this). |
| 8 | submitter |
Submitter binding. |
RISK_SCORE_SIGNED (0x08)
| # | Public input | Oracle validation |
|---|---|---|
| 0 | proof_type |
As RISK_SCORE index 0. |
| 1 | direction |
As RISK_SCORE index 1. |
| 2 | bound_lower |
As RISK_SCORE index 2. |
| 3 | bound_upper |
As RISK_SCORE index 3. |
| 4 | result |
Positive result. |
| 5 | config_hash |
MUST be registered and non-revoked in the config hash registry, and MUST NOT expand to any denied provider. |
| 6 | provider_set_hash |
-- As RISK_SCORE index 6: bound in-circuit, not cross-checked at the submission boundary. |
| 7 | timestamp |
Timestamp freshness; timestamp ratchet. |
| 8 | signer_pubkey_hash |
MUST be registered in the signer pubkey hash registry, to a provider that is not denied. |
| 9 | chain_id |
MUST equal block.chainid. |
| 10 | oracle_address |
MUST equal address(this). |
| 11 | submitter |
Submitter binding. |
COMPLIANCE_MULTI_SIGNED (0x09)
| # | Public input | Oracle validation |
|---|---|---|
| 0 | jurisdiction_id |
MUST equal the jurisdictionId argument of submitCompliance(). |
| 1 | provider_set_hash |
MUST equal the providerSetHash argument of submitCompliance(). |
| 2 | config_hash |
MUST be registered and non-revoked in the config hash registry, and MUST NOT expand to any denied provider. |
| 3 | timestamp |
Timestamp freshness; timestamp ratchet. |
| 4 | meets_threshold |
Positive result. |
| 5 | threshold_m |
MUST be in [1, MAX_PROVIDERS_MULTI], MUST NOT exceed the number of active signer slots, and MUST be at least the jurisdiction’s minMultiProviderThreshold. |
| 6 | signer_pubkey_hash_0 |
If non-zero, MUST be registered in the signer pubkey hash registry, MUST be distinct from every other slot, and MUST belong to a different provider from every other active slot. |
| 7 | signer_pubkey_hash_1 |
As index 6. |
| 8 | signer_pubkey_hash_2 |
As index 6. |
| 9 | signer_pubkey_hash_3 |
As index 6. |
| 10 | signer_pubkey_hash_4 |
As index 6. |
| 11 | chain_id |
MUST equal block.chainid. |
| 12 | oracle_address |
MUST equal address(this). |
| 13 | submitter |
Submitter binding. |
Proof Result Validation
Each proof type has a boolean result field (meets_threshold, result, is_valid, is_member, is_non_member). A valid proof with a false result proves the condition is NOT satisfied. Implementations MUST reject proofs where the result field is not true (encoded as bytes32(uint256(1))).
The providerSetHash parameter of submitCompliance() is meaningful only for COMPLIANCE, COMPLIANCE_SIGNED, and COMPLIANCE_MULTI_SIGNED. For those three, implementations MUST assert that it equals the proof’s provider_set_hash public input, and MUST store it in the attestation. RISK_SCORE and RISK_SCORE_SIGNED bind provider_set_hash in-circuit only; other types have none. For every proof type outside the compliance family, implementations MUST ignore the caller-supplied providerSetHash and store bytes32(0) in the attestation.
Validation Registries
Implementations MUST maintain on-chain registries for the values public inputs are validated against.
Config hash registry. Tracks valid provider weight configuration hashes, added when the administrator updates the configuration. Revocation rules are in Provider Weight Publication.
Merkle root registry. Tracks valid merkle roots for MEMBERSHIP and NON_MEMBERSHIP proofs (e.g., sanctions lists, whitelists). Roots MUST be registered by the administrator before proofs referencing them can be accepted. Registration MUST be per proof type: a root registered for MEMBERSHIP MUST NOT satisfy NON_MEMBERSHIP, and the reverse. Both circuits share the leaf_hash_subject layout, so a shared registry would let a denylist root accept a MEMBERSHIP proof that the subject is on the denylist. 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. The publisher SHOULD publish new credential roots periodically, replacing prior ones. Roots SHOULD have a finite TTL window during which they are accepted, so users with paths against an outgoing root can still submit while a new root propagates. Implementations MUST verify the proof’s provider_id matches the registered providerId for the referenced credential root.
Reporting threshold registry. Tracks valid reporting thresholds for PATTERN (anti-structuring) proofs; each jurisdiction defines its own (e.g., $10,000 under the United States Bank Secrecy Act (BSA)). Thresholds MUST be registered before proofs referencing them can be accepted.
Signer pubkey hash registry. Tracks the H_signer_pubkey commitments of providers authorized to sign screening payloads for the signed variants (0x07, 0x08, 0x09). Each registered commitment MUST be bound to the provider that operates the key; a provider MAY hold several keys. A signer_pubkey_hash public input that is not registered, or whose provider is denied, MUST be rejected. Compliance Multi-Signed (0x09) MUST count providers, not keys: two active slots whose keys belong to the same provider MUST be rejected, since one provider holding two keys (e.g., during rotation) would otherwise satisfy threshold_m = 2 alone. The in-circuit ECDSA check proves only that some key signed; this registry makes it an authorized provider’s key.
Denied provider registry. Tracks provider IDs that MUST NOT contribute to an accepted compliance proof, whether or not a configuration committing to them is still registered. Implementations MUST maintain this registry. They MUST record, for each registered config hash, the provider IDs that hash expands to, and MUST reject any proof whose config_hash (0x01, 0x02, 0x07, 0x08, 0x09) expands to a denied provider. Signed proofs are also rejected when their signer key belongs to a denied provider (see Signer pubkey hash registry). For the unsigned variants provider_set_hash is prover-chosen, so the denylist constrains only the registered configuration. The expansion MUST be recorded atomically with the configuration registration, so a partially-applied rotation cannot silently disable enforcement.
Registry idempotency. Across all registries, re-registering an already-registered value MUST revert, and revoking a value that is not registered MUST revert.
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), and SHOULD support immediate credential root revocation (no delay) so a malicious root 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:
\[\text{RiskScore}_{\text{bps}} = \frac{\displaystyle\sum_{i=1}^{N} \text{signal}_i \cdot \text{weight}_i}{W} \times 100\]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$, and $N \leq 8$ is the number of active providers. The result is in basis points ($0$-$10000$).
Circuits that accept weight_sum as a private input MUST constrain it to equal the actual sum of the weights array, so a prover cannot choose the denominator.
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)
Score integrity depends on provider independence. Implementations whose threat model includes collusion among a subset of providers SHOULD require attestations from multiple independent providers (e.g., COMPLIANCE_MULTI_SIGNED with threshold_m >= 2) and SHOULD weight providers by 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 an implementation-defined far-future bound MUST be rejected in-circuit; the reference rejects values of 2^40 (~year 36812) or more. When timestamp is a field element, the circuit MUST range-check the full value (e.g., to 64 bits) before narrowing it, since a truncating cast would accept t + 2^64 for any in-range t. This applies to COMPLIANCE, COMPLIANCE_SIGNED, RISK_SCORE_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.
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 uses Pedersen from the Noir standard library.
Pedersen commitments are additively homomorphic over the underlying 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, which has stronger random-oracle properties, once its high-level APIs stabilize in the circuit language.
Merkle Tree Domain Separation
Implementations MUST use distinct domain tags for leaf and internal-node hashes, so no leaf hash can collide with an internal node (second-preimage attack). The reference uses three tags: internal nodes, set-style leaves bound to (element, set_id), and value-style leaves committing a single value (e.g., credential_hash).
Fixed-arity Pedersen does NOT domain-separate by arity (e.g., H([a, b, 0]) == H([a, b]) without a length tag). Implementations MUST therefore include an explicit domain tag in the input array.
Non-Membership Proof Security
The non-membership circuit proves the submitter is NOT in a sorted Merkle tree by adjacency: two consecutive leaves $l$ and $h$ satisfy $l < \text{submitter} < h$ AND $\text{high_index} = \text{low_index} + 1$.
Without adjacency, two non-adjacent entries could bracket the submitter and hide an intermediate entry equal to the submitter. 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 neighbors.
Comparison MUST be performed over the full Field range using bit-decomposition (e.g., Noir’s Field::lt; see Reference Implementation), so arbitrary-width identifiers (addresses, hashes) compare without truncation.
Submitter Binding
Implementations MUST bind every proof to its submitter via the submitter public input. The comparison MUST cover the full 32-byte public-input word: submitter MUST equal uint256(uint160(msg.sender)) exactly, and a value that matches msg.sender only in its low 160 bits MUST be rejected, because the circuit proves its claim about the full field value. For proofs about a specific party (membership, non-membership, attestation), the leaf format MUST also bind to submitter in-circuit:
- Membership / non-membership:
leaf_hash_subject(value, set_id, salt)wherevaluederives from the relevant party (e.g.,submitterfor membership; the bracketing tree entries for non-membership ordering). - Attestation:
credential_hash = H(DOMAIN_CREDENTIAL, provider_id, submitter, credential_type, credential_attribute, expiry_timestamp), thenleaf_hash_value(credential_hash).
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
Counterparties to retroactively flagged addresses can present the original attestation (via getHistoricalProof()) as proof-of-innocence that the address was clean at transaction time.
Pause Mechanism
Implementations SHOULD support pausing proof submission, to contain a circuit or verifier vulnerability without redeploying. Pause MUST NOT block read access to existing attestations (checkCompliance, checkComplianceByType, getHistoricalProof, getAttestationHistory), since proof-of-innocence depends on them. Implementations SHOULD support per-proof-type pause in addition to global pause.
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. Implementations SHOULD timelock critical operations (verifier replacement, TTL changes, weight updates) in production. Implementations SHOULD split administrative authority into role classes (e.g., 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 uses three roles (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, so integrators and auditors can compare the published policy against 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. With client-side proving the raw data never leaves the user’s device; the verifier learns only the boolean result.
Why published weights? Opaque compliance algorithms invite regulatory skepticism and legal challenge. Published weights and thresholds make the system auditable without exposing individuals, and weight adjustments are visible.
Why on-chain attestations? Off-chain attestations can be forged, lost, or denied. Proof-of-innocence needs a record retrievable years after the transaction.
Why not Privacy Pools inclusion/exclusion proofs? Privacy Pools prove set membership (“I’m not in the OFAC set”). This ERC proves rule compliance (“my risk score under jurisdiction X is below threshold Y using providers A, B, C”). Set membership is a subset of regulatory compliance.
Why attestation TTL? Compliance status changes as providers update their data. The TTL forces periodic re-attestation, with the window configurable per deployment.
Why nine proof types? Each type is a separate circuit with distinct constraint logic. The signed variants 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[] witness, while a signed proof attests them. Separation keeps circuits small and auditable and lets unsigned-tolerant jurisdictions avoid the signature-verification cost.
What this standard does NOT prove
The cryptographic guarantees cover correct computation, not honest inputs. Three trust tiers exist; integrators pick the tier matching 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 suits jurisdictions that permit user-asserted compliance (some EU and UK contexts), flows where a downstream system checks signal honesty, and composed proofs. A self-attested COMPLIANCE submitter can 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 a deliberate tradeoff.
Normative counterparts: Public Input Validation and Trust Tier Disclosure.
Related Work
ERC-3643 (T-REX). The compliance token standard for regulated securities. It requires identity revelation via on-chain identity claims verified by trusted issuers; this ERC proves compliance without revealing identity, signals, or amounts. This ERC could serve as a ZK identity provider within an ERC-3643 deployment.
Privacy pools. Users prove a withdrawal originates from a “clean” deposit set, with association set providers maintaining approved lists. This ERC extends the model beyond set membership to risk scoring, anti-structuring, credential verification, and membership/non-membership proofs.
Proof-gated token transfers. An oracle-permissioned ERC-20 can validate each transfer with a zkVM proof against off-chain ISO 20022 payment instructions, gating one token through one oracle with one proof type. This ERC provides standalone attestations with nine proof types, usable by any contract.
ERC-7812. A ZK identity registry: a singleton Sparse Merkle Tree (80-level, Poseidon on BN128) with custom registrars, deployed on mainnet. This ERC could operate as a compliance registrar within it.
Smart-account ZK verifier interfaces. A proof-system-agnostic verifier interface for smart accounts (e.g., verifyProof(bytes,bytes) returns (bytes4)) can use a non-reverting return pattern following ERC-1271. This ERC’s routing adds domain-specific semantics (proof type routing, batch verification, version history); each UltraHonk verifier could be wrapped behind such an adapter.
EIP-7702. When a 7702-delegated EOA calls submitCompliance, msg.sender is the EOA, so the submitter public input must equal the EOA. A 7702 batcher can call submitComplianceBatch provided every entry’s submitter equals the delegating EOA. Account-abstraction wallets should show the bound submitter to the user before submission, since a malicious dApp could otherwise solicit proofs bound to the wrong address. The same applies to ERC-4337 paymasters and ERC-1271 contract signers used as compliance subjects.
ZK credential anchors. Non-transferable credential anchors with ZK presentation support predicate proofs (“score >= threshold”) that parallel RISK_SCORE. Such systems target credential issuance and presentation; this ERC targets compliance attestation and retroactive verification.
Generic zk-SNARK verifier interface (2019, stagnant). Defines a generic on-chain verification interface with dynamic arrays. This ERC instead uses per-proof-type routing, UltraHonk support, and domain-specific input validation.
Backwards Compatibility
This ERC introduces new interfaces and modifies no existing standard. It complements, but does not depend on, ERC-5564 (stealth addresses) and ERC-6538 (stealth meta-address registry).
Test Cases
Binary fixtures for all nine proof types are in test/fixtures/<circuit>/ of the reference implementation. The three signed fixtures (COMPLIANCE_SIGNED, RISK_SCORE_SIGNED, COMPLIANCE_MULTI_SIGNED) are signed by public test keys and bind chain ID 31337 and the reference integration test’s Oracle address, so only that deployment accepts them. Every fixture uses timestamp = 1700000000, so the freshness rule accepts timestamped fixtures only while block.timestamp is in [1700000000, 1700003600]. Each fixture contains:
proof: the raw UltraHonk proof bytes (8640 bytes for the unsigned types; 9024 bytes for COMPLIANCE_SIGNED and RISK_SCORE_SIGNED; 9792 bytes for COMPLIANCE_MULTI_SIGNED)public_inputs: the packed bytes32 public inputs
| Proof Type | Public Inputs Size | Logical Public Inputs |
|---|---|---|
| COMPLIANCE | 192 bytes (6 inputs) | jurisdiction_id, provider_set_hash, config_hash, timestamp, meets_threshold, submitter |
| RISK_SCORE | 256 bytes (8 inputs) | proof_type, direction, bound_lower, bound_upper, result, config_hash, provider_set_hash, submitter |
| PATTERN | 224 bytes (7 inputs) | analysis_type, result, reporting_threshold, time_window, tx_set_hash, submitter, settlement_root |
| ATTESTATION | 192 bytes (6 inputs) | provider_id, credential_type, is_valid, credential_root, current_timestamp, submitter |
| MEMBERSHIP | 160 bytes (5 inputs) | merkle_root, set_id, timestamp, is_member, submitter |
| NON_MEMBERSHIP | 160 bytes (5 inputs) | merkle_root, set_id, timestamp, is_non_member, submitter |
| COMPLIANCE_SIGNED | 288 bytes (9 inputs) | jurisdiction_id, provider_set_hash, config_hash, timestamp, meets_threshold, signer_pubkey_hash, chain_id, oracle_address, submitter |
| RISK_SCORE_SIGNED | 384 bytes (12 inputs) | proof_type, direction, bound_lower, bound_upper, result, config_hash, provider_set_hash, timestamp, signer_pubkey_hash, chain_id, oracle_address, submitter |
| COMPLIANCE_MULTI_SIGNED | 448 bytes (14 inputs) | jurisdiction_id, provider_set_hash, config_hash, timestamp, meets_threshold, threshold_m, signer_pubkey_hash_0..4, chain_id, oracle_address, submitter |
Fixtures use Pedersen hash (Noir stdlib) for commitments and Merkle trees, and are regenerated via scripts/generate-fixtures.sh.
Witness Annex
The Prover.toml inputs behind the binary fixtures, for cross-validation. All submitter values are 0xdead and all timestamp values are 1700000000 (2023-11-14). Addresses are packed as field elements; Pedersen hashes are over BN254.
# circuits/compliance/Prover.toml
signals = [20, 0, 0, 0, 0, 0, 0, 0]
weights = [100, 0, 0, 0, 0, 0, 0, 0]
weight_sum = 100
provider_ids = ["1", "0", "0", "0", "0", "0", "0", "0"]
num_providers = 1
jurisdiction_id = 0 # EU
provider_set_hash = "0x14b6becf762f80a24078e62fc9a7eca246b8e406d19962dda817b173f30a94b2"
config_hash = "0x18574f427f33c6c77af53be06544bd749c9a1db855599d950af61ea613df8405"
timestamp = "1700000000"
meets_threshold = true
submitter = "0xdead"
# Derived risk score: 20 * 100 / 100 * 100 = 2000 bps (below EU 7100 trigger).
# circuits/risk_score/Prover.toml
signals = [60, 0, 0, 0, 0, 0, 0, 0]
weights = [100, 0, 0, 0, 0, 0, 0, 0]
weight_sum = 100
provider_ids = ["1", "0", "0", "0", "0", "0", "0", "0"]
num_providers = 1
proof_type = 1 # threshold
direction = 1 # GT
bound_lower = 5000 # asserts score > 5000 bps
bound_upper = 0
result = true
config_hash = "0x18574f427f33c6c77af53be06544bd749c9a1db855599d950af61ea613df8405"
provider_set_hash = "0x14b6becf762f80a24078e62fc9a7eca246b8e406d19962dda817b173f30a94b2"
submitter = "0xdead"
# Derived risk score: 60 * 100 / 100 * 100 = 6000 bps > 5000 -> result=true.
# circuits/pattern/Prover.toml (clean anti-structuring)
amounts = [500, 1200, 3000, 7500, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
timestamps = [1700000000, 1700001000, 1700002000, 1700003000, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
num_transactions = 4
analysis_type = 1 # anti-structuring
result = true # clean
reporting_threshold = 10000 # USD-equivalent, US BSA-style
time_window = 3600
tx_set_hash = "0x2231d26d52515af30cbb6e91834cdb9e3d1d36575f160cbb4f6ebbb3c3dd8dad"
submitter = "0xdead"
settlement_root = "0" # 0 = standalone use (no downstream binding)
# circuits/attestation/Prover.toml (KYC credential)
credential_attribute = "999"
expiry_timestamp = 2000000000
merkle_index = "0"
merkle_path = ["0", "0", "0", "0", "0", "0", "0", "0", "0", "0",
"0", "0", "0", "0", "0", "0", "0", "0", "0", "0"] # 20 levels
provider_id = "42"
credential_type = 1 # KYC basic
is_valid = true
credential_root = "0x24ce58f9ed6ca066d25f66b15b0eb1dccebe6e457f5aa0fcd353d82d539f5ed5"
current_timestamp = 1700000000 # < expiry
submitter = "0xdead"
# credential_hash = H(DOMAIN_CREDENTIAL, provider_id=42, submitter=0xdead,
# credential_type=1, credential_attribute=999, expiry=2000000000)
# circuits/membership/Prover.toml (submitter is a member of set 1)
subject_salt = "0" # 0 = public set
merkle_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 = true
submitter = "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_index
high_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 = true
submitter = "0xdead"
# Adjacency check: low_leaf (0x100) < submitter (0xdead) < high_leaf (0x10000)
# AND high_index == low_index + 1.
# circuits/risk_score_signed/Prover.toml (provider-signed "risk < 50%")
signals = [25, 0, 0, 0, 0, 0, 0, 0]
weights = [100, 0, 0, 0, 0, 0, 0, 0]
weight_sum = 100
provider_ids = ["1", "0", "0", "0", "0", "0", "0", "0"]
num_providers = 1
signature = [182, 115, 199, 138, 49, 136, 65, 189, 35, 71, 72, 208, 193, 89, 65, 185,
16, 157, 119, 27, 54, 175, 210, 53, 18, 123, 253, 65, 234, 199, 103, 2,
112, 233, 231, 138, 229, 193, 168, 120, 241, 198, 7, 173, 114, 19, 157, 40,
160, 61, 75, 20, 243, 126, 101, 201, 54, 138, 59, 255, 192, 4, 184, 81]
pubkey_x = [32, 200, 84, 126, 134, 190, 235, 89, 194, 183, 197, 14, 72, 83, 155, 145,
70, 127, 150, 181, 178, 60, 72, 184, 9, 78, 30, 217, 164, 212, 226, 66]
pubkey_y = [45, 74, 60, 178, 139, 138, 55, 189, 66, 187, 156, 255, 146, 98, 182, 228,
212, 219, 184, 140, 139, 128, 126, 35, 147, 85, 247, 92, 95, 166, 66, 144]
proof_type = 1 # threshold
direction = 2 # LT
bound_lower = 5000 # asserts score < 5000 bps
bound_upper = 0
result = true
config_hash = "0x18574f427f33c6c77af53be06544bd749c9a1db855599d950af61ea613df8405"
provider_set_hash = "0x14b6becf762f80a24078e62fc9a7eca246b8e406d19962dda817b173f30a94b2"
timestamp = "1700000000"
signer_pubkey_hash = "0x294fc7a4edbc711b1c3ceceb89474957aea2974dbf3d27efe759a1ecef35d6dc"
chain_id = "31337"
oracle_address = "0x2e234dae75c793f67a35089c9d99245e1c58470b"
submitter = "0xdead"
# Signer: secp256k1 key keccak256("erc8262-integration-fixture-provider-signer"), a public
# test key. signature = low-S ECDSA over
# H_signed(DOMAIN_RISK_SIGNED_SIGNALS, 31337, oracle_address, provider_set_hash, signals,
# weights, 1700000000, 0xdead)
# = 0x0f0806548e18a3616c34e8852e1919d908f9cf1ccd5cf814c83b534d10dd9fe1.
# circuits/compliance_signed/Prover.toml (provider-signed, US)
signals = [25, 0, 0, 0, 0, 0, 0, 0]
weights = [100, 0, 0, 0, 0, 0, 0, 0]
weight_sum = 100
provider_ids = ["1", "0", "0", "0", "0", "0", "0", "0"]
num_providers = 1
signature = [138, 191, 70, 221, 230, 120, 2, 250, 16, 119, 25, 190, 110, 156, 167, 62,
113, 176, 118, 43, 122, 51, 197, 253, 239, 146, 7, 22, 71, 34, 104, 83,
3, 205, 251, 124, 84, 47, 205, 235, 247, 215, 233, 248, 116, 233, 138, 165,
178, 75, 162, 192, 132, 92, 141, 148, 127, 185, 82, 141, 99, 202, 106, 170]
pubkey_x = [32, 200, 84, 126, 134, 190, 235, 89, 194, 183, 197, 14, 72, 83, 155, 145,
70, 127, 150, 181, 178, 60, 72, 184, 9, 78, 30, 217, 164, 212, 226, 66]
pubkey_y = [45, 74, 60, 178, 139, 138, 55, 189, 66, 187, 156, 255, 146, 98, 182, 228,
212, 219, 184, 140, 139, 128, 126, 35, 147, 85, 247, 92, 95, 166, 66, 144]
jurisdiction_id = 1 # US
provider_set_hash = "0x14b6becf762f80a24078e62fc9a7eca246b8e406d19962dda817b173f30a94b2"
config_hash = "0x18574f427f33c6c77af53be06544bd749c9a1db855599d950af61ea613df8405"
timestamp = "1700000000"
meets_threshold = true
signer_pubkey_hash = "0x294fc7a4edbc711b1c3ceceb89474957aea2974dbf3d27efe759a1ecef35d6dc"
chain_id = "31337"
oracle_address = "0x2e234dae75c793f67a35089c9d99245e1c58470b"
submitter = "0xdead"
# Signer: the RISK_SCORE_SIGNED test key. signature = low-S ECDSA over
# H_signed(DOMAIN_SIGNED_SIGNALS, 31337, oracle_address, provider_set_hash, signals,
# weights, 1700000000, 0xdead).
# circuits/compliance_multi_signed/Prover.toml (US 2-of-5, slots 0 and 2 active)
signals = [[25, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[25, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0],
[0, 0, 0, 0, 0, 0, 0, 0]]
weights = [[100, 0, 0, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0, 0],
[100, 0, 0, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0, 0],
[1, 0, 0, 0, 0, 0, 0, 0]]
weight_sums = [100, 1, 100, 1, 1]
pubkey_xs = [[32, 200, 84, 126, 134, 190, 235, 89, 194, 183, 197, 14, 72, 83, 155, 145,
70, 127, 150, 181, 178, 60, 72, 184, 9, 78, 30, 217, 164, 212, 226, 66],
[198, 4, 127, 148, 65, 237, 125, 109, 48, 69, 64, 110, 149, 192, 124, 216,
92, 119, 142, 75, 140, 239, 60, 167, 171, 172, 9, 185, 92, 112, 158, 229],
[253, 204, 83, 103, 21, 78, 54, 83, 31, 229, 89, 136, 198, 83, 192, 36,
97, 182, 29, 228, 19, 233, 173, 0, 15, 134, 197, 86, 168, 195, 17, 190],
[198, 4, 127, 148, 65, 237, 125, 109, 48, 69, 64, 110, 149, 192, 124, 216,
92, 119, 142, 75, 140, 239, 60, 167, 171, 172, 9, 185, 92, 112, 158, 229],
[198, 4, 127, 148, 65, 237, 125, 109, 48, 69, 64, 110, 149, 192, 124, 216,
92, 119, 142, 75, 140, 239, 60, 167, 171, 172, 9, 185, 92, 112, 158, 229]]
pubkey_ys = [[45, 74, 60, 178, 139, 138, 55, 189, 66, 187, 156, 255, 146, 98, 182, 228,
212, 219, 184, 140, 139, 128, 126, 35, 147, 85, 247, 92, 95, 166, 66, 144],
[26, 225, 104, 254, 166, 61, 195, 57, 163, 197, 132, 25, 70, 108, 234, 238,
247, 246, 50, 101, 50, 102, 208, 225, 35, 100, 49, 169, 80, 207, 229, 42],
[15, 246, 126, 99, 123, 111, 128, 191, 152, 137, 238, 22, 206, 92, 15, 114,
232, 218, 126, 206, 193, 36, 184, 169, 129, 187, 231, 144, 86, 75, 30, 6],
[26, 225, 104, 254, 166, 61, 195, 57, 163, 197, 132, 25, 70, 108, 234, 238,
247, 246, 50, 101, 50, 102, 208, 225, 35, 100, 49, 169, 80, 207, 229, 42],
[26, 225, 104, 254, 166, 61, 195, 57, 163, 197, 132, 25, 70, 108, 234, 238,
247, 246, 50, 101, 50, 102, 208, 225, 35, 100, 49, 169, 80, 207, 229, 42]]
signatures = [[204, 73, 40, 53, 125, 29, 157, 48, 165, 157, 193, 191, 52, 146, 252, 105,
82, 168, 151, 186, 220, 169, 12, 75, 41, 199, 95, 153, 214, 33, 75, 64,
123, 156, 210, 17, 96, 88, 221, 84, 247, 242, 142, 57, 218, 156, 15, 172,
96, 242, 138, 212, 197, 181, 83, 199, 103, 184, 237, 244, 242, 164, 224, 53],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
[88, 175, 244, 218, 171, 130, 90, 176, 10, 110, 162, 173, 198, 210, 91, 167,
35, 0, 184, 82, 108, 214, 1, 96, 247, 50, 122, 115, 33, 187, 127, 37,
127, 160, 104, 4, 235, 108, 16, 182, 1, 18, 206, 203, 160, 162, 96, 114,
254, 136, 27, 176, 160, 192, 26, 121, 170, 113, 246, 44, 189, 55, 71, 68],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]]
jurisdiction_id = 1 # US
provider_set_hash = "0x14b6becf762f80a24078e62fc9a7eca246b8e406d19962dda817b173f30a94b2"
config_hash = "0x18574f427f33c6c77af53be06544bd749c9a1db855599d950af61ea613df8405"
timestamp = "1700000000"
meets_threshold = true
threshold_m = 2 # US floor
signer_pubkey_hash_0 = "0x294fc7a4edbc711b1c3ceceb89474957aea2974dbf3d27efe759a1ecef35d6dc"
signer_pubkey_hash_1 = "0"
signer_pubkey_hash_2 = "0x2eb905640e16a7a7cb2fc08f412e4db744621a9b8ea6c760cedeefc5004e1b20"
signer_pubkey_hash_3 = "0"
signer_pubkey_hash_4 = "0"
chain_id = "31337"
oracle_address = "0x2e234dae75c793f67a35089c9d99245e1c58470b"
submitter = "0xdead"
# Slot 0 signer: the RISK_SCORE_SIGNED test key. Slot 2 signer: secp256k1 key
# keccak256("erc8262-integration-fixture-provider-signer-2"), a public test key.
# Each active slot signs H_multi_slot(slot_index, 31337, oracle_address, 1,
# provider_set_hash, config_hash, signals[i], weights[i], 1700000000, 0xdead).
# Inactive slots (1, 3, 4): signer_pubkey_hash 0, signals 0, weights [1, 0, ...],
# weight_sum 1, pubkey 2G, signature r = s = 1.
Signed-variant witnesses (COMPLIANCE_SIGNED, RISK_SCORE_SIGNED) match their unsigned siblings in the screening payload, plus pubkey_x, pubkey_y, signature, signer_pubkey_hash, chain_id, and oracle_address (RISK_SCORE_SIGNED also adds the public timestamp). The provider signs H_signed(domain, chain_id, oracle_address, provider_set_hash, signals, weights, timestamp, submitter) off-chain with the proof type’s domain tag. In COMPLIANCE_MULTI_SIGNED (0x09) each active slot supplies its own (signals, weights, weight_sum, pubkey_x, pubkey_y, signature) and non-zero signer_pubkey_hash, signing its slot-specific H_multi_slot digest. The signed fixtures carry precomputed signatures from public test keys; implementations producing new signed fixtures must sample fresh ECDSA nonces.
Reference Implementation
A reference implementation is in the assets directory. It covers the oracle, the verifier router, the interfaces, the COMPLIANCE (0x01) circuit, and the fixtures for all nine proof types; the other eight circuits follow Per-Type Circuit Specifications.
- Oracle:
ERC8262Oracle.sol. Proof submission, per-proof-type semantic validation, attestation storage and lifecycle, and the validation registries. Each proof type’s_validate*Inputsfunction lists its public inputs at their byte offsets and is the normative counterpart to Public Input Validation. - Router:
ERC8262Verifier.sol. Per-proof-type verifier registry, append-only version history, timelocked upgrades, and batch verification (Foundry, Solidity 0.8.28, Cancun EVM). The router validates only public-input shape (alignment and count, viaProofTypes.validatePublicInputs); the Oracle does all semantic validation before calling it. - Interfaces and libraries: the verifier interface, oracle interface, proof-type IDs and public-input shape validation, per-jurisdiction policy, and access-control roles.
- Circuit: the COMPLIANCE (0x01) main.nr, in Noir, compiled to an UltraHonk verifier by Barretenberg. Pinned tool versions and licenses are in the reference implementation README.
- Fixtures: proofs and public inputs for all nine proof types under
test/fixtures/, regenerated from each circuit’sProver.tomlbygenerate-fixtures.sh(see Test Cases).
Per-circuit UltraHonk verifiers (~100 KB of Solidity each) are build artifacts reproducible from the circuits; they are omitted here and registered into the router by address at deploy time.
Some raw bb-generated verifiers exceed the EIP-170 24,576-byte runtime limit. Rewriting the pairing() free function in inline Yul saves ~186 bytes per verifier (and ~800 gas per verifyProof call) with semantics identical to bb output on the pairing precompile (address(0x08)) input layout.
Security Considerations
Proof soundness. Security reduces to the soundness of the ZK proof system; a weak system lets a prover forge a passing attestation without satisfying the circuit. Proof System Requirements sets the 128-bit floor.
Provider collusion. Colluding providers holding a weighted majority (or a sole provider under unsigned COMPLIANCE) can issue false clean signals undetectably. COMPLIANCE_MULTI_SIGNED (0x09) with threshold_m >= 2 is the strongest in-protocol defense, with minMultiProviderThreshold as a per-jurisdiction floor (Risk Score Computation).
Timestamp manipulation. Block proposers control block.timestamp within the parent-block constraint: acceptable for day-scale compliance windows, not for fine-grained ordering or liveness. Integrators that need finer-grained ordering must use an explicit nonce or sequence number. In-circuit bounds are in Circuit Constraints.
Regulatory acceptance. Whether a jurisdiction accepts ZK proofs as compliance evidence is a legal question. The VARA (Dubai) definition of “anonymity-enhanced crypto” excludes assets with “mitigating technologies” for traceability, which this standard provides.
Front-running the oracle. A proof submission in the mempool signals an imminent trade, though its details stay hidden. Integrators can batch submissions or submit within the settlement transaction; this is deployment policy, not a requirement.
Administrative operations. A single compromised admin key could pause the Oracle and rewrite its registries in one transaction; see Administrative Operations. The reference role split is in AccessControl.sol, and its verifier upgrade and revocation delays (24 h and 6 h) are in ERC8262Verifier.sol.
Public input validation. Unvalidated inputs let a prover replay a proof from a lenient context (e.g., a permissive reporting threshold) into a stricter one (Public Input Validation). A proof with a false result field is a valid proof of non-compliance; accepting it would record a compliant attestation for a non-compliant subject (Proof Result Validation).
Proof replay prevention. Proof Hash Computation scopes attestations to one (chain, Oracle, proofType), and the _usedProofs guard blocks resubmission of the same proof within one Oracle. In-circuit chain/Oracle binding exists only for the signed variants (Public Input Validation).
Signed-payload type binding and expiry. _usedProofs rejects repeated proof bytes, not repeated signed payloads: one provider signature can back any number of distinct proofs. Two properties bound it. Commitment Layouts gives each signed type its own H_signed domain tag, so a Compliance Signed (0x07) payload cannot satisfy Risk Score Signed (0x08) or the reverse. Both variants expose the signed timestamp, which Public Input Validation holds to the maximum proof age and uses as the ratchet value, so a signature stops minting attestations once that window passes. Without both, any 0x08 signature a provider ever issued, or any 0x07 bundle, could mint fresh 0x08 attestations indefinitely.
Config and root revocation. Without revocation, a flawed configuration or compromised merkle tree stays accepted forever. Rules are in Provider Weight Publication and Validation Registries.
Verifier TOCTOU. A verifier upgrade landing between resolution and verification could record a verifierUsed other than the verifier that validated the proof; Verifier Versioning requires single resolution.
Batch verification limits. Batch cost grows linearly (~2.4 M gas per UltraHonk proof); Batch Verification Limits requires a cap. Reference figures, measured on Cancun EVM with the Test Cases fixtures; other proof systems and circuits 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 cost is dominated by in-circuit ECDSA-secp256k1, which roughly doubles off-chain proving time; on-chain verifyProof stays the same order of magnitude as the unsigned variants. Chains with larger block budgets can raise the batch cap; chains with smaller 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; submitComplianceBatch amortizes the per-entry fixed cost.
Registry idempotency. A silent no-op on duplicate registration or missing revocation could leave operators believing a state change landed (Validation Registries).
Emergency circuit break. Pause Mechanism contains a circuit or verifier vulnerability without redeploying, keeping proof-of-innocence reads live; per-proof-type pause limits the blast radius.
Trust model and signal honesty. The unsigned variants (COMPLIANCE, RISK_SCORE) are self-attested: the circuit verifies the score formula, not the signals (What this standard does NOT prove). The per-jurisdiction policy (Public Input Validation) rejects them in strict-mode jurisdictions (US BSA, Singapore, UAE VARA in the reference). Integrators in permissive jurisdictions needing signal honesty should require the signed variants, optionally composed with ATTESTATION against an independently published credential tree (Trust Tier Disclosure).
ATTESTATION authority root. The credential leaf H(DOMAIN_CREDENTIAL, provider_id, submitter, type, attribute, expiry) binds the credential to the submitter; forging a leaf requires breaking Pedersen preimage resistance. No provider signature over the leaf or root is verified in-circuit: authority resolves to the registered publisher EOA (Validation Registries). A compromised publisher EOA can publish arbitrary (submitter, attribute) pairs until the owner rotates the publisher or revokes the root. Deployments with that threat can layer an in-circuit signature over the root or leaf; this specification does not require it.
Cross-chain replay. The unsigned proof types (COMPLIANCE, RISK_SCORE, PATTERN, ATTESTATION, MEMBERSHIP, NON_MEMBERSHIP) have no chain identifier public input, so the same proof bytes can be replayed on another chain or against another Oracle deployment, producing independent attestations. The proof-hash guard (Proof Hash Computation) blocks replay only within one (chain, Oracle) pair. The signed variants’ digest commits to (chain_id, oracle_address, ...) and Public Input Validation checks both, so replaying them requires forging an ECDSA signature. Deployments that need this for unsigned proofs can fork the unsigned circuits to add chain_id and oracle_address public inputs, at the cost of a new verifier per chain.
Verifier-layer reentrancy. A non-view verifier could reenter the Oracle and mutate attestation state; Verifier Interface requires view, so the call uses STATICCALL.
Copyright
Copyright and related rights waived via CC0.