aere-contracts/contracts/compliance/AereBitstringStatusList.sol
Aere Network a13a649b77
Some checks are pending
contracts-ci / Install (lockfile) → compile → full test suite (push) Waiting to run
contracts-ci / PQC known-answer tests (NIST vectors) (push) Waiting to run
contracts-ci / Coverage (scoped, with artifacts) (push) Waiting to run
Initial public release
Aere Network public source. Everything here can be checked against the live
chain (chain id 2800, https://rpc.aere.network).

Scope note, stated up front rather than buried: consensus on chain 2800 is
classical secp256k1 ECDSA QBFT. The post-quantum work in this repository is at
the signature, precompile, account and transport layers. Nothing here makes the
consensus post-quantum, and no document in it should be read as claiming so.
2026-07-20 01:02:37 +03:00

346 lines
18 KiB
Solidity

// SPDX-License-Identifier: MIT
pragma solidity 0.8.23;
/// @dev Matches Succinct's canonical SP1 verifier / SP1VerifierGateway ABI: verifyProof RETURNS
/// NOTHING and REVERTS on an invalid proof. Used here as the (future) NON-REVOCATION proof
/// verifier. See AereZKScreen.sol for the same pattern already used in Aere's compliance stack.
interface INonRevocationVerifier {
function verifyProof(bytes32 programVKey, bytes calldata publicValues, bytes calldata proof) external view;
}
/**
* @title AereBitstringStatusList, a W3C Bitstring Status List for credential revocation / suspension
*
* @notice An on-chain implementation of the W3C Bitstring Status List v1.0 mechanism. A status list
* is a compact bitset; every credential is assigned a status INDEX into a list, and the bit
* at that index encodes the credential's status (default 0 = active; 1 = revoked or
* suspended, per the list's purpose). Revoking a credential flips one bit. Because a single
* list packs many credentials, a verifier learns only "the bit at index i", which gives herd
* privacy: it cannot tell WHICH credential a status update concerns from the list alone.
*
* PURPOSES. A list is created for exactly one W3C statusPurpose:
* - Revocation: a status change is PERMANENT. Once a bit is set it can never be cleared
* (monotonic), matching revocation's terminal semantics.
* - Suspension: a status change is REVERSIBLE. A bit may be set and later cleared.
*
* ZERO-KNOWLEDGE NON-REVOCATION (see the zk section below). The plain on-chain read
* statusOf(listId, index) is public and reveals the queried index. For privacy-preserving
* verification, a holder can instead prove in zero knowledge that "the status bit of the
* credential I hold is 0" WITHOUT revealing which index it is, binding the proof to the list's
* current committed state. This file defines that verifier interface and the exact
* public-input shape; the SP1 circuit that produces such a proof is [MEASURE] (to be
* implemented against Aere's existing SP1 zk stack, the same stack behind AereZKScreen).
*
* @dev HONEST SCOPE. Application-layer compliance tooling. Holds no funds; changes nothing about
* Aere consensus (classical secp256k1 ECDSA QBFT). No PII on chain: a status list is an
* anonymous bitset keyed by numeric index only.
*/
contract AereBitstringStatusList {
/// @notice W3C statusPurpose for a list.
enum Purpose {
Revocation,
Suspension
}
struct List {
address controller; // the party (typically the issuer) that owns this list's bits
Purpose purpose;
uint256 capacity; // number of status entries (bits) in the list
uint64 createdAt;
uint64 epoch; // increments on every bit mutation (monotonic version counter)
uint64 lastMutatedAt;
bytes32 publishedRoot; // controller-attested commitment to the CURRENT bitstring [MEASURE]
uint64 rootEpoch; // the epoch publishedRoot was published at (freshness anchor)
}
// listId is the index into _lists.
List[] private _lists;
// listId => wordIndex => 256-bit packed status word (bit j of word w is entry w*256 + j).
mapping(uint256 => mapping(uint256 => uint256)) private _words;
// ---- zero-knowledge non-revocation program (network-level config) ----
/// @notice The admin allowed to publish the non-revocation program vkey (the deployer). This is
/// intentionally NOT the per-list controller: the circuit is a network artifact.
address public immutable ADMIN;
/// @notice The SP1 verifier (SP1VerifierGateway) that checks a non-revocation proof. May be the
/// zero address at deploy, in which case the zk non-revocation path is fail-closed off
/// until configured. Immutable once set at construction.
INonRevocationVerifier public immutable NON_REVOCATION_VERIFIER;
/// @notice The SP1 program verification key for the non-revocation circuit. Zero until the
/// circuit is built and its vkey published. [MEASURE]: the circuit itself is pending.
bytes32 public nonRevocationVKey;
event ListCreated(uint256 indexed listId, address indexed controller, Purpose purpose, uint256 capacity);
event StatusSet(uint256 indexed listId, uint256 indexed index, bool value, uint64 epoch);
event StatusRootPublished(uint256 indexed listId, bytes32 root, uint64 epoch);
event NonRevocationVKeySet(bytes32 oldVKey, bytes32 newVKey);
error NotAdmin();
error NotController(uint256 listId);
error UnknownList(uint256 listId);
error ZeroCapacity();
error IndexOutOfRange(uint256 listId, uint256 index);
error RevocationIsTerminal(uint256 listId, uint256 index); // cannot clear a revocation bit
error NoStatusChange(uint256 listId, uint256 index); // the bit already holds `value`
error NonRevocationNotConfigured();
error VerifierHasNoCode();
constructor(address nonRevocationVerifier_) {
ADMIN = msg.sender;
// A non-zero but CODE-LESS verifier would make the try/verifyProof in verifyNonRevocation
// succeed silently (verifyProof returns no data), so a revoked credential would prove "not
// revoked" = fail-open. Guard it exactly as the sibling AereFinalityCertificateVerifier does.
// The zero address is still allowed: it keeps the zk non-revocation path fail-closed OFF until
// a real gateway is configured (nonRevocationConfigured() stays false).
if (nonRevocationVerifier_ != address(0) && nonRevocationVerifier_.code.length == 0) {
revert VerifierHasNoCode();
}
NON_REVOCATION_VERIFIER = INonRevocationVerifier(nonRevocationVerifier_); // zero allowed
}
// ==========================================================================
// Lists
// ==========================================================================
/// @notice Create a status list for a single W3C statusPurpose. msg.sender becomes its controller
/// (typically the credential issuer). Returns the new listId.
/// @param purpose Revocation (terminal bits) or Suspension (reversible bits).
/// @param capacity the number of status entries (bits) the list holds. [VERIFY] W3C recommends a
/// large minimum (e.g. 131072) for herd privacy; that is a deployment policy
/// choice and is NOT enforced here.
function createList(Purpose purpose, uint256 capacity) external returns (uint256 listId) {
if (capacity == 0) revert ZeroCapacity();
listId = _lists.length;
_lists.push(
List({
controller: msg.sender,
purpose: purpose,
capacity: capacity,
createdAt: uint64(block.timestamp),
epoch: 0,
lastMutatedAt: uint64(block.timestamp),
publishedRoot: bytes32(0),
rootEpoch: 0
})
);
emit ListCreated(listId, msg.sender, purpose, capacity);
}
/// @notice Set the status bit of a credential index. Controller-only.
/// Revocation lists: `value` must be true and the bit must currently be 0 (monotonic,
/// terminal). Suspension lists: `value` may be true or false (reversible). Reverts if the
/// bit already equals `value` (no silent no-op).
function setStatus(uint256 listId, uint256 index, bool value) public {
List storage l = _requireList(listId);
if (msg.sender != l.controller) revert NotController(listId);
if (index >= l.capacity) revert IndexOutOfRange(listId, index);
bool current = _getBit(listId, index);
if (current == value) revert NoStatusChange(listId, index);
if (l.purpose == Purpose.Revocation && !value) revert RevocationIsTerminal(listId, index);
_setBit(listId, index, value);
unchecked {
l.epoch += 1;
}
l.lastMutatedAt = uint64(block.timestamp);
emit StatusSet(listId, index, value, l.epoch);
}
/// @notice Convenience: revoke (set bit to 1) the credential at `index` on a Revocation list.
function revoke(uint256 listId, uint256 index) external {
setStatus(listId, index, true);
}
/// @notice Publish a controller-attested commitment to the CURRENT bitstring, anchored to the
/// current epoch. A zk non-revocation proof (below) binds to this root, so a verifier
/// accepts a proof only against an up-to-date root.
/// @dev [MEASURE] The root is controller-attested in this version: the controller is trusted
/// to publish a commitment matching the live bits it just wrote. A future version derives
/// the root on-chain (or inside the SP1 VM) from getWord so it is trustless. The raw words
/// are exposed by getWord for independent recomputation regardless.
function publishStatusRoot(uint256 listId, bytes32 root) external {
List storage l = _requireList(listId);
if (msg.sender != l.controller) revert NotController(listId);
l.publishedRoot = root;
l.rootEpoch = l.epoch;
emit StatusRootPublished(listId, root, l.epoch);
}
// ==========================================================================
// Reads
// ==========================================================================
/// @notice The status bit of a credential index (true = revoked/suspended per the list purpose).
function statusOf(uint256 listId, uint256 index) external view returns (bool) {
List storage l = _requireList(listId);
if (index >= l.capacity) revert IndexOutOfRange(listId, index);
return _getBit(listId, index);
}
/// @notice The raw 256-bit packed word at `wordIndex` (entries [wordIndex*256, wordIndex*256+255]).
/// Exposed so anyone can recompute a commitment over the full bitstring off-chain.
function getWord(uint256 listId, uint256 wordIndex) external view returns (uint256) {
_requireList(listId);
return _words[listId][wordIndex];
}
/// @notice The number of 256-bit words backing a list's capacity.
function wordCount(uint256 listId) external view returns (uint256) {
List storage l = _requireList(listId);
return (l.capacity + 255) / 256;
}
/// @notice Number of lists ever created (listId runs 0..listCount-1).
function listCount() external view returns (uint256) {
return _lists.length;
}
/// @notice Read a list's metadata. Reverts on an unknown listId.
function getList(uint256 listId)
external
view
returns (
address controller,
Purpose purpose,
uint256 capacity,
uint64 createdAt,
uint64 epoch,
uint64 lastMutatedAt,
bytes32 publishedRoot,
uint64 rootEpoch
)
{
List storage l = _requireList(listId);
return (l.controller, l.purpose, l.capacity, l.createdAt, l.epoch, l.lastMutatedAt, l.publishedRoot, l.rootEpoch);
}
// ==========================================================================
// Zero-knowledge non-revocation
// ==========================================================================
//
// GOAL. Let a credential HOLDER prove "the status bit of my credential is 0" (i.e. my credential
// is NOT revoked) WITHOUT revealing which status index it is, and bind that proof to the list's
// current on-chain state so a stale proof is rejected.
//
// PUBLIC-INPUT SHAPE (the SP1 program's committed public values), ABI-encoded exactly as:
// abi.encode(
// uint256 chainId, // must equal block.chainid
// address statusListContract, // must equal address(this)
// uint256 statusListId, // the list the credential's status lives in
// bytes32 statusRoot, // commitment to the list bitstring the proof was made against
// bytes32 credentialCommitment,// hiding commitment to the holder's credential (binds a real
// // anchored credential without revealing its index)
// uint8 purpose // must equal uint8(list.purpose)
// )
//
// IN-CIRCUIT WITNESS (private, [MEASURE] to be implemented):
// - the status index i of the holder's credential,
// - an opening of `statusRoot` at i proving bitstring[i] == 0,
// - an opening of `credentialCommitment` to the credential (issuer, type, index i),
// so the statement proven is: "there is an index i, committed by credentialCommitment, whose
// bit under statusRoot is 0", revealing neither i nor the credential.
//
// ON-CHAIN BINDING. verifyNonRevocation enforces chainId / contract / listId / purpose, and
// requires statusRoot to equal the list's publishedRoot AT THE CURRENT EPOCH (rootEpoch == epoch),
// so a proof is only accepted against the freshest committed state. It then delegates soundness of
// the bit-is-0 statement to the SP1 verifier. FAIL-CLOSED if the verifier or vkey is unconfigured.
/// @notice The canonical ABI type string of the non-revocation public inputs (for off-chain tooling).
string public constant NON_REVOCATION_PUBLIC_VALUES_ABI =
"(uint256 chainId,address statusListContract,uint256 statusListId,bytes32 statusRoot,bytes32 credentialCommitment,uint8 purpose)";
/// @notice Publish the SP1 non-revocation program vkey (admin-only). Settable because the circuit
/// is [MEASURE]: once built and its vkey known, the admin sets it; setting it to a fresh
/// value is how the program is upgraded. Emits the transition for auditability.
function setNonRevocationVKey(bytes32 vkey) external {
if (msg.sender != ADMIN) revert NotAdmin();
bytes32 old = nonRevocationVKey;
nonRevocationVKey = vkey;
emit NonRevocationVKeySet(old, vkey);
}
/// @notice True once a non-revocation verifier AND a program vkey are both configured.
function nonRevocationConfigured() public view returns (bool) {
return address(NON_REVOCATION_VERIFIER) != address(0) && nonRevocationVKey != bytes32(0);
}
/**
* @notice Verify a zero-knowledge NON-REVOCATION proof for `listId` against its current committed
* state. Returns true iff (a) the public inputs bind this chain, this contract, this list
* and its purpose, (b) the proof's statusRoot equals the list's publishedRoot at the
* current epoch (freshness), and (c) the SP1 verifier accepts the proof. FAIL-CLOSED:
* returns false (never reverts) on any binding mismatch or invalid proof, and reverts
* with NonRevocationNotConfigured only when the program is not yet configured, so callers
* cannot mistake "unconfigured" for "not revoked".
*
* @dev [MEASURE] The SP1 circuit that produces the proof is not yet implemented; when it is,
* its vkey is published via setNonRevocationVKey and the verifier accepts real proofs. The
* on-chain binding here is complete and final; only the circuit is pending.
*/
function verifyNonRevocation(uint256 listId, bytes calldata publicValues, bytes calldata proof)
external
view
returns (bool)
{
List storage l = _requireList(listId);
if (!nonRevocationConfigured()) revert NonRevocationNotConfigured();
// Exact length check (6 * 32 = 192) prevents a trailing-bytes decode spoof.
if (publicValues.length != 192) return false;
(
uint256 chainId,
address statusListContract,
uint256 statusListId,
bytes32 statusRoot,
, // credentialCommitment: bound inside the circuit, not re-checked on chain
uint8 purpose
) = abi.decode(publicValues, (uint256, address, uint256, bytes32, bytes32, uint8));
if (chainId != block.chainid) return false;
if (statusListContract != address(this)) return false;
if (statusListId != listId) return false;
if (purpose != uint8(l.purpose)) return false;
// Freshness: the proof must be against the current published root (root must be up to date).
if (l.publishedRoot == bytes32(0)) return false;
if (l.rootEpoch != l.epoch) return false;
if (statusRoot != l.publishedRoot) return false;
// Delegate the "bit is 0 for a hidden index" statement to the SP1 verifier (reverts if invalid).
try NON_REVOCATION_VERIFIER.verifyProof(nonRevocationVKey, publicValues, proof) {
return true;
} catch {
return false;
}
}
// ==========================================================================
// Internal
// ==========================================================================
function _requireList(uint256 listId) internal view returns (List storage l) {
if (listId >= _lists.length) revert UnknownList(listId);
l = _lists[listId];
}
function _getBit(uint256 listId, uint256 index) internal view returns (bool) {
uint256 word = _words[listId][index >> 8];
return ((word >> (index & 0xff)) & 1) == 1;
}
function _setBit(uint256 listId, uint256 index, bool value) internal {
uint256 wordIndex = index >> 8;
uint256 mask = uint256(1) << (index & 0xff);
uint256 word = _words[listId][wordIndex];
if (value) {
word |= mask;
} else {
word &= ~mask;
}
_words[listId][wordIndex] = word;
}
}