// SPDX-License-Identifier: MIT pragma solidity 0.8.24; /// @title ThresholdConsensusV3 /// @notice Small on-chain threshold policy for settlement/evidence conclusions. /// @dev A conclusion is keyed by (schema, subject, nonce). All votes for that /// key must agree on expiry, immutable settlement snapshot, and outcome. contract ThresholdConsensusV3 { bytes32 public constant GOVERNANCE_POLICY_MODULE_ID = keccak256("Vaultfire.BaseV3.ThresholdConsensus"); /// @notice A single attester can never finalize a V3 conclusion. /// @dev Production manifests use 3-of-5; two is retained only as the /// smallest fail-closed policy for isolated test and non-pilot use. uint8 public constant MINIMUM_THRESHOLD = 2; struct Claim { bytes32 schema; bytes32 subject; uint96 nonce; uint64 expiresAt; bytes32 settlementSnapshot; bytes32 outcome; } struct Record { uint64 expiresAt; uint8 approvals; bool finalized; /// @notice Attester/threshold configuration under which approvals were cast. uint256 configVersion; bytes32 settlementSnapshot; bytes32 outcome; } address public owner; uint8 public threshold; uint8 public attesterCount; mapping(address => bool) public attester; mapping(bytes32 => Record) internal _records; /// @notice Bumps whenever an attester is added/removed or the threshold changes. /// @dev Partial conclusions from an older configuration must never be completed /// with approvals from the replacement configuration. uint256 public attesterConfigVersion; mapping(bytes32 => mapping(address => uint256)) private _voteConfigVersion; error NotOwner(); error NotAttester(); error ZeroAddress(); error BadThreshold(); error ExpiredClaim(); error DuplicateVote(); error ConflictingClaim(); error AlreadyFinalized(); event AttesterSetV3(address indexed attester, bool authorized); event ThresholdSetV3(uint8 threshold); event AttesterConfigVersionUpdatedV3(uint256 indexed configVersion); event ClaimAttestedV3(bytes32 indexed claimKey, address indexed attester, uint8 approvals); event ClaimFinalizedV3(bytes32 indexed claimKey, bytes32 indexed outcome, bytes32 settlementSnapshot); constructor(address _owner, uint8 _threshold) { if (_owner == address(0)) revert ZeroAddress(); owner = _owner; if (_threshold < MINIMUM_THRESHOLD) revert BadThreshold(); threshold = _threshold; attesterConfigVersion = 1; } modifier onlyOwner() { if (msg.sender != owner) revert NotOwner(); _; } function setAttester(address account, bool authorized) external onlyOwner { if (account == address(0)) revert ZeroAddress(); if (attester[account] != authorized) { attester[account] = authorized; attesterCount = authorized ? attesterCount + 1 : attesterCount - 1; _bumpAttesterConfigVersion(); } emit AttesterSetV3(account, authorized); } function setThreshold(uint8 newThreshold) external onlyOwner { if (newThreshold < MINIMUM_THRESHOLD || newThreshold > attesterCount) revert BadThreshold(); if (newThreshold != threshold) { threshold = newThreshold; _bumpAttesterConfigVersion(); } emit ThresholdSetV3(newThreshold); } function claimKey(bytes32 schema, bytes32 subject, uint96 nonce) public pure returns (bytes32) { return keccak256(abi.encode(schema, subject, nonce)); } function attest(Claim calldata claim) external { if (!attester[msg.sender]) revert NotAttester(); if (threshold == 0 || threshold > attesterCount) revert BadThreshold(); if (claim.expiresAt <= block.timestamp) revert ExpiredClaim(); if ( claim.schema == bytes32(0) || claim.subject == bytes32(0) || claim.settlementSnapshot == bytes32(0) || claim.outcome == bytes32(0) ) revert ConflictingClaim(); bytes32 key = claimKey(claim.schema, claim.subject, claim.nonce); Record storage r = _records[key]; if (r.finalized) revert AlreadyFinalized(); if (r.configVersion != attesterConfigVersion) { r.expiresAt = claim.expiresAt; r.settlementSnapshot = claim.settlementSnapshot; r.outcome = claim.outcome; r.approvals = 0; r.configVersion = attesterConfigVersion; } else if ( r.expiresAt != claim.expiresAt || r.settlementSnapshot != claim.settlementSnapshot || r.outcome != claim.outcome ) { revert ConflictingClaim(); } if (_voteConfigVersion[key][msg.sender] == r.configVersion) revert DuplicateVote(); _voteConfigVersion[key][msg.sender] = r.configVersion; r.approvals += 1; emit ClaimAttestedV3(key, msg.sender, r.approvals); if (r.approvals >= threshold) { r.finalized = true; emit ClaimFinalizedV3(key, r.outcome, r.settlementSnapshot); } } function isFinalized(bytes32 schema, bytes32 subject, uint96 nonce, bytes32 settlementSnapshot, bytes32 outcome) external view returns (bool) { Record storage r = _records[claimKey(schema, subject, nonce)]; return r.finalized && block.timestamp < r.expiresAt && r.settlementSnapshot == settlementSnapshot && r.outcome == outcome; } /// @notice Whether `account` voted under the record's stored configuration. /// @dev Preserves the previous public-mapping getter ABI. Historical votes /// remain visible on a finalized record so downstream self-attestation /// guards stay conservative after a later configuration rotation. function voted(bytes32 key, address account) external view returns (bool) { Record storage r = _records[key]; return r.configVersion != 0 && _voteConfigVersion[key][account] == r.configVersion; } function recordOf(bytes32 schema, bytes32 subject, uint96 nonce) external view returns (Record memory) { return _records[claimKey(schema, subject, nonce)]; } function _bumpAttesterConfigVersion() private { unchecked { ++attesterConfigVersion; } emit AttesterConfigVersionUpdatedV3(attesterConfigVersion); } }