// SPDX-License-Identifier: MIT pragma solidity 0.8.24; /// @title StreetCredV3 /// @notice Versioned, explainable reputation. Every score carries evidence /// provenance and a schema version. Sybil-flagged addresses cannot /// boost their own score. contract StreetCredV3 { uint256 public constant MAX_RECOMPUTE_LOOKBACK = 128; // --------------------------------------------------------------------- // Types // --------------------------------------------------------------------- struct Signal { uint32 schemaVersion; uint32 weightBps; // 0..10000 contribution to score bytes32 evidenceCommitment; // merkle root or content hash uint8 classification; // 0=self, 1=econ-backed, 2=verified, 3=disputed, 4=expired, 5=legacy, 6=cross-chain address attester; uint64 observedAt; uint64 expiresAt; bool negative; } struct Score { uint32 schemaVersion; uint32 scoreBps; uint32 evidenceCount; bytes32 evidenceRoot; uint64 lastUpdated; bool sybilFlagged; } mapping(bytes32 => Score) public scoreOf; // key = keccak256(chainId, agentIdOrWallet) mapping(bytes32 => Signal[]) internal _signals; // ordered signals for auditability mapping(address => bool) public authorizedAttester; address public owner; // --------------------------------------------------------------------- // Errors + Events // --------------------------------------------------------------------- error NotOwner(); error UnauthorizedAttester(); error SybilFlagged(); error ZeroAddress(); error InvalidWeight(); error InvalidLookback(); error AttesterMismatch(); error InvalidSignal(); event AttesterSetV3(address indexed attester, bool authorized); event SybilFlaggedV3(bytes32 indexed subject, bool flagged); event SignalAddedV3(bytes32 indexed subject, uint256 index, uint8 classification, uint32 weightBps, bool negative); event ScoreUpdatedV3(bytes32 indexed subject, uint32 scoreBps, uint32 evidenceCount, bytes32 evidenceRoot); modifier onlyOwner() { if (msg.sender != owner) revert NotOwner(); _; } constructor(address _owner) { if (_owner == address(0)) revert ZeroAddress(); owner = _owner; } function setAttester(address a, bool v) external onlyOwner { authorizedAttester[a] = v; emit AttesterSetV3(a, v); } function setSybilFlag(bytes32 subject, bool flag) external onlyOwner { scoreOf[subject].sybilFlagged = flag; emit SybilFlaggedV3(subject, flag); } // --------------------------------------------------------------------- // Attestation ingress // --------------------------------------------------------------------- function addSignal(bytes32 subject, Signal calldata s) external { if (!authorizedAttester[msg.sender]) revert UnauthorizedAttester(); if (s.attester != msg.sender) revert AttesterMismatch(); if (s.weightBps > 10_000) revert InvalidWeight(); if ( subject == bytes32(0) || s.evidenceCommitment == bytes32(0) || s.classification > 6 || s.observedAt > block.timestamp || (s.expiresAt != 0 && s.expiresAt <= s.observedAt) ) revert InvalidSignal(); Score storage sc = scoreOf[subject]; if (sc.sybilFlagged && !s.negative) revert SybilFlagged(); _signals[subject].push(s); emit SignalAddedV3(subject, _signals[subject].length - 1, s.classification, s.weightBps, s.negative); } /// @notice Recompute score from the last N signals. Bounded; no unbounded loops. /// Invariant #12. Weighted differently per classification with EXPLICIT weights. function recompute(bytes32 subject, uint256 lookback) external { if (!authorizedAttester[msg.sender]) revert UnauthorizedAttester(); if (lookback > MAX_RECOMPUTE_LOOKBACK) revert InvalidLookback(); Signal[] storage arr = _signals[subject]; uint256 n = arr.length; if (lookback > n) lookback = n; uint256 startIdx = n - lookback; uint256 positive = 0; uint256 negative = 0; uint256 denom = 0; uint32 included = 0; bytes32 evidenceRoot = bytes32(0); for (uint256 i = startIdx; i < n; ++i) { Signal storage sig = arr[i]; if (sig.expiresAt != 0 && sig.expiresAt <= block.timestamp) continue; uint256 factor = _classificationWeight(sig.classification); uint256 contrib = uint256(sig.weightBps) * factor; if (sig.negative) { negative += contrib; } else { positive += contrib; } denom += factor; ++included; evidenceRoot = keccak256( abi.encode( evidenceRoot, i, sig.schemaVersion, sig.weightBps, sig.evidenceCommitment, sig.classification, sig.attester, sig.observedAt, sig.expiresAt, sig.negative ) ); } uint256 net = positive > negative ? positive - negative : 0; uint32 score = denom == 0 ? 0 : uint32(net / denom); Score storage sc = scoreOf[subject]; sc.schemaVersion = 1; sc.scoreBps = score; sc.evidenceCount = included; sc.evidenceRoot = evidenceRoot; sc.lastUpdated = uint64(block.timestamp); emit ScoreUpdatedV3(subject, score, sc.evidenceCount, sc.evidenceRoot); } function _classificationWeight(uint8 c) private pure returns (uint256) { // Self-asserted = 1, econ-backed = 3, independently verified = 5, // disputed = 0, expired = 0, legacy = 1, cross-chain = 2. if (c == 0) return 1; if (c == 1) return 3; if (c == 2) return 5; if (c == 3) return 0; if (c == 4) return 0; if (c == 5) return 1; if (c == 6) return 2; return 0; } function signalCount(bytes32 subject) external view returns (uint256) { return _signals[subject].length; } function signalAt(bytes32 subject, uint256 i) external view returns (Signal memory) { return _signals[subject][i]; } }